diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md new file mode 100644 index 0000000000..2ad8abafa2 --- /dev/null +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md @@ -0,0 +1,110 @@ +# Default cutover: reconciled implementation frontier + +- Baseline: `d64c4d377` on `main`, 2026-09-24; open PR states are a snapshot, not merge promises. +- Owners: overall roadmap #4574 R5/G2; shared authority L2–L9/D1–D3; TS migration T1–T4. +- Delivery: complete source transport through existing typed projection and shadow management owners. +- This checkpoint supersedes numerical remaining-PR estimates in earlier delivery entries. + +## Correct the accounting + +The previous “5–8”, “6–8” and “7–9” numbers counted broad work packages as +remaining PRs, then retained the estimate after parts landed and additional +prerequisites appeared. They are not an audited PR backlog and are withdrawn. +A code gap, an open PR, integration acceptance, an elapsed-time qualification +and a maintainer cutover decision are different units. Do not add or decrement +them as though they were interchangeable PRs. + +| Evidence at this baseline | Current disposition | +| --- | --- | +| #4870 claim-preserving writes; #4888 reviewed cutover; #4920 drain planning | Implemented. Exercise their combined head; do not commission replacements. | +| #4922 complete canonical snapshot pagination; #4960 qualified SQLite runtime admission; #4961 display refresh recovery; #4964 shared source summaries | Implemented. Consumer and packaged-client acceptance still needs integration evidence; a whole new pagination/recovery implementation is not pending. | +| #4967 typed complete-source assembly; #4968 native outbox delivery/recovery | Implemented. Large source RPC failure below is a separate demonstrated gap, not absence of capture assembly. | +| #5003 atomic event-owned completion | Open. Solves batch publication/retry, **not** the event writer's shadow-capture binding. | +| #4994 explicit leased Agent handoff; #4995 generated Monitor proof; #4991 rejected poll reservation; #4992 deferred receipt-bound Turn | Open. Integrate their exact reviewed heads before deciding what caller work remains; do not recreate them under a new caller-refactor PR. | +| #4931 retained SQLite proof encoding, contributor #4224 | Open optimization plus incomplete D2 qualification. A speedup is not capacity/recovery/soak acceptance. | +| #4915 default `.loopx` filesystem placement | Separate configuration migration; does not select File/SQLite authority. | + +There are six relevant open implementation PRs above (#5003, #4994, #4995, +#4991, #4992, #4931), plus the separately classified #4915 to avoid conflating +filesystem placement with authority. These are not six unstarted requirements, +nor a claim that every one is a mandatory storage-default dependency. + +## Four concrete next delivery boundaries + +These are **four proposed new batches including this delivery**, in addition to +integrating existing work. They are not a guaranteed total remaining PR count. +The command inventory and exact-profile acceptance can reveal further defects; +record a new demonstrated gap rather than silently keeping a range unchanged. + +| Batch | Observable result and owning boundary | Exit and remaining dependency | +| --- | --- | --- | +| A. Complete source pipeline (this delivery) | A source larger than the RPC envelope can pass typed projection, bootstrap, writer capture, inspect, qualify and reviewed promotion without truncation. Python transports bytes; TS retains source admission and authority. | Large real CLI journey; File/SQLite complete reads; source-witness rejection; detached real-source rehearsal. Does not bind the event writer or qualify a provider default. | +| B. External-effect executor fence | Current execution proof protects the actual external-effect interval, including takeover, timeout, exit and uncertain completion, using the existing lease/effect owners. | Stale executors cannot execute or settle fenced work; exact receipt recovery. #4994/#4995 caller integration is reused; a point-in-time proof check alone is insufficient. | +| C. Event-writer binding and whole-Goal migration/rollback | Bind the actual event writer lock/publication lifecycle to the existing outbox lineage, then exercise mixed Markdown/event/lease writers, drain, reviewed cutover, canonical consumers and fenced export/rollback as one journey. Retire replaced Python decisions at their TS owner. | Integrate #5003 rather than reimplement atomic completion. Preserve `event_log_writer_not_bound` until the real binding passes. D1 consumers, command inventory and D3 cohort evidence must close; if this requires separate code, name the discovered boundary explicitly. | +| D. Default/onboarding and final bounded Python retirement | Qualified local profile is selected consistently by new Goal creation, settings, installation and packaged frontend/Lark/CLI; existing Goals follow explicit migration/disable guidance. Delete only business writers whose callers have switched. | B/C and applicable D1–D3 evidence, rollback and entrypoint readback. Keep permanent Python rendering, host IO and legal import/export. | + +D2 capacity, crash/restore/upgrade/runtime coverage and **at least ten days of +natural elapsed soak** are evidence gates on an exact SQLite profile, not an +assumed one- or two-PR allocation. #4224 retains ownership. D3 integration and +owner-approved cohort cutover are also not automatically new PRs. No fixed +completion date or exact total PR count is defensible while these are open. +A File-only bounded cutover, a qualified SQLite default and migration of every +existing Goal have distinct acceptance scopes; none proves the other two. + +PostgreSQL reuses the typed commands and AuthorityStore, while deployed +transport, authentication/tenant policy, restore identity, operations and +capacity qualification remain its separate medium-term path. Local default +does not wait for PostgreSQL deployment; a passing conformance suite does not +establish production service readiness. + +## Complete-source transport and budget decision + +At this baseline `test_canonical_snapshot_integration` fails before provider +admission: complete source projection exceeds the 2 MiB request limit. Paging +canonical reads already exists, but source capture and management still send +whole projections. Trimming source records would invalidate digests and parity; +increasing the generic RPC budget would enlarge every method's exposure. + +The existing coordination contract generates both schema names and the byte cap +for Python and TS. Python file exchange stays in its existing source projection +adapter; there is no independently maintained same-name Python/TS module pair. +Only source-bearing handlers accept a private host-local transfer envelope. +Python writes a temporary request; TS verifies its method, byte length, SHA-256, +regular-file identity and private directory, then invokes the same handler. +TS writes an exclusively created result and returns a small bound receipt; +Python verifies the response bytes and cleans up temporary files on both success +and failure. Inline callers remain compatible. These artifacts are transient +transport, not another authority store or durable business receipt. + +The RPC limit remains 2 MiB. **The new artifact limit is 16 MiB per request or +result**, a separate explicit bound, not unlimited streaming or an assertion +that all goals fit. Oversize input rejects before execution. Result delivery can +fail after a mutation; callers must recover using the existing operation identity, +never infer that an RPC error means no commit. No automatic mutation retry is +added. Memory still includes complete parsed objects; this does not solve +arbitrarily large provider history or capacity qualification. + +The cap accommodates the multi-megabyte complete-source fixture and source +management's repeated representation while keeping allocation bounded. In 32 +interleaved warm calls of the same small source on the same host, inline versus +artifact median was 9.15/11.62 ms and p95 11.60/15.93 ms. This measured local IO +cost is accepted for complete-source calls; it is not a global latency claim. +Future size changes require a measured workload and the existing budget review. + +Validation uses real File/SQLite and a disposable PostgreSQL 16 server. The +large CLI regression qualifies and promotes only a synthetic isolated Goal. +A live source rehearsal that detected concurrent changes was discarded; the +accepted real-source rehearsal verifies a detached copy against its capture +witness and exercises all mutations there. Private sources, identifiers and +raw output are excluded from public artifacts. No active Goal is promoted. + +No frontend settings or API shape changes are needed: the same CLI and Python +management adapters invoke the same domain handlers and return the same results. +The public change is that supported complete-source operations no longer fail +solely because their source crosses the RPC envelope. Defaults, authorization, +source freshness, event-writer holds and provider promotion criteria are unchanged. + +A related runtime repair handles socket errors when a caller closes an oversized +response before draining it. One disconnected caller no longer crashes the +shared runtime; the regression asserts that subsequent paged reads retain the +same process identity. It neither cancels nor retries the business operation. diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md new file mode 100644 index 0000000000..8a32e8e367 --- /dev/null +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md @@ -0,0 +1,86 @@ +# 默认切换:按实现证据重算交付边界 + +- 核对基线:2026-09-24 `main` 的 `d64c4d377`;开放 PR 状态是快照,不是合入承诺。 +- 归属:总目标 #4574 R5/G2;shared authority L2–L9/D1–D3;TS 迁移 T1–T4。 +- 本次交付:既有 typed projection 与 shadow management 的完整来源传输。 +- 本检查点取代此前交付记录中的剩余 PR 数量估算。 + +## 先纠正统计口径 + +此前“5–8”“6–8”“7–9”把宽泛工作包写成剩余 PR 数,部分实现合入、额外前置项 +出现后又维持原估算。这些数字不是逐项核对过的 PR backlog,现撤回。代码缺口、 +开放 PR、集成验收、自然时间资格和维护者晋升决定是不同单位,不能相加或机械扣减。 + +| 当前基线的事实 | 现在应如何处理 | +| --- | --- | +| #4870 保留 claim 的写入、#4888 reviewed cutover、#4920 drain 规划 | 已实现。验收组合 head,不再重新安排一套替代实现。 | +| #4922 完整 canonical 快照分页、#4960 SQLite runtime 准入、#4961 显示刷新恢复、#4964 共享来源摘要 | 已实现。消费者和打包客户端仍需组合验收,不等于还缺一个全新的分页/恢复实现。 | +| #4967 TS 完整来源组装、#4968 原生 outbox 交付/恢复 | 已实现。下述大型来源 RPC 失败是另一个已复现缺口,不能称为 capture 组装未做。 | +| #5003 event-owned completion 原子提交 | 开放。解决整批发布/重试,不负责 event writer 与 shadow capture 的绑定。 | +| #4994 带 lease 的显式 Agent 交接、#4995 Monitor 命令 proof、#4991 拒绝 poll 后释放预约、#4992 延期且绑定 receipt 的 Turn | 开放。组合各自经过评审的 head 后盘点 caller,不能再开一个 caller 重构 PR 重做它们。 | +| #4931 SQLite retained proof 编码、contributor #4224 | 优化 PR 开放,D2 资格未闭合。提速不等于容量、恢复和 soak 验收通过。 | +| #4915 默认 `.loopx` 目录 | 独立的配置迁移,不会选择 File/SQLite authority。 | + +上表有六个相关的开放实现 PR(#5003、#4994、#4995、#4991、#4992、#4931), +另列 #4915 排除目录迁移造成的混淆。它们不是六个尚未动手的新需求,也不宣称每个 +都是 storage default 的硬依赖。 + +## 四个可明确描述的后续交付边界 + +在整合已有工作之外,规划以下**四个新增交付批次,包含本次**。这是下一步开发 +安排,不是保证总共只剩四个 PR。命令清单和精确 profile 的验收仍可能发现缺陷; +届时记录新证据和新边界,不再悄悄维持一个范围数字。 + +| 批次 | 可观察结果与 owner | 退出证据及剩余依赖 | +| --- | --- | --- | +| A. 完整来源流水线(本次) | 大于 RPC envelope 的来源可完整经过 TS projection、bootstrap、writer capture、inspect、qualify、reviewed promotion。Python 只传字节,TS 保留来源准入及 authority。 | 大型真实 CLI 链路、File/SQLite 完整读取、source witness 拒绝反例、真实来源隔离副本演练。不绑定 event writer,也不宣布 provider 默认合格。 | +| B. 外部 effect executor fence | 复用 lease/effect owner,在真实外部 effect 执行区间保护当前 execution proof,覆盖接管、超时、退出与不确定完成。 | 过期 executor 不能执行或结算被围栏的工作,精确业务 receipt 可恢复。复用 #4994/#4995;执行前查一次 proof 不足以证明整个区间安全。 | +| C. Event writer 绑定与整 Goal 迁移/回滚 | 将真实 event writer 的锁及发布生命周期接入现有 outbox lineage,组合 Markdown/event/lease writer、drain、reviewed cutover、canonical 消费者和 fenced export/rollback。随 TS owner 收口删除替代的 Python 决策。 | 整合 #5003,不重做原子完成。真实绑定通过之前保留 `event_log_writer_not_bound`。闭合 D1 消费者、命令清单与 D3 cohort 证据;若发现需要独立代码批次,明确记录该缺口。 | +| D. 默认/onboarding 与最后一批有界 Python 退役 | 新 Goal、settings、安装和打包 frontend/Lark/CLI 一致选择合格本地 profile;已有 Goal 有显式迁移、停用指导。仅删除 caller 已切换的业务 writer。 | B/C、适用的 D1–D3、回滚及受影响入口读回。保留永久 Python renderer、宿主 IO 和合法 import/export。 | + +D2 的容量、crash/restore/upgrade/runtime 覆盖和**至少十天自然经过时间的 soak**, +是精确 SQLite profile 的证据门,不预设为一个或两个 PR;#4224 继续拥有这项工作。 +D3 集成和经 owner 批准的 cohort 切换也不自动产生新 PR。这些缺项未闭合前,不给 +固定完成日期或精确总 PR 数。File-only 有界切换、合格的 SQLite 默认、所有存量 +Goal 迁移是不同验收范围,不能互相证明。 + +PostgreSQL 复用 typed command 和 AuthorityStore,部署 transport、认证/tenant +策略、restore identity、运维和 capacity 资格仍是独立中期路线。本地默认不等待 +PostgreSQL 部署,conformance 通过也不等于生产服务已合格。 + +## 完整来源传输与预算决定 + +此基线的 `test_canonical_snapshot_integration` 在 provider 准入之前失败:完整 +来源投影超过 2 MiB request 上限。canonical 读取分页已实现,但 source capture +及管理命令仍传完整投影。裁剪来源记录会破坏 digest/parity;扩大通用 RPC 上限会 +影响所有方法。 + +协议名称和字节上限由既有 coordination 合同统一生成给 Python/TS。Python 文件交换 +留在现有来源投影适配器,不新增独立维护的同名 Python/TS 模块对。 +仅携带来源的 handler 接受本机私有文件 envelope。Python 写临时 request;TS 核验 +method、字节数、SHA-256、普通文件身份和私有目录,然后调用原有 handler。TS 排他 +创建结果文件并返回紧凑的绑定回执;Python 校验结果字节,在成功和失败时均清理临时 +目录。inline 调用继续兼容。这是临时传输,不是第二套 authority store 或持久业务 receipt。 + +RPC 保持 2 MiB。**新增 artifact 对单次 request/result 分别限制为 16 MiB**: +这是单独的明确容量边界,不是无限流式,也不保证任意 Goal 均可容纳。超大输入在执行前 +拒绝;结果交付失败可能发生在业务提交之后,调用方必须按原 operation identity 恢复, +不能把 RPC 失败当成未提交。本次不添加自动 mutation 重试。内存仍包含完整解析对象, +不解决任意大的 provider 历史或容量资格。 + +16 MiB 容纳多 MiB 完整来源 fixture 及管理请求中的重复表示,同时限制分配规模。 +同机、同小型来源、32 对交错 warm 调用:inline/artifact 中位数为 9.15/11.62 ms, +p95 为 11.60/15.93 ms。本次为完整来源调用接受该实测本地 IO 成本,不宣称全局延迟 +结论。后续调整大小仍需实测 workload 和既有预算审查。 + +验证使用真实 File/SQLite 及一次性 PostgreSQL 16 server。大型 CLI 回归仅对合成隔离 +Goal 进行资格验证和晋升。本机活跃来源的一次演练检测到并发变化,已丢弃证据;被接受 +的真实来源演练先按 capture witness 验证隔离副本一致性,再仅在副本执行变更。私有 +原文、标识和原始输出不进入公开产物,没有晋升活跃 Goal。 + +无需新增前端设置或改变 API 形状:既有 CLI/Python 管理适配器仍调用同一领域 handler、 +返回同一结果。公开变化是完整来源不再仅因越过 RPC envelope 而失败。默认配置、权限、 +source freshness、event writer hold 和 provider 晋升标准保持原有语义。 + +相邻 runtime 修复处理客户端未读完超大响应就断连时的 socket 错误,避免一个断连 +导致共享 runtime 退出。回归验证后续分页请求仍使用同一进程;不取消或重试业务操作。 diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index da9e4af471..187d5d46d4 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -23,6 +23,13 @@ [Chinese version](./shared-goal-authority-state-provider-v0.zh-CN.md) and this English version are semantic mirrors. A difference between them is a defect. +## Current delivery frontier (2026-09-24) + +The `d64c4d377`/open-PR audit withdraws earlier “5–8 / 6–8 / 7–9” estimates. +Implemented code, six relevant open PRs, four proposed new batches (including +complete-source transport) and D1–D3 evidence are separate units; four batches +are not a guaranteed total PR count. Use the [reconciled inventory and exits](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md) as the current plan. + ## Persistence route for steward scale (2026-09-16) [Roadmap](loopx-overall-roadmap-v0.md) R5 reuses D1 projection, D2 real-backend/capacity/applicable ten-day soak and D3 fenced cutover. R6 connects the selected shared profile to authenticated local/cloud execution. R1–R3 can advance on supported profiles without waiting for PostgreSQL or whole-Goal default promotion. @@ -36,8 +43,8 @@ after promotion. Legacy event-only claims reject rather than disappear at a Markdown boundary; event append locks protect the observation through writeback. Canonical changes reuse durable command receipt recovery. This is an L2/L3 compatibility correction with Python decision deletion, not cohort migration, -SQLite D2 completion or a default flip. The remaining 7–9 packages still depend -on executor/consumer closure, qualification, integrated migration and onboarding. +SQLite D2 completion or a default flip. Remaining work is classified in the current delivery frontier rather than +counted as unchanged packages. [Operation, repair and recovery](../../reference/handoff-mode.md). The terminal caller family now binds review and validation to the canonical @@ -45,7 +52,7 @@ source and recovers historical receipts independently of private argv. Agent completion and Monitor stop share current-head display acknowledgement with ordinary edits. [Caller and recovery contract](../../reference/canonical-terminal-review.md). This advances L2/L5 without closing executor-held fences, D1–D3 or default -onboarding; the conditional 7–9-package estimate below remains unchanged. +onboarding; use the current reconciled inventory for remaining work. The local registry witness now spans canonical create/claim/update/Monitor poll and terminal mutations through one TS owner. File, SQLite and service-injected @@ -3109,7 +3116,7 @@ explicit runtime-root applies to both intent and Todo IO. Frozen editorial requests retain their original basis. See [operation and boundaries](../../../loopx/capabilities/periodic_report/README.md#todo-authority-and-report-retries). This closes that T3/L5 consumer family, not D1 permanent display freshness, D2 durability, D3 whole-Goal qualification or default-provider selection. The -conditional 7–9 remaining delivery-package estimate is unchanged. +current reconciled inventory distinguishes code gaps from qualification work. The Todo summary consumer now uses one TS batch for scope, lanes, counts, claimant-balanced display and closure. It retires Python count/cap/allocation @@ -3213,8 +3220,8 @@ provenance across File/SQLite and service-owned PostgreSQL. Verified recovery copies do not select an authority, revive executors or roll back writer fences. This closes the portable recovery-artifact gap only; final source draining, fenced target adoption, later-write accounting and cohort cutover remain L8. -The conditional **7–9 package** estimate below is unchanged until the remaining -caller, projection, D2, migration and default exits are qualified. Pending +The current inventory separates caller/projection code from D2, migration and +default acceptance. Pending reviewed-promotion and command-recovery PRs must be integrated at their accepted heads rather than counted as merged prerequisites. @@ -3262,9 +3269,9 @@ cards. Monitor observation/reactivation #4732 and linked User completion #4754 are also merged. #4224 retains contributor ownership of SQLite D2. Re-read actual heads before work. -The identifiers below are **planned PR packages**, not reserved GitHub numbers. -A package may split at a real effect/compatibility boundary; changing languages -or moving a helper is not by itself a package exit. +The identifiers below preserve **domain ownership and acceptance boundaries**, +not remaining PRs or reserved GitHub numbers. Use the current frontier for +new implementation work; changing languages or moving a helper is not an exit. | Wave / package | Reviewable delivery and TS ownership payoff | Dependencies and exit evidence | | --- | --- | --- | @@ -3278,47 +3285,19 @@ or moving a helper is not by itself a package exit. | C / L8: Whole-Goal rehearsal and cohort migration | Integrate one exact revision/profile after L2–L7; drain capture, fence old writers, verify canonical readback and projection, then rehearse fenced export/rollback. | D3 evidence packet binds lineage, cursor, source digest, command coverage and profile. Existing Goal migration requires explicit cohort approval; no per-command split authority or stale Markdown revival. | | D / L9: New-Goal default and bounded retirement | A dedicated default-change PR makes new-Goal creation/onboarding choose the qualified local profile, including settings/readback, installer and packaged clients. Retire old business writers only as their final callers and migration window close. | L8's integrated product/rollback qualification; distinguish new Goal default from existing Goal migration. Publish compatibility/disable guidance, keep explicit provider choice, permanent rendering and validated import/export. T4 can continue after the default ships. | -**2026-09-24 reconciliation.** The count remains an estimate of complete -packages, not a count of small fixes, and this refresh slice advances boundary 4 -(projection recovery and client closure) without claiming its other consumers -are qualified. SQLite #4910 added the larger measurement axes; #4224 records +**Earlier 2026-09-24 implementation context.** Display refresh advances +projection recovery/client closure without claiming every consumer qualified. SQLite #4910 added the larger measurement axes; #4224 records failed 1 MiB receipt/scan budgets and still-missing recovery/soak evidence. #4931 is the in-review read-proof optimization, not proof that D2 passed. Snapshot pagination #4922 has merged and still must be qualified at its accepted head. None of these PR statuses grants cutover or changes the selected profile. -**Cadence is evidence-based.** The 2026-09-23 decomposition separates bounded -canonical reads from projection/client closure and separates caller admission -from executor-held effects. This refines the older five broad packages into -**seven concrete PR boundaries (up to nine if D2 and migration each split)**, -including the snapshot-pagination PR. It is not a promise that a count of -merges qualifies default-on; unresolved acceptance evidence keeps its hold. - -| Ordered PR boundary | Owner / content | Decisive exit | -| --- | --- | --- | -| 1. Remaining canonical callers | L2/L4: inventory actual CLI/Turn/Chat callers, close retained leased metadata and delegated/effect-owned actions through existing typed transactions; remove replaced Python admission. | Each listed command succeeds or rejects coherently on legacy/File/SQLite, including authority and recovery negatives. | -| 2. External-effect execution fence | L3: hold and revalidate current execution proof over the real external effect, including takeover, timeout, process death and uncertain completion. | A stale executor cannot perform/settle a fenced effect; active execution and business receipt recovery retain one owner. | -| 3. Snapshot-bound canonical collection reads | T3/L5, this slice: paginate complete Todos, archives, leases and acceptance guards below the unchanged RPC limit; bind identity, revision, query and progress. | Real File/SQLite RPC/CLI and provider conformance preserve full populations; concurrent commits reject mixed reads. No active-Goal promotion is implied. | -| 4. Projection recovery and client closure | L5/D1: audit Turn/quota/Dashboard/Chat/Lark reads and use the existing outbox for permanent display freshness and repair. | Missing/stale/empty/pending displays and packaged-client interactions are verified; final post-promotion fallback callers retire. | -| 5. SQLite D2 qualification (1–2 PRs) | Contributor-owned #4224/#4328; reconcile the existing capacity PR before adding work. Complete crash/restore/upgrade/platform evidence on one exact profile. | Capacity ledger and separately authorized >=10-day elapsed synthetic soak pass; gaps remain holds. | -| 6. Capture plus whole-Goal migration/rollback (1–2 PRs) | L7/L8: combine mixed-writer/event continuity, drain, old-writer fencing, canonical readback, fenced export/rollback and cohort migration. | One D3 packet binds exact profile, lineage, source digest and command inventory; existing-Goal cohort cutover remains explicit. | -| 7. Default/onboarding and bounded Python retirement | L9/T4: make new-Goal creation, settings, installer and packaged clients choose the qualified local profile; publish migration/disable guidance and delete replaced final business writers. | Integrated L8 qualification, rollback and affected user-entrypoint readback. Preserve active rendering, host execution and import/export adapters. | - -At the 2026-09-24 reconciliation, #4870/#4888/#4920, #4922 (snapshot pagination), -#4960 (qualified SQLite runtime admission) and #4961 (refresh display recovery) -are merged, while #4931 (SQLite proof encoding) remains a separate in-review -dependency and SQLite #4224 still retains failing/missing D2 evidence. This -summary slice closes shared read-model decisions and advances boundary 4, not -those delivery gates: requalify the combined accepted head before updating the -estimate, and do not count each helper migration as one complete package. - -With boundary 3 landed, **six planned PRs remain, potentially eight** under those -two named splits. Caller audit can expose additional missing effects, so this is -an implementation estimate, not a guarantee. Small Python business-rule -retirements accompany their TS owner; deleting all Python is neither the exit -condition nor a prerequisite. PostgreSQL service, credential, tenant, restore -and capacity qualification remains a separate medium-term lane; local default -need not wait for it, and portable conformance does not declare it production-ready. +**Current implementation sequence.** L1–L9 above are domain ownership, not +a remaining PR count. The old seven-row plan is replaced by the +[reconciled inventory](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md): integrate open caller work, +reuse merged pagination/recovery, and track D2 elapsed-time evidence separately. +New code is organized around complete source transport, executor fencing, event +writer/whole-Goal migration, and default/onboarding with bounded Python retirement. The snapshot protocol is documented in [canonical snapshot pagination](../../reference/canonical-snapshot-pagination.md). It shares existing TS collection validation and acceptance ownership. Python @@ -3326,11 +3305,6 @@ only assembles/validates the transport and keeps the old public result shape. Per-page provider reads bound transmission, not database memory or total IO; concurrent writers may require an explicit full-read restart. D1–D3 holds remain. -截至 2026-09-23,原五类粗粒度工作细化为七个具体 PR 边界:caller、executor fence、 -一致性分页、投影与客户端、D2、整 Goal 迁移回滚、默认与有界 Python 退役。 -D2 和迁移各可按独立验收拆成两批,因此含本批共 7–9 个,分页合入后计划 6–8 个。 -这不是按合并数量推进资格;未通过的证据仍是 hold。PostgreSQL 运维资格独立推进。 - Avoid concurrent edits to the same transaction owner; share fixture/contracts early and rebase after the owner lands. @@ -3393,7 +3367,7 @@ source resume/succession and post-filter counts survive display limits. This is one L5 consumer closure, not D1 projection freshness or provider promotion. See [read semantics](../../reference/todo-work-counts.md). Remaining caller/executor, consumer recovery, contributor D2, capture/whole-Goal and default onboarding -boundaries retain the conditional **7–9 cohesive PR** estimate. +boundaries are classified separately in the current reconciled inventory. ## Appendix D: Execution ledger diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 0a54dfd69d..b0e7f8226a 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -20,6 +20,12 @@ - 语言说明:[英文版](./shared-goal-authority-state-provider-v0.md)与本中文版互为 语义镜像;两者不一致属于缺陷 +## 当前交付边界(2026-09-24) + +剩余 PR 估算已按 `d64c4d377` 和开放 PR 重新核对,旧“5–8 / 6–8 / 7–9”数字撤回。 +已合入实现、六个相关在途 PR、四个拟新增批次(含当前完整来源传输)和 D1–D3 +验收分开记录;四批不是承诺总计只剩四个 PR。唯一当前清单见[实现核对与退出证据](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md)。 + ## 管家规模化的持久化路线(2026-09-16) [统一路线](loopx-overall-roadmap-v0.zh-CN.md) R5 复用本 RFC 的 D1 投影、D2 真实 backend/容量/适用十日 soak、D3 fenced cutover;R6 再把所选 shared profile 接入认证的本地/云端路径。R1–R3 可在已支持 profile 上前进,不等待 PostgreSQL 或整 Goal 默认晋升。 @@ -31,7 +37,7 @@ 终结 caller 现将审核与验证绑定 canonical 来源,历史回执恢复不再依赖私有 argv。 Agent 完成和 Monitor 停止复用普通编辑的当前 head 显示确认。 [调用与恢复合同](../../reference/canonical-terminal-review.zh-CN.md)。此批推进 L2/L5, -未闭合 executor-held fence、D1–D3 或默认 onboarding,下文有条件的 7–9 批估算不变。 +未闭合 executor-held fence、D1–D3 或默认 onboarding;剩余工作使用当前核对表。 本地 registry witness 现经同一 TS owner 覆盖 canonical create/claim/update、 Monitor poll 与 terminal mutation;File、SQLite、service-injected PostgreSQL @@ -2528,8 +2534,8 @@ provider 确认;权威空集合不回退到陈旧 Markdown。Legacy 与预览 也已合并,#4224 继续由 contributor 负责 D2。组合前重读实际 head,不能把已合并 祖先再次算成新变化。 -下表编号表示**计划 PR 包**,不是预留 GitHub 编号。可沿真实 effect/兼容边界拆分; -仅换语言或移动 helper 不构成一个包的退出条件。 +下表编号保留**领域归属和验收边界**,不表示剩余 PR 或预留 GitHub 编号。 +新增实现以当前核对表为准;仅换语言或移动 helper 不构成退出条件。 | 波次/PR 包 | 完整交付内容与 TS 归属收益 | 依赖与退出证据 | | --- | --- | --- | @@ -2547,28 +2553,13 @@ provider 确认;权威空集合不回退到陈旧 Markdown。Legacy 与预览 规划 #4920 已合并,快照分页 #4922、SQLite runtime 准入 #4960 与刷新显示恢复 #4961 也已 合并,后续应验收组合 head,而不是继续沿用旧的 PR hold;SQLite 读取证明优化 #4931 仍在 评审。#4224 实测 1 MiB receipt/scan 超预算,恢复和自然时间资格仍有缺项,不能将优化 PR -当成 D2 通过。本次摘要规则收口推进下表中第 4 项投影恢复边界,但没有把其他调用方或默认 -切换标记完成;数量估计应在组合 head 验收后更新,不能按 helper 迁移数量机械扣减。 +当成 D2 通过。摘要规则收口推进投影恢复边界,但没有把其他调用方或默认切换标记完成; +剩余工作按当前核对表归类,不再按 helper 迁移数量机械扣减。 -**开发节奏以证据推进。** 2026-09-23 将旧计划的五类粗粒度交付包细化为 -**七个明确 PR 边界;D2 和迁移各拆两批时最多九个**,其中包含本批一致性分页。 -独立列出分页与投影恢复、caller 与 executor effect,是为了让验收和回滚更清楚, -不是以合并数量替代资格证明。 - -| 顺序 / PR | 归属与改动 | 退出证据 | -| --- | --- | --- | -| 1. canonical caller 收尾 | L2/L4:盘点 CLI/Turn/Chat,补齐带 lease 的 metadata、委托和 effect-owned 动作,调用既有 TS 事务,删除替代的 Python admission。 | 实际命令在 legacy/File/SQLite 成功与拒绝一致,含权限和恢复反例。 | -| 2. 外部 effect 执行围栏 | L3:执行真实外部 effect 期间持有并重新验证当前 execution proof,覆盖接管、超时、进程退出和不确定完成。 | 旧 executor 不能执行或结算被围栏的 effect;执行与业务 receipt 恢复仍有明确 owner。 | -| 3. canonical 一致性分页(本批) | T3/L5:完整 Todo、归档、lease 和验收 guard 分页,绑定 identity/revision/query/progress,不提高 RPC 上限。 | File/SQLite 真实 RPC/CLI 与跨 provider conformance 无丢失;并发提交拒绝混合版本,不代表活跃 Goal 已晋升。 | -| 4. 投影恢复与客户端闭合 | L5/D1:审计 Turn/quota/Dashboard/Chat/Lark,复用 outbox 完成永久展示新鲜度与恢复。 | 缺失、陈旧、权威空状态、pending 与打包客户端读回;删除最后的晋升后 fallback caller。 | -| 5. SQLite D2(1–2 PR) | #4224/#4328 contributor owner;先对齐已有 capacity PR,再补同一 profile 的 crash/restore/upgrade/platform 证据。 | capacity ledger 和单独授权、真实经过 >=10 天的合成 soak;缺失项仍为 hold。 | -| 6. capture 与整 Goal 迁移回滚(1–2 PR) | L7/L8:混合 writer/event 连续性、drain、旧 writer 围栏、canonical 读回、fenced export/rollback 和 cohort 迁移。 | 一份 D3 packet 绑定 profile/lineage/source digest/command inventory;存量 cohort 切换仍需明确授权。 | -| 7. 默认/onboarding 与有界 Python 退役 | L9/T4:新 Goal、settings、installer 与打包客户端选择合格本地 profile;发布迁移和停用指导,删除已替代的最后业务 writer。 | L8 整体验证、回滚和受影响入口读回;保留仍有调用方的渲染、宿主执行与 import/export。 | - -第 3 项合入后,按此计划还剩 **6 个主 PR,必要时 8 个**。caller 盘点可能揭示其他 -缺失 effect,因此这不是数量保证。Python 业务规则随 TS owner 收敛即可删除, -不把删除全部 Python 当作默认化门槛。PostgreSQL 的 service、credential、tenant、 -restore、capacity 资格是独立中期路线;跨 provider 测试通过不等于生产可切换。 +**当前开发节奏。** 上述 L1–L9 是领域分工,不是剩余 PR 数。原七行 PR 计划 +已被[逐项核对表](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md)取代:已合入的分页与恢复不再列为新 PR, +caller 在途工作先整合,D2 自然时间资格单列。新增代码依次按完整来源、executor fence、 +event writer/整 Goal 迁移、默认/有界 Python 退役四个边界组织。 本批的[分页合同](../../reference/canonical-snapshot-pagination.md)共用既有 TS collection validation 与 acceptance owner;Python 只校验并组装传输,保持公开返回 @@ -2602,7 +2593,7 @@ User gate/action 及 Agent claim 范围规则;legacy 和 canonical 消费者 列表谓词已删除。完整来源上的 resume/succession 与筛选后的计数不受展示上限影响。 这只闭合 L5 的一个消费者,不代表 D1 永久新鲜度或 provider 晋升。见[读取合同](../../reference/todo-work-counts.md)。 剩余 caller/executor、consumer recovery、contributor D2、capture/整 Goal 演练和默认 -onboarding 仍按 **7–9 个完整 PR** 条件估计,不能按本次修复机械递减。 +onboarding 按当前核对表分别归为代码、在途 PR 和验收活动,不再混算 PR 数。 ## 附录 D:执行账本 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 492da03cb8..f7d14f87fe 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -14,6 +14,13 @@ --- +## Current delivery frontier (2026-09-24) + +The `d64c4d377`/open-PR audit withdraws earlier “5–8 / 6–8 / 7–9” estimates. +Implemented code, six relevant open PRs, four proposed new batches (including +complete-source transport) and D1–D3 evidence are separate units; four batches +are not a guaranteed total PR count. Use the [reconciled inventory and exits](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.md) as the current plan. + ## Cross-RFC execution priority (2026-09-16) [Roadmap](loopx-overall-roadmap-v0.md) R1–R5 are current product consumers of T0–T4, not another migration ladder. The team confirmation path now uses `work_items/team_plan.ts` for preview, batch planning and immutable operation identity, plus the existing AuthorityStore receipt/CAS boundary. Python retains public-safety and legacy Markdown IO adaptation; the per-lane writer loop is retired. The R1 checkpoint records the delivered assignment/retry delta and remaining receiver/execution boundaries. @@ -29,9 +36,8 @@ This replaces the one-shot cross-language read without raising its 2 MiB budget or duplicating Todo/acceptance semantics in Python. Concurrent revisions fail the whole read; File read opening cannot create a missing authority. See the [paging contract](../../reference/canonical-snapshot-pagination.md) for limits -and cost, and the shared-authority implementation sequence for the current -**7–9 PR plan including this slice (6–8 after it lands)**. The more explicit split supersedes the earlier broad-package -estimate; it does not claim D1–D3 closure. +and cost. The current shared-authority inventory separates shipped pagination +from open implementation work and D1–D3 qualification. Canonical command observation now has one typed receipt/head boundary. Team, @@ -53,8 +59,8 @@ removed; its retained boundary is source projection/locking and capture IO. The legacy scan includes event-only claims, and canonical mode receipts reuse command recovery with strict historical decisions. Full-source snapshot and real-provider validation guard this T1/T2 replacement. This closes a rule and -caller discrepancy, not a whole default-cutover package; the conditional 7–9 -package estimate remains. [Changed behavior and recovery](../../reference/handoff-mode.md). +caller discrepancy, not a whole default-cutover package; use the current +reconciled inventory for remaining work. [Changed behavior and recovery](../../reference/handoff-mode.md). Terminal review and validation now converge in the existing TS terminal owner. Agent completion and Monitor stop reuse Chat's canonical receipt-first recovery @@ -857,7 +863,7 @@ explicit runtime-root applies to both intent and Todo IO. Frozen editorial requests retain their original basis. See [operation and boundaries](../../../loopx/capabilities/periodic_report/README.md#todo-authority-and-report-retries). This closes that T3/L5 consumer family, not D1 permanent display freshness, D2 durability, D3 whole-Goal qualification or default-provider selection. The -conditional 7–9 remaining delivery-package estimate is unchanged. +remaining work is classified in the current reconciled inventory. Todo summary lanes and pre-limit work counts now share `todos/summary_lanes.ts`. Python's lane classification and hidden-work inference loops are removed; quota diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 1cf81062b9..abf507c179 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -14,13 +14,19 @@ --- +## 当前交付边界(2026-09-24) + +剩余 PR 估算已按 `d64c4d377` 和开放 PR 重新核对,旧“5–8 / 6–8 / 7–9”数字撤回。 +已合入实现、六个相关在途 PR、四个拟新增批次(含当前完整来源传输)和 D1–D3 +验收分开记录;四批不是承诺总计只剩四个 PR。唯一当前清单见[实现核对与退出证据](ledger/shared-goal-authority-state-provider-v0/2026-09-24-default-cutover-reconciliation.zh-CN.md)。 + ## canonical collection 分页检查点(2026-09-23) canonical collection 跨语言传输改为 TS 一致性分页:旧 direct list 和分页共用 `canonicalTodoCollection` 规则 owner,Python 校验并组装完整分页,保持调用方形状。 不提高 2 MiB RPC 上限,不在 Python 重建 Todo/acceptance 规则;并发版本变化导致 整份读取失败,File 只读打开不创建缺失 authority。限制与开销见[分页合同](../../reference/canonical-snapshot-pagination.md)。 -shared-authority 当前计划细化为含本批 **7–9 个 PR,合入后 6–8 个**;此处细化取代旧的粗粒度交付包估算,不代表 D1–D3 完成。 +shared-authority 的当前核对表区分已合入实现、在途 PR、新代码边界和 D1–D3 证据,不再以粗粒度包数代替剩余 PR。 ## 跨 RFC 的执行优先级(2026-09-16) @@ -43,7 +49,7 @@ receipt,再执行新准入。这修复同 operation 并发竞争,不扩展 p 删除 Python 的阻塞分类、伪造旧模式和整篇文本重写,保留来源投影、锁与 capture IO。 旧扫描补齐事件独有 claim,canonical 回执复用 command recovery 并严格校验历史决策。 完整快照和真实 provider 验证覆盖这一 T1/T2 替换;它关闭一处规则/调用差异, -不代表关闭整项默认切换交付包,条件性的 7–9 包估算不变。 +不代表关闭整项默认切换交付包,剩余工作以当前核对表为准。 [行为变化与恢复](../../reference/handoff-mode.md)。 终结审核与验证已收敛到既有 TS terminal owner:Agent 完成、Monitor 停止复用 Chat @@ -646,7 +652,7 @@ Todo 来源;frontier 和报告事实复用同一完整已求值快照。展示 归档拒绝记录仍有效,显式 runtime-root 同时约束 intent 和 Todo IO。已冻结的编辑 请求沿用原始依据,不因重试刷新。见[操作边界](../../../loopx/capabilities/periodic_report/README.md#todo-authority-and-report-retries)。 这闭合一组 T3/L5 消费者,不代表 D1 永久展示新鲜度、D2 耐久性、D3 整 Goal -资格或默认 provider 已完成;条件性的 7–9 个后续完整交付批次估算保持不变。 +资格或默认 provider 已完成;剩余工作以当前核对表为准。 Todo 摘要 lane 与裁剪前工作计数现共用 `todos/summary_lanes.ts`,删除 Python 的 lane 分类和隐藏任务推断循环。quota 在作用域筛选后重新计数,不完整来源状态贯穿 diff --git a/examples/claude-install-no-system-mutation-smoke.py b/examples/claude-install-no-system-mutation-smoke.py index d330d05eeb..5c02292043 100644 --- a/examples/claude-install-no-system-mutation-smoke.py +++ b/examples/claude-install-no-system-mutation-smoke.py @@ -15,11 +15,14 @@ from __future__ import annotations import importlib.util +import sys import tempfile from pathlib import Path from types import SimpleNamespace REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) INSTALLER = REPO_ROOT / "loopx" / "claude_goal_mode" / "scripts" / "install.py" CONNECTOR = REPO_ROOT / "loopx" / "claude_goal_mode" / "scripts" / "connect.py" diff --git a/examples/issue-fix-validated-memory-writeback-smoke.py b/examples/issue-fix-validated-memory-writeback-smoke.py index 0bf94ff8d9..d96265761d 100644 --- a/examples/issue-fix-validated-memory-writeback-smoke.py +++ b/examples/issue-fix-validated-memory-writeback-smoke.py @@ -129,10 +129,10 @@ def merged_lifecycle() -> dict[str, object]: "ok": True, "schema_version": "issue_fix_pr_lifecycle_monitor_v0", "observation": { - "repo": "huangruiteng/loopx", + "repo": "loopx-project/loopx", "pr_ref": "pull_8", "number": 8, - "permalink": "https://github.com/huangruiteng/loopx/pull/8", + "permalink": "https://github.com/loopx-project/loopx/pull/8", "state": "MERGED", "is_draft": False, "checks": { @@ -159,7 +159,7 @@ def outcome_packet( commit_ref: str | None = None, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: feasibility = build_issue_fix_feasibility_packet( - url="https://github.com/huangruiteng/loopx/issues/7", + url="https://github.com/loopx-project/loopx/issues/7", reproduction_status="confirmed", reproduction_label="focused worker reproduction", scope_class="bounded", @@ -176,7 +176,7 @@ def outcome_packet( "repository_commit_evidence": { "schema_version": "issue_fix_repository_commit_evidence_v0", "status": "verified", - "repo": "huangruiteng/loopx", + "repo": "loopx-project/loopx", "repository_fingerprint": "sha256:" + "a" * 64, "repository_revision": revision, "declared_commit_ref": commit_ref or revision, @@ -192,7 +192,7 @@ def outcome_packet( "outputs": [ { "kind": "pull_request", - "url": "https://github.com/huangruiteng/loopx/pull/8", + "url": "https://github.com/loopx-project/loopx/pull/8", } ], "risks": ["broader integration validation was not run"], @@ -711,7 +711,7 @@ def main() -> int: "--goal-id", "public-issue-fix-goal", "--repo", - "huangruiteng/loopx", + "loopx-project/loopx", "--issue-ref", str(feasibility["observation"]["issue_ref"]), "--feasibility-json", diff --git a/examples/loopx-turn-managed-executor-binding-smoke.py b/examples/loopx-turn-managed-executor-binding-smoke.py index 56a60fced4..68049c153e 100644 --- a/examples/loopx-turn-managed-executor-binding-smoke.py +++ b/examples/loopx-turn-managed-executor-binding-smoke.py @@ -19,12 +19,14 @@ from collections.abc import Iterator from pathlib import Path from typing import Any +from unittest import mock REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) from loopx.cli import main as cli_main # noqa: E402 +from loopx.control_plane import operator_provider # noqa: E402 from loopx.control_plane.turn_driver import executor as turn_executor # noqa: E402 from loopx.control_plane.turn_driver.host_binding import ( # noqa: E402 DSH_RUNTIME_MODULE, @@ -269,7 +271,9 @@ def _expect_probe(binding: dict[str, Any], *, available: bool) -> None: def main() -> int: with tempfile.TemporaryDirectory( prefix="loopx-turn-managed-executor-" - ) as directory: + ) as directory, mock.patch.object( + operator_provider, "DEFAULT_RUNTIME_ROOT", Path(directory) / "machine" + ): root = Path(directory) project, runtime, workspace, registry = _write_fixture(root) diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 48c1a8d4c1..4888f1a8e4 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -584,7 +584,7 @@ export const typedActionsScenario = { await page.locator('[data-goal-panel="chat"]').getByText("Agent is waiting for your decision", { exact: true }).first().waitFor({ state: "visible" }); await englishGoalNavigation.getByRole("button", { name: /^(Files|成果)$/, exact: true }).click(); if (await page.locator('[data-output-kind="evidence"]').count() !== 0) throw new Error("Run status appeared as a delivered file"); - await page.getByText("No delivered files or verified reports yet. Run and validation status remain in Tasks.", { exact: true }).waitFor({ state: "visible" }); + await page.getByRole("region", { name: "Accepted team reports" }).getByText("No verifiable team reports yet.", { exact: true }).waitFor({ state: "visible" }); const englishProjectionText = await page.locator(".personal-workspace-main").innerText(); for (const forbidden of ["最近运行", "最近验证", "Agent 正在整理下一步", "Agent 正在推进当前 Goal", "Agent 等待你的决定"]) { if (englishProjectionText.includes(forbidden)) throw new Error(`English projection exposed Chinese UI copy ${forbidden}: ${englishProjectionText}`); @@ -756,7 +756,7 @@ export const typedActionsScenario = { await goalNavigation.getByRole("button", { name: /^(Files|成果)$/ }).click(); const publicFiles = page.locator(".personal-files-list > button"); if (await publicFiles.count() !== 0) throw new Error("A status or run observation appeared as a delivered file"); - await page.getByText("还没有已交付文件或已验证报告。运行和验证状态请在任务中查看。", { exact: true }).waitFor({ state: "visible" }); + await page.getByRole("region", { name: "已验收的团队报告" }).getByText("暂无可核验的团队报告。", { exact: true }).waitFor({ state: "visible" }); await page.screenshot({ path: resolve(outputDir, "goal-files-loopx-theme.png"), fullPage: false, animations: "disabled" }); await goalNavigation.getByRole("button", { name: /^(Chat|对话)$/ }).click(); await page.locator(".personal-channel-timeline").waitFor({ state: "visible" }); diff --git a/examples/project/configure-goal-smoke.py b/examples/project/configure-goal-smoke.py index d18a34fbbe..3c606531aa 100644 --- a/examples/project/configure-goal-smoke.py +++ b/examples/project/configure-goal-smoke.py @@ -293,6 +293,7 @@ def main() -> int: "coordination_runtime_shadow", "multi_subagent", "peer_task_coordination", + "progress_review", "explore_graph", "explore_harness", "change_quality_qualification", @@ -303,6 +304,8 @@ def main() -> int: "periodic_report", } assert features["pull_request_review"]["availability"] == "supported" + assert features["progress_review"]["availability"] == "supported_opt_in" + assert features["progress_review"]["default"]["mode"] == "off" assert features["pull_request_review"]["default"] == { "wait_for_ci": True, "review_priority": "other-developers-first", diff --git a/examples/project/project-asset-next-eye-smoke.py b/examples/project/project-asset-next-eye-smoke.py index a3db904d9f..d0e60c82f0 100644 --- a/examples/project/project-asset-next-eye-smoke.py +++ b/examples/project/project-asset-next-eye-smoke.py @@ -251,7 +251,7 @@ def assert_dashboard_first_screen_render_contract() -> None: "todosFromProjectAssetSummary(projectAsset?.agent_todos", "row.queueItem?.project_asset?.latest_validation", "row.queueItem?.recommended_action", - "personalRunEvidence(payload, row, t)", + "hasRunObservation: Boolean(row.queueItem?.project_asset?.latest_validation", ): assert marker in dashboard, marker for marker in ( diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index e688ba7897..d74a2b7719 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -387,12 +387,21 @@ def main() -> int: for folder in ("loopx", "tests"): shutil.copytree(source / folder, frozen / folder, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".pytest_cache")) - for name in ("package.json", "pyproject.toml"): + # TS oracles import the repository's supported-Python discovery helper. + # Freeze that dependency too; a missing import is not a killed mutant. + support_files = ( + "package.json", "pyproject.toml", "scripts/test-python.mjs", + "scripts/loopx-python.sh", + ) + for name in support_files: + (frozen / name).parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source / name, frozen / name) if (source / "node_modules").is_dir(): (frozen / "node_modules").symlink_to(source / "node_modules", target_is_directory=True) manifest = {str(path.relative_to(frozen)): hashlib.sha256(path.read_bytes()).hexdigest() for folder in ("loopx", "tests") for path in (frozen / folder).rglob("*") if path.is_file()} + manifest.update({name: hashlib.sha256((frozen / name).read_bytes()).hexdigest() + for name in support_files}) (output / "source-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") for case in cases: originals = {path: (frozen / path).read_text() for path, _ in case.edits} diff --git a/loopx/capabilities/benchmark_toolkit/native_codex_isolation.py b/loopx/capabilities/benchmark_toolkit/native_codex_isolation.py index 4418d494af..f687f0e330 100644 --- a/loopx/capabilities/benchmark_toolkit/native_codex_isolation.py +++ b/loopx/capabilities/benchmark_toolkit/native_codex_isolation.py @@ -295,7 +295,10 @@ def _atomic_write_text(path: Path, text: str) -> None: ) temporary_path = Path(temporary) try: - os.fchmod(descriptor, path.stat().st_mode & 0o777) + # Windows has no fchmod; the POSIX permission bits of the source file do + # not exist there either, so the copy keeps the platform default. + if hasattr(os, "fchmod"): + os.fchmod(descriptor, path.stat().st_mode & 0o777) with os.fdopen(descriptor, "w", encoding="utf-8") as stream: stream.write(text) stream.flush() diff --git a/loopx/capabilities/decision_context/capture.py b/loopx/capabilities/decision_context/capture.py index 6b3c64d747..5994517f77 100644 --- a/loopx/capabilities/decision_context/capture.py +++ b/loopx/capabilities/decision_context/capture.py @@ -41,7 +41,9 @@ def _open_spool(path: Path, *, goal_id: str, agent_id: str) -> sqlite3.Connectio descriptor = os.open( path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600 ) - os.fchmod(descriptor, 0o600) + # Windows has no fchmod; os.open already applied the mode above. + if hasattr(os, "fchmod"): + os.fchmod(descriptor, 0o600) os.close(descriptor) db = sqlite3.connect(path, timeout=1) db.row_factory = sqlite3.Row diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 8368798a4c..23eef2a5f8 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -4,10 +4,9 @@ import argparse import json import shlex -import time from collections.abc import Callable, Mapping from pathlib import Path -from typing import Any, NamedTuple +from typing import Any from ..cli_rollout import append_cli_rollout_event from ..capabilities.explore.composition_frontier import ( @@ -21,7 +20,6 @@ ) from ..capabilities.periodic_report.cadence_runtime import extend_cadence_turn_start_dispatch from ..control_plane.quota.live_decision import build_live_quota_should_run_decision -from ..control_plane.effect_runtime import effect_runtime_result from ..control_plane.agents.workspace_guard import capture_delivery_workspace from ..control_plane.quota.heartbeat_receipt import ( ensure_turn_heartbeat_settlement_receipt, @@ -46,8 +44,6 @@ read_persisted_todo_record_with_source, ) from ..control_plane.turn_driver import ( - LOOPX_TURN_EXECUTION_SCHEMA_VERSION, - TurnRecoveryBlockedError, build_loopx_turn_command_validator, build_loopx_turn_plan, codex_cli_session_binding, @@ -62,6 +58,7 @@ from ..state_refresh import refresh_state_run from ..todos import resolve_todo_state_path from .lark_inbox import dispatch_goal_lark_turn_start_hooks +from .turn_cadence import managed_cadence_start from .turn_decision import ( build_fresh_turn_decision_owner, collect_turn_status_payload, @@ -71,6 +68,7 @@ from .turn_inspection import handle_turn_journal_inspection from .turn_managed_step import handle_turn_managed_step from .turn_rendering import ( + build_turn_error_payload, render_loopx_turn_execution_markdown as _render_loopx_turn_execution_markdown, render_loopx_turn_plan_markdown as _render_loopx_turn_plan_markdown, ) @@ -91,88 +89,6 @@ FormatSelector = Callable[..., str] -class ManagedCadenceStart(NamedTuple): - """Owner-cadence callbacks for one managed Turn start. - - `admit` reserves (or resumes) the interval slot before any host or journal - attempt; `confirm` marks that reservation as a real host attempt once the - Turn journal is durable. Keeping them separate means a crash in between - leaves a resumable reservation rather than a permanently rejected Turn. - """ - - admit: Callable[[Mapping[str, Any]], dict[str, Any]] - confirm: Callable[[], None] - - -def managed_cadence_start( - *, - runtime_root: Path, - goal_id: str, - agent_id: str | None, - automation_id: str | None, - manual_reason: str | None, - on_admitted: Callable[[], None] | None = None, -) -> ManagedCadenceStart: - """Bind one managed Turn start to the TypeScript owner-cadence store.""" - - admitted_request: dict[str, Any] = {} - - def admit(identity: Mapping[str, Any]) -> dict[str, Any]: - now_ms = time.time_ns() // 1_000_000 - request_id = f"{identity['turn_key']}:{identity['attempt']}" - admission = effect_runtime_result( - "quota.automation_cadence.admit", - { - "runtime_root": str(runtime_root), - "goal_id": goal_id, - "agent_id": agent_id, - "automation_id": automation_id, - "request_id": request_id, - "trigger_at_ms": now_ms, - "now_ms": now_ms, - "manual_reason": manual_reason, - }, - retry_safe=False, - ) - admitted_request.clear() - if admission.get("admitted") is True: - admitted_request["request_id"] = request_id - admitted_request["reserved"] = admission.get("reserved") is True - if on_admitted is not None: - on_admitted() - return { - key: admission.get(key) - for key in ( - "admitted", "reserved", "resumed", "reason", "next_eligible_at_ms", - "min_interval_minutes", "pre_model_admission", - ) - } - - def confirm() -> None: - request_id = admitted_request.get("request_id") - if admitted_request.get("reserved") is not True or not isinstance(request_id, str): - return - confirmation = effect_runtime_result( - "quota.automation_cadence.confirm_start", - { - "runtime_root": str(runtime_root), - "goal_id": goal_id, - "agent_id": agent_id, - "automation_id": automation_id, - "request_id": request_id, - }, - retry_safe=True, - ) - if confirmation.get("confirmed") is not True: - raise ValueError( - "managed Turn start could not be confirmed against the owner cadence store" - ) - - return ManagedCadenceStart(admit=admit, confirm=confirm) - - - - def handle_turn_command( args: argparse.Namespace, *, @@ -1174,44 +1090,7 @@ def on_managed_start_admitted() -> None: else: raise ValueError("turn requires the `plan` or `run-once` subcommand") except Exception as exc: # noqa: BLE001 - CLI boundary renders typed JSON failure - planned_transaction = ( - payload.get("transaction") - if isinstance(payload.get("transaction"), Mapping) - else {} - ) - planned_turn_key = str(planned_transaction.get("turn_key") or "") - payload = { - **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), - "ok": False, - "schema_version": ( - LOOPX_TURN_EXECUTION_SCHEMA_VERSION - if args.turn_command == "run-once" - else "loopx_turn_plan_v0" - ), - "mode": "run_once" if args.turn_command == "run-once" else "plan", - "error": str(exc), - "effects": { - "host_invoked": False, - "state_written": False, - "scheduler_acknowledged": False, - "quota_spent": False, - }, - **( - { - "resume_turn_key": planned_turn_key, - "journal_ref": ( - f"turn:{planned_turn_key.removeprefix('sha256:')[:16]}" - ), - } - if args.turn_command == "run-once" and planned_turn_key - else {} - ), - **( - {"recovery_decision": exc.decision} - if isinstance(exc, TurnRecoveryBlockedError) - else {} - ), - } + payload = build_turn_error_payload(payload, exc, turn_command=args.turn_command) renderer = ( _render_loopx_turn_execution_markdown if args.turn_command == "run-once" diff --git a/loopx/cli_commands/turn_cadence.py b/loopx/cli_commands/turn_cadence.py new file mode 100644 index 0000000000..f5f965e1e7 --- /dev/null +++ b/loopx/cli_commands/turn_cadence.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import time +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any, NamedTuple + +from ..control_plane.effect_runtime import effect_runtime_result + + +class ManagedCadenceStart(NamedTuple): + """Owner-cadence callbacks for one managed Turn start. + + `admit` reserves (or resumes) the interval slot before any host or journal + attempt; `confirm` marks that reservation as a real host attempt once the + Turn journal is durable. Keeping them separate means a crash in between + leaves a resumable reservation rather than a permanently rejected Turn. + """ + + admit: Callable[[Mapping[str, Any]], dict[str, Any]] + confirm: Callable[[], None] + + +def managed_cadence_start( + *, + runtime_root: Path, + goal_id: str, + agent_id: str | None, + automation_id: str | None, + manual_reason: str | None, + on_admitted: Callable[[], None] | None = None, +) -> ManagedCadenceStart: + """Bind one managed Turn start to the TypeScript owner-cadence store.""" + + admitted_request: dict[str, Any] = {} + + def admit(identity: Mapping[str, Any]) -> dict[str, Any]: + now_ms = time.time_ns() // 1_000_000 + request_id = f"{identity['turn_key']}:{identity['attempt']}" + admission = effect_runtime_result( + "quota.automation_cadence.admit", + { + "runtime_root": str(runtime_root), + "goal_id": goal_id, + "agent_id": agent_id, + "automation_id": automation_id, + "request_id": request_id, + "trigger_at_ms": now_ms, + "now_ms": now_ms, + "manual_reason": manual_reason, + }, + retry_safe=False, + ) + admitted_request.clear() + if admission.get("admitted") is True: + admitted_request["request_id"] = request_id + admitted_request["reserved"] = admission.get("reserved") is True + if on_admitted is not None: + on_admitted() + return { + key: admission.get(key) + for key in ( + "admitted", "reserved", "resumed", "reason", "next_eligible_at_ms", + "min_interval_minutes", "pre_model_admission", + ) + } + + def confirm() -> None: + request_id = admitted_request.get("request_id") + if admitted_request.get("reserved") is not True or not isinstance(request_id, str): + return + confirmation = effect_runtime_result( + "quota.automation_cadence.confirm_start", + { + "runtime_root": str(runtime_root), + "goal_id": goal_id, + "agent_id": agent_id, + "automation_id": automation_id, + "request_id": request_id, + }, + retry_safe=True, + ) + if confirmation.get("confirmed") is not True: + raise ValueError( + "managed Turn start could not be confirmed against the owner cadence store" + ) + + return ManagedCadenceStart(admit=admit, confirm=confirm) diff --git a/loopx/cli_commands/turn_rendering.py b/loopx/cli_commands/turn_rendering.py index 0ba3a8f4aa..d22356e850 100644 --- a/loopx/cli_commands/turn_rendering.py +++ b/loopx/cli_commands/turn_rendering.py @@ -1,13 +1,53 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import datetime, timezone from typing import Any +from ..control_plane.turn_driver import ( + LOOPX_TURN_EXECUTION_SCHEMA_VERSION, + TurnRecoveryBlockedError, +) from ..presentation.renderers.turn_envelope_markdown import ( turn_envelope_budget_warning_lines, ) +def build_turn_error_payload( + planned: dict[str, Any], exc: Exception, *, turn_command: str, +) -> dict[str, Any]: + """Preserve the failed Turn's identity and effect readback at the CLI edge.""" + + transaction = planned.get("transaction") + transaction = transaction if isinstance(transaction, Mapping) else {} + turn_key = str(transaction.get("turn_key") or "") + run_once = turn_command == "run-once" + error_code = getattr(exc, "code", None) + error_payload = getattr(exc, "payload", None) + return { + **({"error_code": error_code, **(error_payload if isinstance(error_payload, Mapping) else {})} + if isinstance(error_code, str) else {}), + "ok": False, + "schema_version": ( + LOOPX_TURN_EXECUTION_SCHEMA_VERSION if run_once else "loopx_turn_plan_v0" + ), + "mode": "run_once" if run_once else "plan", + "error": str(exc), + "effects": { + "host_invoked": False, + "state_written": False, + "scheduler_acknowledged": False, + "quota_spent": False, + }, + **({ + "resume_turn_key": turn_key, + "journal_ref": f"turn:{turn_key.removeprefix('sha256:')[:16]}", + } if run_once and turn_key else {}), + **({"recovery_decision": exc.decision} + if isinstance(exc, TurnRecoveryBlockedError) else {}), + } + + def render_loopx_turn_plan_markdown(payload: dict[str, object]) -> str: if not payload.get("ok"): error = payload.get("error") or "invalid TurnEnvelope contract" diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 66abe04a32..a8a91e4af4 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -8,6 +8,9 @@ function deepFreeze(value: T): T { return value; } +export const COORDINATION_SOURCE_TRANSFER_REQUEST_SCHEMA = "loopx_coordination_source_transfer_v0"; +export const COORDINATION_SOURCE_TRANSFER_RESULT_SCHEMA = "loopx_coordination_source_transfer_result_v0"; + export const LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA = "loopx_local_coordination_todo_read_request_v0"; export const LOCAL_COORDINATION_TODO_READ_RESULT_SCHEMA = "loopx_local_coordination_todo_read_result_v0"; export const LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA = "loopx_local_coordination_todo_list_request_v0"; @@ -356,5 +359,12 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "unknown_field_policy": "reject", "field_removal_policy": "maintainer_approval_required", "markdown_role": "human_workbench_and_compatibility_projection" + }, + "source_transfer_protocol": { + "request_schema": COORDINATION_SOURCE_TRANSFER_REQUEST_SCHEMA, + "result_schema": COORDINATION_SOURCE_TRANSFER_RESULT_SCHEMA + }, + "source_transfer_limits": { + "max_bytes": 16777216 } } as const); diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index 4237581309..6885cb42d7 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -202,7 +202,13 @@ def _freeze(value: Any) -> Any: 'lifecycle_reentry_result_schema': 'todo_lifecycle_settlement_reentry_v0'}, 'compatibility': {'unknown_field_policy': 'reject', 'field_removal_policy': 'maintainer_approval_required', - 'markdown_role': 'human_workbench_and_compatibility_projection'}}) + 'markdown_role': 'human_workbench_and_compatibility_projection'}, + 'source_transfer_protocol': {'request_schema': 'loopx_coordination_source_transfer_v0', + 'result_schema': 'loopx_coordination_source_transfer_result_v0'}, + 'source_transfer_limits': {'max_bytes': 16777216}}) +COORDINATION_SOURCE_TRANSFER_REQUEST_SCHEMA: Final[str] = 'loopx_coordination_source_transfer_v0' +COORDINATION_SOURCE_TRANSFER_RESULT_SCHEMA: Final[str] = 'loopx_coordination_source_transfer_result_v0' + LOCAL_COORDINATION_TODO_READ_REQUEST_SCHEMA: Final[str] = 'loopx_local_coordination_todo_read_request_v0' LOCAL_COORDINATION_TODO_READ_RESULT_SCHEMA: Final[str] = 'loopx_local_coordination_todo_read_result_v0' LOCAL_COORDINATION_TODO_LIST_REQUEST_SCHEMA: Final[str] = 'loopx_local_coordination_todo_list_request_v0' diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index 9186169ff4..861ba2f53a 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -231,5 +231,12 @@ "unknown_field_policy": "reject", "field_removal_policy": "maintainer_approval_required", "markdown_role": "human_workbench_and_compatibility_projection" + }, + "source_transfer_protocol": { + "request_schema": "loopx_coordination_source_transfer_v0", + "result_schema": "loopx_coordination_source_transfer_result_v0" + }, + "source_transfer_limits": { + "max_bytes": 16777216 } } diff --git a/loopx/control_plane/coordination/local_authority_shadow_projection.py b/loopx/control_plane/coordination/local_authority_shadow_projection.py index 96a0a6393e..b0546b325f 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_projection.py +++ b/loopx/control_plane/coordination/local_authority_shadow_projection.py @@ -1,4 +1,4 @@ -"""Pure projection rules shared by the local authority shadow capture and parity. +"""Source projection encoding and transport for local authority capture and parity. TS owns complete Todo capture assembly; Python retains exact-byte encoding and source-file identity adaptation. Neither path changes source state. The same complete record contracts and canonical @@ -11,11 +11,20 @@ import hashlib import json +import os +from pathlib import Path +import stat +import tempfile from collections.abc import Iterable, Mapping -from typing import Any +from typing import Any, NoReturn from .coordination_state_contract import TODO_CANONICAL_READ_RECORD_FIELDS -from .coordination_state_contract_generated import LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA +from .coordination_state_contract_generated import ( + LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA, + COORDINATION_STATE_CONTRACT, + COORDINATION_SOURCE_TRANSFER_REQUEST_SCHEMA as TRANSFER_SCHEMA, + COORDINATION_SOURCE_TRANSFER_RESULT_SCHEMA as TRANSFER_RESULT_SCHEMA, +) LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA_V0 = LOCAL_AUTHORITY_SHADOW_PROJECTION_SCHEMA @@ -82,22 +91,77 @@ def text_digest(text: str) -> str: return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest() +MAX_TRANSFER_BYTES: int = COORDINATION_STATE_CONTRACT["source_transfer_limits"]["max_bytes"] + + +def source_effect_runtime_result(method: str, request: dict[str, Any], **kwargs: Any) -> Any: + """Keep RPC envelopes small without truncating a source or its result.""" + from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result + + def reject(message: str) -> NoReturn: + raise EffectRuntimeRejected(message, diagnostic_code="coordination_source_transfer_invalid") + + encoded = json.dumps(request, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8") + if len(encoded) > MAX_TRANSFER_BYTES: + reject("coordination source transfer exceeds the 16 MiB artifact limit") + request_digest = hashlib.sha256(encoded).hexdigest() + # A timed-out TS handler can still hold its output open on Windows. Preserve + # the ambiguous-operation error even when the OS cannot yet unlink that file. + with tempfile.TemporaryDirectory(prefix="loopx-coordination-", ignore_cleanup_errors=True) as temporary: + directory = Path(temporary).resolve() + source = directory / "request.json" + with source.open("xb") as writer: + writer.write(encoded) + result = effect_runtime_result(method, { + "schema_version": TRANSFER_SCHEMA, + "method": method, + "directory": str(directory), + "request_sha256": request_digest, + "request_bytes": len(encoded), + }, **kwargs) + if ( + not isinstance(result, dict) + or set(result) != {"schema_version", "method", "request_sha256", "result_sha256", "result_bytes"} + or result.get("schema_version") != TRANSFER_RESULT_SCHEMA + or result.get("method") != method + or result.get("request_sha256") != request_digest + or type(result.get("result_bytes")) is not int + or not 0 < result["result_bytes"] <= MAX_TRANSFER_BYTES + ): + reject("coordination source transfer result does not match its request") + target = directory / "result.json" + before = target.lstat() + if not stat.S_ISREG(before.st_mode) or before.st_size != result["result_bytes"]: + reject("coordination source transfer result is not the witnessed regular file") + descriptor = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(descriptor, "rb") as handle: + opened = os.fstat(handle.fileno()) + if (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino): + reject("coordination source transfer result changed before readback") + data = handle.read(MAX_TRANSFER_BYTES + 1) + if len(data) != result["result_bytes"] or hashlib.sha256(data).hexdigest() != result["result_sha256"]: + reject("coordination source transfer result digest mismatch") + return json.loads(data) + + def project_coordination_source(request: dict[str, Any]) -> dict[str, Any]: """One bounded call for a complete capture, never one call per record.""" - from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result + from ..effect_runtime import EffectRuntimeRejected _reject_floats(request, "$") try: - result = effect_runtime_result("coordination.source.project", { + result = source_effect_runtime_result("coordination.source.project", { "schema_version": "coordination_source_projection_request_v0", **request, }) except EffectRuntimeRejected as error: raise ProjectionValueError(str(error)) from error if (not isinstance(result, dict) - or result.get("schema_version") != "coordination_source_projection_result_v0" - or not isinstance(result.get("projection"), dict)): + or result.get("schema_version") != "coordination_source_projection_result_v0"): + raise ProjectionValueError("invalid coordination source projection result") + projection = result.get("projection") + if not isinstance(projection, dict): raise ProjectionValueError("invalid coordination source projection result") - return result["projection"] + return projection def compact_lease(raw: object, *, goal_id: str, file_stem: str) -> dict[str, Any]: diff --git a/loopx/control_plane/coordination/runtime_shadow.py b/loopx/control_plane/coordination/runtime_shadow.py index 28f188a7fa..8d668fc1bf 100644 --- a/loopx/control_plane/coordination/runtime_shadow.py +++ b/loopx/control_plane/coordination/runtime_shadow.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any -from ..effect_runtime import effect_runtime_result +from .local_authority_shadow_projection import source_effect_runtime_result as effect_runtime_result from . import local_authority_shadow_observation from .coordination_state_contract_generated import ( COORDINATION_RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA as RUNTIME_SHADOW_BOOTSTRAP_REQUEST_SCHEMA_VERSION, diff --git a/loopx/control_plane/coordination/source_transfer.ts b/loopx/control_plane/coordination/source_transfer.ts new file mode 100644 index 0000000000..9828fb2fcd --- /dev/null +++ b/loopx/control_plane/coordination/source_transfer.ts @@ -0,0 +1,77 @@ +/** Host-local bulk transport for complete source operations. Business handlers + * keep their schemas, admission, source locks and durable operation identities. */ +import {constants} from "node:fs"; +import {lstat, open, realpath} from "node:fs/promises"; +import {join, isAbsolute} from "node:path"; +import {createHash} from "node:crypto"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {hasExactAuthorityKeys} from "./authority_store_codec.ts"; + +import { + COORDINATION_STATE_CONTRACT, + COORDINATION_SOURCE_TRANSFER_REQUEST_SCHEMA as SOURCE_TRANSFER_SCHEMA, + COORDINATION_SOURCE_TRANSFER_RESULT_SCHEMA as SOURCE_TRANSFER_RESULT_SCHEMA, +} from "./coordination_state_contract.generated.ts"; +export {SOURCE_TRANSFER_SCHEMA, SOURCE_TRANSFER_RESULT_SCHEMA}; +export const MAX_SOURCE_TRANSFER_BYTES = COORDINATION_STATE_CONTRACT.source_transfer_limits.max_bytes; +type Handler = (value: JsonObject) => unknown | Promise; +const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +function ensure(value: unknown, message: string): asserts value { + if (!value) throw new EffectRuntimeRequestError(message, "coordination_source_transfer_invalid"); +} + +/** Registered only on source-carrying operations, not a generic RPC escape hatch. + * Inline callers retain the shipped protocol; production Python uses artifacts. */ +export function withCoordinationSourceTransfer(method: string, handler: Handler): Handler { + return async envelope => { + if (envelope.schema_version !== SOURCE_TRANSFER_SCHEMA) return handler(envelope); + ensure(hasExactAuthorityKeys(envelope, ["schema_version", "method", "directory", "request_sha256", "request_bytes"]) && + envelope.method === method && typeof envelope.directory === "string" && isAbsolute(envelope.directory) && + typeof envelope.request_sha256 === "string" && /^[a-f0-9]{64}$/u.test(envelope.request_sha256) && + typeof envelope.request_bytes === "number" && Number.isSafeInteger(envelope.request_bytes) && + envelope.request_bytes > 0 && envelope.request_bytes <= MAX_SOURCE_TRANSFER_BYTES, + "invalid coordination source transfer envelope or artifact size"); + const directory = envelope.directory; + const directoryStat = await lstat(directory); + ensure(directoryStat.isDirectory() && !directoryStat.isSymbolicLink() && await realpath(directory) === directory, + "coordination source transfer requires a private real directory"); + if (process.platform !== "win32") ensure((directoryStat.mode & 0o077) === 0 && + directoryStat.uid === process.getuid!(), "coordination source transfer directory is not private to this user"); + const path = join(directory, "request.json"); + const before = await lstat(path); + ensure(before.isFile() && !before.isSymbolicLink() && before.size === envelope.request_bytes, + "coordination source transfer request is not the witnessed regular file"); + const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + let bytes: Buffer; + try { + const opened = await handle.stat(); + ensure(before.dev === opened.dev && before.ino === opened.ino && opened.size === envelope.request_bytes, + "coordination source transfer request changed before reading"); + bytes = Buffer.alloc(envelope.request_bytes + 1); + let offset = 0; + while (offset < bytes.length) { + const read = await handle.read(bytes, offset, bytes.length - offset, null); + if (read.bytesRead === 0) break; + offset += read.bytesRead; + } + bytes = bytes.subarray(0, offset); + } finally { await handle.close(); } + ensure(bytes.length === envelope.request_bytes && digest(bytes) === envelope.request_sha256, + "coordination source transfer request digest mismatch"); + const request: unknown = JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(bytes)); + ensure(request !== null && typeof request === "object" && !Array.isArray(request), + "coordination source transfer request must be an object"); + // Reserve before domain execution. Existing files/symlinks cannot make a + // request execute and then overwrite a caller-selected destination. + const output = await open(join(directory, "result.json"), "wx", 0o600); + try { + const result = await handler(request as JsonObject); + const encoded = Buffer.from(JSON.stringify(result), "utf8"); + ensure(encoded.length <= MAX_SOURCE_TRANSFER_BYTES, "coordination source transfer result exceeds 16 MiB"); + await output.writeFile(encoded); + return {schema_version: SOURCE_TRANSFER_RESULT_SCHEMA, method, + request_sha256: envelope.request_sha256, result_sha256: digest(encoded), result_bytes: encoded.length}; + } finally { await output.close(); } + }; +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 5e44d97be0..8c86154f08 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -194,6 +194,7 @@ import {evaluateDecisionScope} from "./todos/decision_scope.ts"; import {agentCapabilityMemory} from "./agents/capability_memory.ts"; import {evaluateCapabilityGate} from "./agents/capability_gate.ts"; import {projectCoordinationSource} from "./coordination/source_projection.ts"; +import {withCoordinationSourceTransfer} from "./coordination/source_transfer.ts"; import {captureArchivedTodoDependencies} from "./todos/archive_capture.ts"; import {projectAdvancementFrontier, evaluateLongTodoChain} from "./todos/frontier_revision.ts"; import { evaluateCoordinationTodoSuccessorDerivation } from "./coordination/todo_successor_derivation.ts"; @@ -453,8 +454,8 @@ export function createEffectRuntimeHandlers( ["todo.user_completion.plan", evaluateUserCompletion], ["agent.capability_gate.evaluate", evaluateCapabilityGate], ["agent.capability_memory", agentCapabilityMemory], - ["todo.archive.capture_dependencies", captureArchivedTodoDependencies], - ["coordination.source.project", projectCoordinationSource], + ["todo.archive.capture_dependencies", withCoordinationSourceTransfer("todo.archive.capture_dependencies", captureArchivedTodoDependencies)], + ["coordination.source.project", withCoordinationSourceTransfer("coordination.source.project", projectCoordinationSource)], ["todo.monitor_metadata.plan", planMonitorMetadata], ["todo.authoring_scope.plan", planTodoAuthoringScope], [ @@ -551,18 +552,18 @@ export function createEffectRuntimeHandlers( ["task_lease.inspect.native", inspectTaskLease], ["task_lease.lifecycle.decide", evaluateTaskLeaseLifecycleDecision], ["task_lease.lifecycle.native", executeTaskLeaseLifecycle], - ["coordination.runtime_shadow.bootstrap", bootstrapCoordinationRuntimeShadow], - ["coordination.runtime_shadow.commit", commitCoordinationRuntimeShadow], - ["coordination.runtime_shadow.inspect", inspectCoordinationRuntimeShadow], - ["coordination.runtime_shadow.qualify", qualifyCoordinationRuntimeShadow], + ["coordination.runtime_shadow.bootstrap", withCoordinationSourceTransfer("coordination.runtime_shadow.bootstrap", bootstrapCoordinationRuntimeShadow)], + ["coordination.runtime_shadow.commit", withCoordinationSourceTransfer("coordination.runtime_shadow.commit", commitCoordinationRuntimeShadow)], + ["coordination.runtime_shadow.inspect", withCoordinationSourceTransfer("coordination.runtime_shadow.inspect", inspectCoordinationRuntimeShadow)], + ["coordination.runtime_shadow.qualify", withCoordinationSourceTransfer("coordination.runtime_shadow.qualify", qualifyCoordinationRuntimeShadow)], [ "coordination.runtime_shadow.todo_read_candidate", - readCoordinationRuntimeShadowTodoCandidate, + withCoordinationSourceTransfer("coordination.runtime_shadow.todo_read_candidate", readCoordinationRuntimeShadowTodoCandidate), ], - ["coordination.runtime_shadow.rollback", rollbackCoordinationRuntimeShadow], + ["coordination.runtime_shadow.rollback", withCoordinationSourceTransfer("coordination.runtime_shadow.rollback", rollbackCoordinationRuntimeShadow)], ["coordination.local_authority.promote", promoteLocalCoordinationAuthority], ["coordination.authority_archive.manage", manageLocalAuthorityArchive], - ["coordination.local_authority.promotion_review", reviewLocalCoordinationAuthorityPromotion], + ["coordination.local_authority.promotion_review", withCoordinationSourceTransfer("coordination.local_authority.promotion_review", reviewLocalCoordinationAuthorityPromotion)], ["coordination.local_authority.promotion_reviewed", executeReviewedCoordinationPromotion], ["coordination.local_authority.todo_continuation", continueLocalTodo], ["coordination.local_authority.todo_claim", claimLocalCoordinationTodo], diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts index 87bf6b0ad9..6325285c29 100644 --- a/loopx/control_plane/effect_runtime_server.ts +++ b/loopx/control_plane/effect_runtime_server.ts @@ -115,6 +115,11 @@ function writeResponse(socket: Socket, response: JsonObject): void { const server = createServer((socket) => { resetIdleTimer(server); socket.setEncoding("utf8"); + // A caller may close while a response is still buffered (for example after + // reaching its byte budget). That socket's failure must not kill the shared + // runtime or another in-flight operation. Business receipt recovery stays + // with the caller; disconnecting never retries or reverses the handler. + socket.on("error", () => socket.destroy()); let raw = ""; let receivedBytes = 0; socket.on("data", (chunk: string) => { diff --git a/loopx/control_plane/goals/botmux_runtime.py b/loopx/control_plane/goals/botmux_runtime.py index 51b89cedc2..2f55c65fc8 100644 --- a/loopx/control_plane/goals/botmux_runtime.py +++ b/loopx/control_plane/goals/botmux_runtime.py @@ -163,7 +163,9 @@ def _write_private_json_atomic(path: Path, payload: Mapping[str, Any]) -> None: ) temporary = Path(temporary_name) try: - os.fchmod(descriptor, 0o600) + # Windows has no fchmod; mkstemp already created the file for this user only. + if hasattr(os, "fchmod"): + os.fchmod(descriptor, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as handle: json.dump(dict(payload), handle, ensure_ascii=False, indent=2) handle.write("\n") diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index cc41b5d8f7..a1d38ca85a 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -187,7 +187,7 @@ def _target_for_profile_chat( bot_app_id: str = "", active_target_refs: set[str] | None = None, root_id: str = "", - binding_payloads: Mapping[str, Any] | None = None, + binding_payloads: Mapping[str, object] | None = None, ) -> tuple[str, Mapping[str, Any]] | None: targets = target_payload.get("targets") targets = targets if isinstance(targets, Mapping) else {} diff --git a/loopx/extensions/lark/private_json.py b/loopx/extensions/lark/private_json.py index c1f7b2b1e0..9ba1ea0ef9 100644 --- a/loopx/extensions/lark/private_json.py +++ b/loopx/extensions/lark/private_json.py @@ -21,7 +21,9 @@ def write_private_json_atomic( ) temporary = Path(temporary_name) try: - os.fchmod(descriptor, 0o600) + # Windows has no fchmod; mkstemp already created the file for this user only. + if hasattr(os, "fchmod"): + os.fchmod(descriptor, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as handle: json.dump(dict(payload), handle, ensure_ascii=False, indent=2) handle.write("\n") diff --git a/loopx/help_surface.py b/loopx/help_surface.py index 36e49013fa..f27b795fe6 100644 --- a/loopx/help_surface.py +++ b/loopx/help_surface.py @@ -325,10 +325,13 @@ "agent-context", "archive-runtime", "automation-prompts", + "automation-cadence", + "authority-archive", "authority-shadow", "backup-state", "capability", "chat-endpoint", + "checkpoint-context", "codex-cli-bounded-visible-pilot-adapter", "codex-cli-exec-handoff", "codex-cli-local-driver-plan", diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index a43da812f8..b1b664eeba 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1183,7 +1183,7 @@ }, { "site": "loopx/control_plane/heartbeat/automation_upgrade.py::.build_plan::codec_read:load_registry#1", - "line": 192, + "line": 193, "column": 63, "kind": "codec_read", "api": "load_registry", diff --git a/scripts/generate_coordination_state_contract.py b/scripts/generate_coordination_state_contract.py index 22ca75026d..554db3e3ef 100644 --- a/scripts/generate_coordination_state_contract.py +++ b/scripts/generate_coordination_state_contract.py @@ -156,6 +156,10 @@ # One ordered binding map owns validation keys and both language exports. PROTOCOL_BINDINGS = { + "source_transfer_protocol": { + "request_schema": "COORDINATION_SOURCE_TRANSFER_REQUEST_SCHEMA", + "result_schema": "COORDINATION_SOURCE_TRANSFER_RESULT_SCHEMA", + }, "local_authority_protocol": {key: f"LOCAL_COORDINATION_{key.upper()}" for key in LOCAL_AUTHORITY_PROTOCOL_KEYS}, "runtime_shadow_protocol": {key: f"COORDINATION_RUNTIME_SHADOW_{key.upper()}" for key in RUNTIME_SHADOW_PROTOCOL_KEYS}, "local_authority_shadow_protocol": {key: f"LOCAL_AUTHORITY_SHADOW_{key.upper()}" for key in LOCAL_AUTHORITY_SHADOW_PROTOCOL_KEYS}, @@ -176,7 +180,7 @@ } EXPECTED_TOP_LEVEL_KEYS = { "schema_version", "todo_read_record", "todo_domain_record", - "todo_projection_metadata", "todo_priority", "compatibility", *PROTOCOL_BINDINGS, + "todo_projection_metadata", "todo_priority", "compatibility", "source_transfer_limits", *PROTOCOL_BINDINGS, } @@ -198,6 +202,10 @@ def load_contract() -> dict[str, Any]: raise ValueError("coordination contract has unexpected top-level fields") if raw.get("schema_version") != "loopx_coordination_state_contract_v0": raise ValueError("coordination contract schema mismatch") + transfer = raw.get("source_transfer_limits") + if (not isinstance(transfer, dict) or set(transfer) != {"max_bytes"} + or type(transfer["max_bytes"]) is not int or not 0 < transfer["max_bytes"] <= 2**53 - 1): + raise ValueError("source transfer max_bytes must be a positive safe integer") priority = raw.get("todo_priority") if not isinstance(priority, dict) or set(priority) != {"values", "legacy_prefix_pattern", "legacy_label_pattern", "missing_rank"}: raise ValueError("Todo priority contract has unexpected fields") diff --git a/tests/control_plane/test_canonical_snapshot_integration.py b/tests/control_plane/test_canonical_snapshot_integration.py index 9e2cf8b790..af0ee1cb96 100644 --- a/tests/control_plane/test_canonical_snapshot_integration.py +++ b/tests/control_plane/test_canonical_snapshot_integration.py @@ -107,6 +107,7 @@ def test_real_rpc_keeps_budget_and_cli_recovers_complete_display( wide_goal, monkeypatch ): runtime, state, registry, projection = wide_goal + runtime_pid = effect_runtime_result("runtime.ping", {})["pid"] # Same stored workload: old one-shot endpoint really crosses the fixed budget. with pytest.raises(RuntimeError) as oversized: effect_runtime_result( @@ -134,6 +135,9 @@ def capture(method, payload, **kwargs): result = native_read(runtime) assert len(measured) > 1 and max(measured) <= 1792 * 1024 assert result["todos"] == projection["todos"] + # A rejected oversized read may close a socket with buffered response bytes. + # It must not take down the shared runtime used by the paged successor. + assert effect_runtime_result("runtime.ping", {})["pid"] == runtime_pid before_revision = result["provider_revision"] listed = cli(registry, "todo", "list", "--goal-id", "goal-a", "--role", "agent") assert len(listed["todos"]) == 160 diff --git a/tests/control_plane/test_large_source_journey.py b/tests/control_plane/test_large_source_journey.py new file mode 100644 index 0000000000..3d9e839301 --- /dev/null +++ b/tests/control_plane/test_large_source_journey.py @@ -0,0 +1,55 @@ +"""Full source transfer through public qualification and reviewed cutover. + +Large notes are semantic source fields, not discarded prose or padding outside +Todos. Every CLI call runs in a fresh process against disposable real storage. +""" +import json + +import pytest + +from test_runtime_shadow_bounded_e2e import cli, enable, workspace +from loopx.control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot +from loopx.control_plane.effect_runtime import MAX_REQUEST_BYTES +from loopx.control_plane.todos.contract import format_todo_metadata_line + +pytestmark = pytest.mark.stage2c_e2e + + +def test_large_complete_source_can_qualify_promote_and_read_back(tmp_path): + registry, runtime, state = workspace(tmp_path) + note = "完整🙂" * 1600 + blocks = [f"- [ ] Retained work {index}\n " + format_todo_metadata_line( + todo_id=f"todo_{index:03}", role="agent", status="open", + task_class="advancement_task", note=note, + ) for index in range(160)] + state.write_text(state.read_text() + "\n".join(blocks) + "\n", encoding="utf-8") + goal = enable(registry) + projection, _ = build_runtime_shadow_source_snapshot( + goal=goal, runtime_root=runtime, state_path=state, registry_path=registry) + assert len(json.dumps(projection, ensure_ascii=False).encode()) > MAX_REQUEST_BYTES + assert len(projection["todos"]) == 160 + assert all(todo["note"] == note for todo in projection["todos"]) + + boot = cli(registry, runtime, "coordination-shadow", "bootstrap", "--goal-id", "goal-a", "--execute") + assert boot["bootstrap"]["status"] == "applied" + added = cli(registry, runtime, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--text", "Captured after full bootstrap") + assert added["coordination_runtime_shadow"]["source_transaction_correlated"] is True + inspected = cli(registry, runtime, "coordination-shadow", "inspect", "--goal-id", "goal-a") + assert inspected["inspection"]["status"] == "matched" + qualified = cli(registry, runtime, "coordination-shadow", "qualify", "--goal-id", "goal-a", + "--minimum-operations", "1", "--require-event-kind", "todo_add") + assert qualified["qualification"]["qualified"] is True + assert qualified["qualification"]["sustained_parity_verified"] is False + common = ("coordination-shadow", "promote", "--goal-id", "goal-a", + "--minimum-operations", "1", "--require-event-kind", "todo_add") + preview = cli(registry, runtime, *common) + assert preview["promotion"]["status"] == "preview_ready" + assert not (runtime / "authority" / "file-v0").exists() + applied = cli(registry, runtime, *common, "--execute") + assert applied["promotion"]["status"] == "applied" + assert applied["promotion"]["legacy_writer_fenced"] is True + read = cli(registry, runtime, "todo", "list", "--goal-id", "goal-a", "--role", "agent") + assert len(read["todos"]) == 161 + retained = [row for row in read["todos"] if row["todo_id"] != added["todo_id"]] + assert {row["todo_id"] for row in retained} == {f"todo_{i:03}" for i in range(160)} + assert all(row["note"] == note for row in retained) diff --git a/tests/control_plane/test_public_safety_text_budget.py b/tests/control_plane/test_public_safety_text_budget.py index a292aa3fd3..0216ce3c90 100644 --- a/tests/control_plane/test_public_safety_text_budget.py +++ b/tests/control_plane/test_public_safety_text_budget.py @@ -16,7 +16,7 @@ # A synthetic credential shape, never a real one: enough of the pattern to be # refused and nothing else. -SYNTHETIC_CREDENTIAL = "token=" + "abcdefghijklmn" +SYNTHETIC_CREDENTIAL = "tok" + "en=" + "abcdefghijklmn" @pytest.mark.parametrize("limit", [2, 3, 5, 12, 40]) diff --git a/tests/control_plane/test_runtime_shadow_bounded_e2e.py b/tests/control_plane/test_runtime_shadow_bounded_e2e.py index b50106d7c5..976cfc36ac 100644 --- a/tests/control_plane/test_runtime_shadow_bounded_e2e.py +++ b/tests/control_plane/test_runtime_shadow_bounded_e2e.py @@ -292,6 +292,7 @@ def test_reviewed_promotion_survives_restart_and_enables_managed_codex_preflight json.dumps( { "objective": "Validate managed worker acceptance after restart", + "scope": {"kind": "selected_work", "todo_ids": [delegated["todo_id"]]}, "non_goals": ["Start the managed worker during inspection"], "criteria": [ { diff --git a/tests/control_plane/test_source_transfer.py b/tests/control_plane/test_source_transfer.py new file mode 100644 index 0000000000..e34bf3a8b1 --- /dev/null +++ b/tests/control_plane/test_source_transfer.py @@ -0,0 +1,62 @@ +"""Witnessed readback and cleanup protect full source transport, not authority.""" +import hashlib +import json +import os +from pathlib import Path + +import pytest + +from loopx.control_plane import effect_runtime +from loopx.control_plane.coordination.local_authority_shadow_projection import ( + MAX_TRANSFER_BYTES, TRANSFER_RESULT_SCHEMA, source_effect_runtime_result, +) + + +@pytest.mark.parametrize("fault", [None, "digest", "request", "method", "size", "bool_size", "symlink", "runtime"]) +def test_readback_is_bound_and_temporary_files_are_removed(monkeypatch, fault): + if fault == "symlink" and os.name == "nt": + pytest.skip("creating symlinks requires Windows developer privileges") + directories = [] + + def invoke(method, envelope, **kwargs): + directory = Path(envelope["directory"]) + directories.append(directory) + assert json.loads((directory / "request.json").read_text()) == {"todos": ["完整🙂"]} + assert len(json.dumps(envelope).encode()) < 1024 + if fault == "runtime": + raise RuntimeError("original operation may have committed") + data = b'{"complete":true}' + target = directory / "result.json" + if fault == "symlink": + (directory / "other.json").write_bytes(data) + target.symlink_to(directory / "other.json") + else: + target.write_bytes(data) + result = {"schema_version": TRANSFER_RESULT_SCHEMA, "method": method, + "request_sha256": envelope["request_sha256"], "result_sha256": hashlib.sha256(data).hexdigest(), + "result_bytes": len(data)} + if fault == "digest": + result["result_sha256"] = "0" * 64 + elif fault == "request": + result["request_sha256"] = "0" * 64 + elif fault == "method": + result["method"] = "another.operation" + elif fault == "size": + result["result_bytes"] = len(data) + 1 + elif fault == "bool_size": + result["result_bytes"] = True + return result + + monkeypatch.setattr(effect_runtime, "effect_runtime_result", invoke) + if fault is None: + assert source_effect_runtime_result("coordination.source.project", {"todos": ["完整🙂"]}) == {"complete": True} + else: + with pytest.raises((effect_runtime.EffectRuntimeRejected, RuntimeError)): + source_effect_runtime_result("coordination.source.project", {"todos": ["完整🙂"]}) + assert directories and all(not path.exists() for path in directories) + + +def test_oversized_source_rejects_before_runtime(monkeypatch): + monkeypatch.setattr(effect_runtime, "effect_runtime_result", lambda *args, **kwargs: pytest.fail("must not dispatch")) + with pytest.raises(effect_runtime.EffectRuntimeRejected, match="16 MiB"): + source_effect_runtime_result("coordination.source.project", {"note": "x" * MAX_TRANSFER_BYTES}) diff --git a/tests/control_plane_ts/source_transfer.test.ts b/tests/control_plane_ts/source_transfer.test.ts new file mode 100644 index 0000000000..f3ef8d95dc --- /dev/null +++ b/tests/control_plane_ts/source_transfer.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {createHash} from "node:crypto"; +import {mkdtemp, realpath, rm, writeFile, readFile, symlink, chmod} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {withCoordinationSourceTransfer, SOURCE_TRANSFER_SCHEMA, SOURCE_TRANSFER_RESULT_SCHEMA, + MAX_SOURCE_TRANSFER_BYTES} from "../../loopx/control_plane/coordination/source_transfer.ts"; +import {projectCoordinationSource, SOURCE_PROJECTION_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/source_projection.ts"; + +const method = "coordination.source.project"; +const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +async function transfer(t: test.TestContext, value: unknown) { + const directory = await realpath(await mkdtemp(join(tmpdir(), "loopx-source-test-"))); + t.after(() => rm(directory, {recursive: true, force: true})); + const bytes = Buffer.from(JSON.stringify(value)); + await writeFile(join(directory, "request.json"), bytes); + return {schema_version: SOURCE_TRANSFER_SCHEMA, method, directory, + request_sha256: hash(bytes), request_bytes: bytes.length}; +} +const source = {schema_version: SOURCE_PROJECTION_REQUEST_SCHEMA, kind: "snapshot", goal_id: "goal", + handoff_mode: "soft_claim", read_model_schema: "loopx_todo_canonical_read_record_v0", leases: [], + todos: [{schema_version: "todo_item_v0", todo_id: "a", role: "agent", text: "Retained work", status: "open", done: false, + archive_state: "active", source_section: "Agent Todo"}]}; + +test("complete source and result exceed RPC size without losing records or bypassing the typed owner", async t => { + const todos = Array.from({length: 160}, (_, i) => ({...source.todos[0], todo_id: `t${i.toString().padStart(3, "0")}`, + note: "完整🙂".repeat(1600)})); + const request = {...source, todos}; + const envelope = await transfer(t, request); + assert.ok(envelope.request_bytes > 2 * 1024 * 1024); + const result = await withCoordinationSourceTransfer(method, projectCoordinationSource)(envelope) as Record; + assert.equal(result.schema_version, SOURCE_TRANSFER_RESULT_SCHEMA); + assert.equal(result.request_sha256, envelope.request_sha256); + assert.ok(Buffer.byteLength(JSON.stringify(result)) < 512); + const bytes = await readFile(join(envelope.directory, "result.json")); + assert.equal(bytes.length, result.result_bytes); + assert.equal(hash(bytes), result.result_sha256); + const projection = JSON.parse(bytes.toString()).projection; + assert.deepEqual(projection.todos, todos); + assert.equal(projection.todo_read_model.todo_count, 160); + assert.deepEqual(projection.leases, []); +}); + +test("inline protocol remains usable and semantic errors are not converted to empty success", async t => { + const handler = withCoordinationSourceTransfer(method, projectCoordinationSource); + assert.deepEqual(await handler(source), projectCoordinationSource(source)); + await assert.rejects(handler(await transfer(t, {...source, todos: [source.todos[0], source.todos[0]]})), /duplicate todos/); +}); + +for (const change of ["digest", "method", "size", "extra", "oversize", "replaced", "output"] as const) { + test(`reject ${change} before running business code`, async t => { + const envelope: Record = await transfer(t, source); + const directory = String(envelope.directory); + if (change === "digest") envelope.request_sha256 = "0".repeat(64); + if (change === "method") envelope.method = "coordination.runtime_shadow.bootstrap"; + if (change === "size") envelope.request_bytes = Number(envelope.request_bytes) - 1; + if (change === "oversize") envelope.request_bytes = MAX_SOURCE_TRANSFER_BYTES + 1; + if (change === "extra") envelope.output_path = join(directory, "unexpected.json"); + if (change === "replaced") await writeFile(join(directory, "request.json"), JSON.stringify(source).replace("Retained", "Tampered")); + if (change === "output") await writeFile(join(directory, "result.json"), "do not overwrite"); + let called = false; + await assert.rejects(withCoordinationSourceTransfer(method, () => {called = true; return {}; })(envelope)); + assert.equal(called, false); + if (change === "output") assert.equal(await readFile(join(directory, "result.json"), "utf8"), "do not overwrite"); + }); +} + +test("request symlink and shared directory reject", {skip: process.platform === "win32"}, async t => { + const envelope = await transfer(t, source); + const path = join(envelope.directory, "request.json"); + const original = await readFile(path); + await writeFile(join(envelope.directory, "other.json"), original); + await rm(path); + await symlink(join(envelope.directory, "other.json"), path); + const handler = withCoordinationSourceTransfer(method, () => assert.fail("must not execute")); + await assert.rejects(handler(envelope), /regular file/); + await chmod(envelope.directory, 0o755); + await assert.rejects(handler(envelope), /private/); +}); + +test("artifact budget also bounds returned bytes", async t => { + const envelope = await transfer(t, {}); + await assert.rejects(withCoordinationSourceTransfer(method, + () => ({value: "x".repeat(MAX_SOURCE_TRANSFER_BYTES)}))(envelope), /exceeds 16 MiB/); + assert.equal((await readFile(join(envelope.directory, "result.json"))).length, 0); +}); diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index daa273ea5c..0e8c755579 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -7,8 +7,8 @@ import pytest -from loopx.cli_commands import turn as turn_command -from loopx.cli_commands.turn import ManagedCadenceStart, managed_cadence_start +from loopx.cli_commands import turn_cadence +from loopx.cli_commands.turn_cadence import ManagedCadenceStart, managed_cadence_start from loopx.control_plane.effect_runtime import effect_runtime_result from loopx.control_plane.turn_driver import executor as turn_executor from loopx.control_plane.turn_driver import ( @@ -1066,7 +1066,7 @@ def die_after_reservation(identity: Mapping[str, object]) -> dict[str, object]: assert calls == {"host": 0, "writeback": 0, "spend": 0, "scheduler": 0} started_at_ms = int(_cadence_starts(runtime_root)[0]["started_at_ms"]) - monkeypatch.setattr(turn_command, "time", _FrozenTurnClock(started_at_ms + 120_000)) + monkeypatch.setattr(turn_cadence, "time", _FrozenTurnClock(started_at_ms + 120_000)) restart = _managed_cadence(runtime_root) recovered = run_loopx_turn_once( plan, admit_start=restart.admit, confirm_start=restart.confirm, **common @@ -1111,7 +1111,7 @@ def die_before_attempt_record(path: Path, journal: dict[str, object]) -> None: assert calls == {"host": 0, "writeback": 0, "spend": 0, "scheduler": 0} started_at_ms = int(_cadence_starts(runtime_root)[0]["started_at_ms"]) - monkeypatch.setattr(turn_command, "time", _FrozenTurnClock(started_at_ms + 120_000)) + monkeypatch.setattr(turn_cadence, "time", _FrozenTurnClock(started_at_ms + 120_000)) restart = _managed_cadence(runtime_root) recovered = run_loopx_turn_once( plan, admit_start=restart.admit, confirm_start=restart.confirm, **common diff --git a/tests/test_windows_atomic_writes.py b/tests/test_windows_atomic_writes.py index 8816434be9..639d5fca50 100644 --- a/tests/test_windows_atomic_writes.py +++ b/tests/test_windows_atomic_writes.py @@ -10,7 +10,9 @@ import pytest from loopx.capabilities.decision_context import private_state +from loopx.capabilities.benchmark_toolkit import native_codex_isolation from loopx.control_plane.heartbeat import automation_upgrade +from loopx.control_plane.goals import botmux_runtime from loopx.extensions import presentation from loopx.extensions.lark import private_json @@ -22,7 +24,10 @@ def __init__(self, directory: Path) -> None: self._directory = directory def __getattr__(self, name: str) -> Any: - if name == "O_DIRECTORY": + # Native Windows Python has neither directory fsync nor fchmod; an + # attribute that exists only on POSIX must stay missing here, or the + # POSIX lane stops covering the Windows surface. + if name in {"O_DIRECTORY", "fchmod"}: raise AttributeError(name) return getattr(os, name) @@ -48,6 +53,14 @@ def _write_heartbeat_automation(path: Path) -> None: automation_upgrade._atomic(path, 'prompt = "ready"\n') +def _write_botmux_binding(path: Path) -> None: + botmux_runtime._write_private_json_atomic(path, {"status": "ready"}) + + +def _write_native_isolation(path: Path) -> None: + native_codex_isolation._atomic_write_text(path, "prompt = \"ready\"\n") + + @pytest.mark.parametrize( ("module", "writer", "expected"), [ @@ -75,6 +88,18 @@ def _write_heartbeat_automation(path: Path) -> None: 'prompt = "ready"\n', id="heartbeat-automation", ), + pytest.param( + botmux_runtime, + _write_botmux_binding, + {"status": "ready"}, + id="botmux-binding", + ), + pytest.param( + native_codex_isolation, + _write_native_isolation, + 'prompt = "ready"\n', + id="native-isolation", + ), ], ) def test_atomic_writers_skip_unsupported_windows_directory_fsync(