diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index c7c151b8c4..caf378fa70 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -2567,6 +2567,96 @@ R0/V10/R1, D2/D5/D6/D3, context/model/split-install, PostgreSQL host/wheel and full-cost/adoption requirements remain open. No main merge or host upgrade is included. +### 2026-11-08 — Feedback validation without mutation-adapter loading + +Initial runtime `2c5212fdb15035c26c3e158420d67197d45715f2` makes the two existing +retained-state mutation adapters in `loopx.feedback` load on demand. Pure +feedback validation now works when those adapters are unavailable; the parent +fails at module import. Reward mutation resolves both adapters before registry +reads or writes, while the original guard and writer call sites still look up +the current module binding. Adapter object identity, mid-call patch behavior, +index-before-state lock ordering, TypeScript guard ownership, credential refusal +and partial-write handling remain. A local compatibility map, module `__getattr__` +and `__dir__`, and a computed `__all__` preserve all 50 original star-export +names, including the later append/render functions. There is no new capability, +provider, persisted vocabulary, decision owner or cross-request authority cache. + +The two relevant groups pass 291 feedback-policy and 48 adapter/write/recovery +cases. The independent unavailable-adapter tests fail on the parent before the +change; head preserves pure validation and rejects a missing writer before +reward mutation. Focused mypy and Ruff pass. The production semantic advisory +finds no supported new carriers; explicit test-file inclusion is outside that +scanner's supported `loopx/` scope. Test refusal strings remain local fixtures. + +The rebuilt monolithic wheel first passes isolated installed-module ownership, +then matches all 1,840 packaged tracked sources outside the checkout with +`PYTHONPATH` cleared. The five source-only omissions remain; relative to the +frozen runtime candidate, only `effect_runtime.py` and `feedback.py` bytes change. +The existing dashboard Chat build supplies the required bundle after the first +wheel preparation failed for its absence. Four new candidate source/wheel × +File/SQLite 101-Todo CLI lifecycles use default bytecode and pass the original +artifact, completion-time, scope, four-receipt, one-debit, restart, refusal and +replay oracles. Both managed native daemons exit in every case. These are new +runtime correctness checks, not additional matched cost samples. + +A normal-cache profile of an already settled synthetic recovery dry-run is +used only to inspect loading dependencies. Its 410-module closure and nested +import/compile timings cannot establish default-provider performance, fresh +authority behavior or lifecycle cost. The initial stdlib-shadowing profile +filename failed before CLI execution and remains a preparation failure. No +frozen sample or preceding attribution pair is repeated, expanded or relabeled. +No new whole-path benefit, PostgreSQL host/wheel, model/attention or installed +adoption qualification is claimed. Preserve the peer hard-cut hold and all +earlier failures, I3 for at least one minor release, M1/#8011, full R0/V10/R1, +D2/D5/D6/D3 and context/model/split-install/full-cost/adoption acceptance. +The original task and independent exact-head review/readiness remain open; +the next loading decision stays with the existing quota/context/refresh callers. +No main merge or installed-host upgrade is included. + +During submission preparation, a maintainer merges #8302 at +`481436a56b47220c7c43bd6e272c1fa59d5038fe`; that head excludes this feedback +change. The two unsubmitted feedback/checkpoint commits move to a clean successor +based on target main `0ed909880`, retaining identical feedback and focused-test +bytes. The successor wheel matches 1,841 packaged tracked sources, with the same +five omissions and installed ownership checked first. Its fresh source/wheel × +File/SQLite lifecycles pass all four original oracle sets and both native exits +per case. On this changed integration baseline, 48 write/recovery and 265 shared +feedback-policy cases, mypy and Ruff pass. These checks qualify the successor's +changed baseline; the initial runs remain historical evidence. No performance +sample is repeated or added. The successor still requires independent exact-head +review/readiness; the original task's complete acceptance remains open. + +### 2026-11-08 — Declaration codecs preserve selected-owner loading + +The inherited TypeScript CI failures reproduce on target main `94366314f0`. +Cold handler loading reaches lease execution through the interaction/acceptance +and outcome owners, because work declarations import `normalizeWriteScopes` +from the full acquire runtime. Runtime `b0c42af8f` moves the existing lexical +scope/value functions to `work_items/write_scope_codec.ts` and keeps their old +exports. Coercion, raw request spellings, character classes, the 160-character +limit and rejection behavior remain. Lease IO, admission, repository/workspace +identity and source/revision/WAL/receipt fences stay with their existing owners. +The digest guard also registers the existing quota action-wildcard matcher; +its visible 64-hex rejection is unchanged. + +Both failing CI cases now pass; 19 loading/digest cases pass on Node 22.22.3. +The related Node 26 groups pass 60 and 70 cases; the first group's missing-PG +skip is subsequently exercised on an isolated PostgreSQL 16.15 server. Its +store/workspace groups pass 391/32 cases and the separate service suite passes +11 with no skips. Initial missing service environment and reused-incarnation +failures remain recorded; the corrected service uses its own fresh database. +Sixteen old/head scope-value/refusal comparisons and original export identities +pass, as does TypeScript typing. Four new source/wheel × File/SQLite 101-Todo +CLI lifecycles preserve the original artifact, completion time, scopes, four +receipts, one debit, restart/refusal/replay and both native exits. The wheel +matches 1,842 packaged tracked sources after installed-root verification; the +same five source-only omissions remain. These qualify this runtime's correctness, +not complete cost or installed benefit. All earlier frozen failures, peer +hard-cut evidence, I3/minor-release, M1/#8011, full R0/V10/R1, D2/D5/D6/D3 and +context/model/split-install/PostgreSQL-host/full-cost/adoption gaps remain open. +The original task and independent review/readiness remain open; no main merge +or installed-host upgrade is included. + ## Appendix B: Decision log | Date | Decision | Owner / approval | Alternatives | Normative sections changed | @@ -2652,3 +2742,41 @@ inside marked Todo region`. Thin Codex App output may expose only the error code read-only `loopx --format json quota status --goal-id --verbose` supplies the detailed symptom. Distinguish a shipped repair from installed-reader drift without weakening foreign-content rejection or rewriting active state. + +### 2026-11-08 — Combined loading proposal and nullable staged reads + +The declaration-codec proposal now integrates target main `efd49352c`, the +feedback-loading component from #8339 and the equivalent anchored-clock repair +already on main. Component approvals and checks remain evidence for their old +heads; the combined proposal still needs its own review and validation. + +A real precision-preserving `quota replan-view --replan-stage explore` read +fails when the staged view contains only strings, booleans and null. The +existing edit encoder compacts the whole root into an empty property path, +which its Python transport rejects. Restricting that compact replacement to +nested records preserves the property-path contract and existing numeric/source +lineage. The independent root-path oracle fails before the change and passes +afterward; the real read now returns the original context/hash and an absent +Explore basis. No decision owner, vocabulary, provider or authority is added. + +The integrated source and packaged wheel research-record checks retain the +original negative paragraph, two original history rows, Goal isolation and +exports; the packaged conversation scenario also passes. The earlier CI timeout +is not reproduced locally, so its platform/timing cause remains unknown rather +than being declared repaired. Four fresh source/wheel × File/SQLite 101-Todo +correctness episodes preserve the original fixture/artifact, scopes, completion +time, four receipts, one debit, refusal/restart/replay and two owned native exits. +An initial driver used `python` while the source console selected `python3`, +producing two distinct runtime fingerprints and failing the warmup/exit oracle. +That failure is retained; the subsequent cases use each console's actual +interpreter. They do not repeat or relabel any frozen cost sample. + +An isolated PostgreSQL 16 server passes 391 store and 11 service cases without +skips. The installed wheel matches 1,921 tracked package entries byte for byte; +17 tracked documentation/media/testing or source-only entries are omitted, and +general distribution coverage beyond the exercised callers is not inferred. +Whole-path cost benefit, installed adoption, PostgreSQL host/wheel and all +I3/minor-release, M1/#8011, full R0/V10/R1, D2/D5/D6/D3, +context/model/split-install and historical failure qualifications remain open. +The next useful boundary remains report subscription/construction loading after +the combined proposal's review; the original task stays open. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index fac481ef09..53a9782f9e 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -1993,6 +1993,80 @@ caller 的必需依赖闭包,再选一个完整可回退的加载改动并验 context/model/split-install、PostgreSQL host/wheel、full-cost/adoption 要求 继续开放;本轮不合入 main、不升级 host。 +### 2026-11-08 — 反馈校验不加载写入适配器 + +初始 runtime `2c5212fdb15035c26c3e158420d67197d45715f2` 将 `loopx.feedback` 中 +两个既有 retained-state 写入适配器改为按需加载。适配器不可用时,纯反馈校验 +仍可执行;parent 会在模块导入时失败。Reward mutation 在 registry 读取或 +写入前解析两个适配器,原 guard 与 writer 调用位置仍查找当前模块 binding。 +保留适配器对象 identity、调用中途 patch 行为、先 index 后 state 的锁顺序、 +TypeScript guard owner、凭证拒绝及 partial-write handling。本地兼容映射、 +模块 `__getattr__`/`__dir__` 与 computed `__all__` 保留全部 50 个原 star-export +名称,包括后面的 append/render 函数。不新增 capability、provider、持久词汇、 +规则 owner 或跨请求 authority cache。 + +两个相关组分别通过 291 个反馈策略及 48 个适配器/写入/恢复用例。独立的 +适配器不可用用例在 parent 上失败;head 保留纯校验,并在 reward mutation 前 +拒绝缺失 writer。聚焦 mypy/Ruff 通过。Production semantic advisory 未发现 +支持范围内的新 carrier;显式纳入测试文件超出该 scanner 支持的 `loopx/` 范围。 +测试中的 refusal 字符串仍是本地 fixture。 + +重建单体 wheel 先断言隔离安装的模块所有权,再在非 checkout 工作目录、清空 +`PYTHONPATH` 后核对全部 1840 个 packaged tracked source。相同五个 source-only +omission 保留;相对冻结 runtime candidate,仅 `effect_runtime.py` 和 +`feedback.py` 字节变化。首次 wheel 准备因缺少 Chat bundle 失败后,既有 dashboard +Chat build 提供所需 bundle。四条新的 candidate source/wheel × File/SQLite +101-Todo CLI 生命周期使用默认 bytecode,全部通过原 artifact、completion time、 +scope、四份 receipt、一次 debit、restart、refusal 与 replay oracle。每条的两个 +managed native daemon 均退出。这是新 runtime 正确性检查,不是新增 matched +cost 样本。 + +正常缓存 profile 仅检查已结算 synthetic recovery dry-run 的加载依赖。其 +410-module 闭包及嵌套 import/compile timing 不能证明默认 provider 性能、fresh +authority 行为或生命周期成本。初始 profile 文件名遮蔽 stdlib,在 CLI 前失败, +仍保留为准备失败。冻结样本及上一轮归因 pair 不重跑、不扩充、不重标签。 +不新增 whole-path 收益、PostgreSQL host/wheel、model/attention 或 installed +adoption 资格。保留 peer hard-cut hold、全部历史失败、至少一个 minor release +的 I3、M1/#8011、完整 R0/V10/R1、D2/D5/D6/D3 及 +context/model/split-install/full-cost/adoption 验收。原 Task 与独立 exact-head +评审/readiness 继续开放;下一段加载决策仍属于既有 quota/context/refresh +caller。本轮不合入 main,不升级 installed host。 + +提交准备期间,维护者在 `481436a56b47220c7c43bd6e272c1fa59d5038fe` 合入 +#8302,该 head 未包含本次反馈改动。两个尚未提交远端的反馈/checkpoint commit +移到基于目标 main `0ed909880` 的干净后续分支,反馈源码及聚焦测试字节相同。 +后续 wheel 先验证安装所有权,再匹配 1841 个 packaged tracked source,保留 +相同五个 omission。新的 source/wheel × File/SQLite 生命周期全部通过四组 +原 oracle,每条两个 native 均退出。在此变化的集成基线上,48 个写入/恢复及 +265 个共享反馈策略用例、mypy/Ruff 通过。这些检查验收后续提案的变化基线, +初始运行保留为历史证据,不重跑或新增性能样本。后续提案仍需独立 exact-head +评审/readiness,原 Task 的完整验收继续开放。 + +### 2026-11-08 — 声明 codec 保留按需加载 owner + +继承的 TypeScript CI 失败在目标 main `94366314f0` 复现。冷启动 handler 经由 +interaction/acceptance、outcome owner 加载 lease 执行,因为 work declaration +从完整 acquire runtime 导入 `normalizeWriteScopes`。Runtime `b0c42af8f` 将既有 +scope/value 词法函数移到 `work_items/write_scope_codec.ts`,保留原导出。 +Coercion、原请求拼写、字符类、160 字符限制及拒绝行为不变。Lease IO、admission、 +repository/workspace identity 和 source/revision/WAL/receipt 栅栏仍由原 +owner 持有。Digest guard 登记既有 quota action 通配 matcher,可见 64-hex +模式的拒绝保持。 + +两个失败 CI 用例通过;Node 22.22.3 的 19 个加载/digest 用例通过,Node 26 +相关组通过 60 及 70 个用例。首组缺 PG 的 skip 随后由隔离 PostgreSQL 16.15 +覆盖:store/workspace 组通过 391/32 个,独立 service suite 通过 11 个、零 +skip。缺少 service 环境变量及复用 incarnation 的初始失败保留,修正后的 service +使用自己的新数据库。16 个 old/head scope 值/拒绝比较及原导出 identity、 +TypeScript typing 通过。四条新的 source/wheel × File/SQLite 101-Todo CLI +生命周期保留原 artifact、completion time、scope、四份 receipt、一次 debit、 +restart/refusal/replay 及两个 native 退出。Wheel 先验证安装根,再匹配 1842 +个 packaged tracked source;相同五个 source-only omission 保留。这验收当前 +runtime 正确性,不验收完整成本或 installed benefit。全部历史冻结失败、peer +hard-cut 证据、I3/minor-release、M1/#8011、完整 R0/V10/R1、D2/D5/D6/D3 和 +context/model/split-install/PostgreSQL-host/full-cost/adoption 缺口保持开放。 +原 Task、独立评审/readiness 继续开放,本轮不合入 main、不升级 installed host。 + ## 附录 B:决策日志 | 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 | @@ -2058,3 +2132,33 @@ minor release。这些证据不关闭完整 R0/V10、M1/#8011、R1、context/mod 精简 Codex App 输出可能只有错误码;只读 `loopx --format json quota status --goal-id --verbose` 提供详细症状。 据此区分已交付修复和安装 reader 漂移;保留外来内容拒绝,不重写活跃状态。 + +### 2026-11-08 — 加载提案集成与可空 staged 读取 + +声明 codec 提案现已整合目标 main `efd49352c`、#8339 的反馈加载组件,以及 main +已有的等价固定时钟修复。组件的批准及检查仍只证明各自旧 head,组合提案需要自己的 +评审与验证。 + +真实的保留数值精度的 `quota replan-view --replan-stage explore` 读取,在 staged +视图仅含 string、boolean 和 null 时失败。既有 edit encoder 将整个 root 压缩为 +空 property path,Python transport 随后拒绝。将该紧凑替换限定到嵌套 record, +保留 property-path 契约及既有 number/source lineage。独立 root-path oracle 在 +修改前失败、修改后通过;真实读取恢复原 context/hash 和缺省 Explore basis。 +不新增规则 owner、词汇、provider 或 authority。 + +集成后的 source 与 packaged wheel 研究记录场景保留原负结果段落、两条原始历史、 +Goal 隔离及导出;packaged 对话场景也通过。此前 CI 超时在本地未复现,其平台/时序 +原因仍未知,不宣称已经修复。四条新的 source/wheel × File/SQLite 101-Todo +正确性流程保留原 fixture/artifact、scope、completion time、四份 receipt、一次 +debit、refusal/restart/replay 和两个自有 native 退出。初始 driver 使用 `python`, +source console 实际选择 `python3`,产生不同 runtime fingerprint,导致 warmup/ +exit oracle 失败。保留该失败;后续用例采用各 console 的实际解释器,不重跑或重标签 +任何冻结成本样本。 + +隔离 PostgreSQL 16 的 store/service 分别通过 391/11 个用例,零 skip。 +已安装 wheel 的 1921 个 tracked package entry 字节一致;17 个 tracked 文档/媒体/ +测试或 source-only entry 未打包,不据此推断已执行 caller 之外的通用发行覆盖。 +Whole-path 成本收益、installed adoption、PostgreSQL host/wheel,以及全部 +I3/minor-release、M1/#8011、完整 R0/V10/R1、D2/D5/D6/D3、 +context/model/split-install 与历史失败资格继续开放。组合提案评审之后,下一段 +有用边界仍是报告订阅/构造加载;原 Task 保持开放。 diff --git a/loopx/control_plane/quota/diagnostic_projection.ts b/loopx/control_plane/quota/diagnostic_projection.ts index 8e9545e301..45fabc294c 100644 --- a/loopx/control_plane/quota/diagnostic_projection.ts +++ b/loopx/control_plane/quota/diagnostic_projection.ts @@ -101,7 +101,8 @@ export function contextProjectionChanges(source: JsonObject, projected: JsonObje visit(Object.hasOwn(old, key) ? old[key] : undefined, value, [...path, key], after as object); // Small scalar records can cost less than separate removal paths. // Keep source-lineage and numeric edits on their existing exact codec. - if (changes.length - start > 1 && Object.values(after as JsonObject).every( + // The transport addresses properties; its consumer rejects root replacements. + if (path.length > 0 && changes.length - start > 1 && Object.values(after as JsonObject).every( value => value === null || typeof value === "string" || typeof value === "boolean")) { const edits = changes.slice(start), replacement = {path, value: after}; if (!edits.some(edit => "from" in edit) && diff --git a/loopx/control_plane/todos/work_requirements.ts b/loopx/control_plane/todos/work_requirements.ts index 39f57fa261..52a56fadd3 100644 --- a/loopx/control_plane/todos/work_requirements.ts +++ b/loopx/control_plane/todos/work_requirements.ts @@ -4,7 +4,7 @@ import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { optionalNonEmptyString, requireStringArray } from "../runtime_decode.ts"; import { compactPythonWhitespace, stripPythonWhitespace } from "../coordination/todo_agents.ts"; -import { normalizeWriteScopes } from "../work_items/task_lease_acquire.ts"; +import { normalizeWriteScopes } from "../work_items/write_scope_codec.ts"; import { isAbsolute, resolve, sep } from "node:path"; /** Goal scope projection preserves existing relative scopes. Absolute grants diff --git a/loopx/control_plane/work_items/task_lease_acquire.ts b/loopx/control_plane/work_items/task_lease_acquire.ts index 8a4e027258..07e83296d2 100644 --- a/loopx/control_plane/work_items/task_lease_acquire.ts +++ b/loopx/control_plane/work_items/task_lease_acquire.ts @@ -1,10 +1,11 @@ +import {canonicalWriteScope, compactScopeInput as compact, normalizeWriteScopes, writeScopeCandidates} from "./write_scope_codec.ts"; +export {normalizeWriteScopes} from "./write_scope_codec.ts"; import {HANDOFF_MODES, type HandoffMode} from "../coordination/handoff_mode_vocabulary.ts"; import {LeaseWorktreeObservationError, observeLeaseWorktree, type LeaseWorkspace} from "./task_lease_workspace.ts"; import {isCanonicalTodoId} from "../todos/identity.ts"; import {executeCanonicalTaskLeaseAcquire} from "../coordination/task_lease_acquire.ts"; import {withCanonicalTaskLeaseAuthority} from "./canonical_task_lease_lifecycle.ts"; import { - canonicalWriteScope, evaluateTaskLeaseWriteScopesOverlap, } from "./task_lease_acquire_decision.ts"; import { ShadowManagementError } from "../coordination/shadow_management.ts"; @@ -158,29 +159,6 @@ function optionalInteger(value: unknown, label: string): number | null { return value; } -function compactString(value: unknown): string { - if (value === null || value === undefined) return ""; - switch (typeof value) { - case "string": - return value; - case "number": - case "bigint": - case "symbol": - return value.toString(); - case "boolean": - return value ? "true" : "false"; - case "function": - return Function.prototype.toString.call(value); - case "object": - return Array.isArray(value) ? value.join(",") : Object.prototype.toString.call(value); - } - return ""; -} - -function compact(value: unknown): string { - return compactString(value).trim().split(/\s+/u).filter(Boolean).join(" "); -} - export function normalizeAgent(value: unknown): string | null { const candidate = compact(value).toLowerCase().replaceAll(" ", "-"); return /^[a-z][a-z0-9_.:@-]{0,79}$/u.test(candidate) ? candidate : null; @@ -233,37 +211,6 @@ export function normalizeIdempotencyKey(value: unknown): string { return key; } -function writeScopeCandidates(value: unknown): unknown[] { - if (value === null || value === undefined) return []; - let raw: unknown[]; - if (Array.isArray(value)) { - raw = value; - } else if (typeof value === "string") { - raw = value.split(/[,;|]/u); - } else { - raw = [value]; - } - return raw; -} - -/** Keep request spellings stable for immutable receipts and declared work. */ -export function normalizeWriteScopes(value: unknown): string[] { - const raw = writeScopeCandidates(value); - const scopes: string[] = []; - const seen = new Set(); - for (const item of raw) { - const scope = compact(item); - if ( - canonicalWriteScope(scope) === null || seen.has(scope) - ) { - continue; - } - seen.add(scope); - scopes.push(scope); - } - return scopes; -} - export function requireWriteScopes(value: unknown): string[] { const raw = writeScopeCandidates(value); if (raw.some(item => canonicalWriteScope(compact(item)) === null)) { diff --git a/loopx/control_plane/work_items/task_lease_acquire_decision.ts b/loopx/control_plane/work_items/task_lease_acquire_decision.ts index f2e5c7154f..1c61171cf1 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -1,3 +1,5 @@ +import {canonicalWriteScope, characterClassEnd} from "./write_scope_codec.ts"; +export {canonicalWriteScope, TODO_WRITE_SCOPE_MAX_CHARS} from "./write_scope_codec.ts"; import {HANDOFF_MODES} from "../coordination/handoff_mode_vocabulary.ts"; import {leaseWorkspace, sameLeaseWorkspace, sameLeaseCheckout, type LeaseWorkspace} from "./task_lease_workspace.ts"; /** Shared acquire/reclaim admission. IO and durable receipts belong to callers. */ @@ -10,7 +12,6 @@ import type {TodoFact, LeaseRecord} from "./task_lease_acquire.ts"; import {normalizeTodoRepository} from "../todos/work_requirements.ts"; import {leaseWriteRepository, leaseRepositoryRejection, repositoryScopesMayOverlap} from "./task_lease_repository.ts"; -export const TODO_WRITE_SCOPE_MAX_CHARS = 160; export interface AcquireDecisionLease { present: boolean; @@ -68,52 +69,6 @@ function stringValue(value: unknown, label: string): string { return value; } -function characterClassEnd(pattern: string, start: number): number { - let end = start + 1; - if (pattern[end] === "!") end += 1; - if (pattern[end] === "]") end += 1; - return pattern.indexOf("]", end); -} - -/** - * Canonicalize one repository-relative scope without resolving it against a - * host filesystem. Glob character classes retain their own slash semantics. - */ -export function canonicalWriteScope(value: string): string | null { - const scope = value.trim(); - if ( - scope.length === 0 || scope.length > TODO_WRITE_SCOPE_MAX_CHARS || - scope.startsWith("/") || scope.startsWith("~") || scope.includes("\\") || - /[\s<>]/u.test(scope) - ) { - return null; - } - const parts: string[] = []; - let start = 0; - for (let index = 0; index < scope.length; index += 1) { - if (scope[index] === "[") { - const closing = characterClassEnd(scope, index); - if (closing >= 0) { - index = closing; - continue; - } - } - if (scope[index] !== "/") continue; - parts.push(scope.slice(start, index)); - start = index + 1; - } - parts.push(scope.slice(start)); - const trailingSlash = scope.endsWith("/"); - const canonical: string[] = []; - for (const part of parts) { - if (part.length === 0 || part === ".") continue; - if (part === "..") return null; - canonical.push(part); - } - if (canonical.length === 0) return trailingSlash ? "./" : null; - return `${canonical.join("/")}${trailingSlash ? "/" : ""}`; -} - /** * Build a conflict-comparison view for persisted scopes without rewriting the * retained record. An unreadable legacy scope blocks new work conservatively. diff --git a/loopx/control_plane/work_items/write_scope_codec.ts b/loopx/control_plane/work_items/write_scope_codec.ts new file mode 100644 index 0000000000..2475ef72ce --- /dev/null +++ b/loopx/control_plane/work_items/write_scope_codec.ts @@ -0,0 +1,102 @@ +/** Lexical request/scope codecs. No lease IO, admission or authority reads. */ +export const TODO_WRITE_SCOPE_MAX_CHARS = 160; + +function compactString(value: unknown): string { + if (value === null || value === undefined) return ""; + switch (typeof value) { + case "string": + return value; + case "number": + case "bigint": + case "symbol": + return value.toString(); + case "boolean": + return value ? "true" : "false"; + case "function": + return Function.prototype.toString.call(value); + case "object": + return Array.isArray(value) ? value.join(",") : Object.prototype.toString.call(value); + } + return ""; +} + +export function compactScopeInput(value: unknown): string { + return compactString(value).trim().split(/\s+/u).filter(Boolean).join(" "); +} + +export function characterClassEnd(pattern: string, start: number): number { + let end = start + 1; + if (pattern[end] === "!") end += 1; + if (pattern[end] === "]") end += 1; + return pattern.indexOf("]", end); +} + +/** + * Canonicalize one repository-relative scope without resolving it against a + * host filesystem. Glob character classes retain their own slash semantics. + */ +export function canonicalWriteScope(value: string): string | null { + const scope = value.trim(); + if ( + scope.length === 0 || scope.length > TODO_WRITE_SCOPE_MAX_CHARS || + scope.startsWith("/") || scope.startsWith("~") || scope.includes("\\") || + /[\s<>]/u.test(scope) + ) { + return null; + } + const parts: string[] = []; + let start = 0; + for (let index = 0; index < scope.length; index += 1) { + if (scope[index] === "[") { + const closing = characterClassEnd(scope, index); + if (closing >= 0) { + index = closing; + continue; + } + } + if (scope[index] !== "/") continue; + parts.push(scope.slice(start, index)); + start = index + 1; + } + parts.push(scope.slice(start)); + const trailingSlash = scope.endsWith("/"); + const canonical: string[] = []; + for (const part of parts) { + if (part.length === 0 || part === ".") continue; + if (part === "..") return null; + canonical.push(part); + } + if (canonical.length === 0) return trailingSlash ? "./" : null; + return `${canonical.join("/")}${trailingSlash ? "/" : ""}`; +} + +export function writeScopeCandidates(value: unknown): unknown[] { + if (value === null || value === undefined) return []; + let raw: unknown[]; + if (Array.isArray(value)) { + raw = value; + } else if (typeof value === "string") { + raw = value.split(/[,;|]/u); + } else { + raw = [value]; + } + return raw; +} + +/** Keep request spellings stable for immutable receipts and declared work. */ +export function normalizeWriteScopes(value: unknown): string[] { + const raw = writeScopeCandidates(value); + const scopes: string[] = []; + const seen = new Set(); + for (const item of raw) { + const scope = compactScopeInput(item); + if ( + canonicalWriteScope(scope) === null || seen.has(scope) + ) { + continue; + } + seen.add(scope); + scopes.push(scope); + } + return scopes; +} diff --git a/loopx/feedback.py b/loopx/feedback.py index fc78946de0..1b44e1339b 100644 --- a/loopx/feedback.py +++ b/loopx/feedback.py @@ -3,10 +3,9 @@ from typing import Any, cast from contextlib import nullcontext +from importlib import import_module as _import_module from .file_lock import exclusive_cross_runtime_file_lock -from .control_plane.coordination.legacy_writer_fence import require_prose_state_write_allowed -from .control_plane.runtime.document_io import atomic_write_state_text import re from datetime import datetime, timezone @@ -24,6 +23,30 @@ from .control_plane.actor_identity import normalize_owner_controller_actor +_MUTATION_ADAPTERS = { + "require_prose_state_write_allowed": "loopx.control_plane.coordination.legacy_writer_fence", + "atomic_write_state_text": "loopx.control_plane.runtime.document_io", +} + + +def __getattr__(name: str) -> Any: + owner = _MUTATION_ADAPTERS.get(name) + if owner is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_import_module(owner), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_MUTATION_ADAPTERS)) + + +def _mutation_adapter(name: str) -> Any: + # Preserve the released module-level patch points and original objects. + return globals()[name] if name in globals() else __getattr__(name) + + GOAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") @@ -413,6 +436,10 @@ def append_human_reward( state_file_override: Path | None = None, write_active_state_summary: bool = False, ) -> dict[str, Any]: + # Resolve before registry reads or any append. An unavailable adapter must + # fail before the reward index can commit; TS still owns the write check. + _mutation_adapter("require_prose_state_write_allowed") + _mutation_adapter("atomic_write_state_text") actor = normalize_owner_controller_actor(actor_kind, required=not dry_run) validate_goal_id(goal_id) registry = load_registry(registry_path) @@ -486,7 +513,7 @@ def append_human_reward( original, str(active_state_update["entry"]), updated_at=str(reward.get("recorded_at") or now_local()), ) - require_prose_state_write_allowed( + _mutation_adapter("require_prose_state_write_allowed")( registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, state_path=state_file_to_write, original_text=original, planned_text=planned, ) @@ -495,7 +522,7 @@ def append_human_reward( append_run_index_record(index_path, index_record) if state_file_to_write and state_text_to_write is not None: try: - atomic_write_state_text(state_file_to_write, state_text_to_write) + _mutation_adapter("atomic_write_state_text")(state_file_to_write, state_text_to_write) except OSError as error: raise HumanRewardSummaryWriteError(str(error)) from error active_state_update["written"] = True @@ -622,3 +649,7 @@ def render_reward_markdown(payload: dict[str, Any]) -> str: ] ) return "\n".join(lines) + + +# Keep the historical star-import surface, including the deferred adapters. +__all__ = [name for name in globals() if not name.startswith("_")] + list(_MUTATION_ADAPTERS) diff --git a/tests/control_plane/test_feedback_adapter_loading.py b/tests/control_plane/test_feedback_adapter_loading.py new file mode 100644 index 0000000000..29f3652148 --- /dev/null +++ b/tests/control_plane/test_feedback_adapter_loading.py @@ -0,0 +1,90 @@ +"""Pure feedback callers do not require retained-state mutation adapters.""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def _isolated(code: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", code], cwd=ROOT, + capture_output=True, text=True, check=False, + ) + + +def test_text_validation_remains_available_without_mutation_adapters() -> None: + result = _isolated(''' +import importlib.abc +import sys + +class UnavailableWriters(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname in { + 'loopx.control_plane.coordination.legacy_writer_fence', + 'loopx.control_plane.runtime.document_io', + }: + raise ImportError('retained-state mutation adapters unavailable') + +sys.meta_path.insert(0, UnavailableWriters()) +from loopx.feedback import validate_local_control_text, validate_public_safe_text +validate_local_control_text('next_action', 'Read the current task and continue.') +validate_public_safe_text('summary', 'The artifact passed independent validation.') +try: + validate_local_control_text('next_action', 'Bearer ' + 'x' * 32) +except ValueError: + pass +else: + raise AssertionError('the existing credential refusal must remain') +''') + assert result.returncode == 0, result.stderr + + +def test_feedback_adapter_exports_retain_owner_identity_and_star_import() -> None: + result = _isolated(''' +from loopx import feedback +from loopx.control_plane.coordination import legacy_writer_fence +from loopx.control_plane.runtime import document_io + +namespace = {} +exec('from loopx.feedback import *', namespace) +assert namespace['append_human_reward'] is feedback.append_human_reward +assert namespace['render_reward_markdown'] is feedback.render_reward_markdown +assert namespace['require_prose_state_write_allowed'] is legacy_writer_fence.require_prose_state_write_allowed +assert namespace['atomic_write_state_text'] is document_io.atomic_write_state_text +assert feedback.require_prose_state_write_allowed is namespace['require_prose_state_write_allowed'] +assert feedback.atomic_write_state_text is namespace['atomic_write_state_text'] +assert 'require_prose_state_write_allowed' in dir(feedback) +assert 'atomic_write_state_text' in dir(feedback) +''') + assert result.returncode == 0, result.stderr + + +def test_missing_writer_dependency_fails_before_reward_mutation() -> None: + result = _isolated(''' +import importlib.abc +import sys +from pathlib import Path + +class UnavailableWriters(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname == 'loopx.control_plane.runtime.document_io': + raise ImportError('mutation writer unavailable') + +sys.meta_path.insert(0, UnavailableWriters()) +from loopx.feedback import append_human_reward +try: + append_human_reward( + registry_path=Path('registry-does-not-exist.json'), + runtime_root_override=None, goal_id='synthetic-feedback', + run_generated_at=None, reward={}, actor_kind='owner', + ) +except ImportError as error: + assert str(error) == 'mutation writer unavailable' +else: + raise AssertionError('an unavailable writer must refuse before opening the registry') +''') + assert result.returncode == 0, result.stderr diff --git a/tests/control_plane/test_replan_progress_native_cli.py b/tests/control_plane/test_replan_progress_native_cli.py index 03dd4f6412..94489b8e63 100644 --- a/tests/control_plane/test_replan_progress_native_cli.py +++ b/tests/control_plane/test_replan_progress_native_cli.py @@ -266,6 +266,12 @@ def review_for(body): assert writer["writeback"]["settlement_plan"]["identity"] == receipt["settlement_identity"] assert writer["required_outcome"] == source["replan_action_packet"]["required_outcome"] + explore_command = review_command.copy() + explore_command[explore_command.index("--replan-stage") + 1] = "explore" + explore = read_stage(explore_command) + assert explore["explore_basis"] is None + assert explore["source_hash"] == review["source_hash"] + recovered_path = original.with_name("recovered.json") original.rename(recovered_path) saved = ("quota", "context-project", *scope, "--capture-file", str(recovered_path), "--initial-read") diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index e46f3ac106..e9ce2b19c0 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -86,6 +86,10 @@ const DECLARED_UNFOLDABLE: Record = { count: 2, reason: "legacy patterns are carried by the contract record, not by this module", }, + "control_plane/todos/quota_selection.ts": { + count: 1, + reason: "anchored wildcard matcher from a validated quota action pattern, not a digest matcher", + }, "control_plane/todos/read_codec.ts": { count: 2, reason: "Todo reference boundaries and legacy task classification, not digest matchers", diff --git a/tests/control_plane_ts/context_projection_root.test.ts b/tests/control_plane_ts/context_projection_root.test.ts new file mode 100644 index 0000000000..ae02d73900 --- /dev/null +++ b/tests/control_plane_ts/context_projection_root.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {contextProjectionChanges} from "../../loopx/control_plane/quota/diagnostic_projection.ts"; + +test("a scalar-only staged view keeps the transport's property-path contract", () => { + const source = {quota: {remaining: 1}, coverage_count: 42}; + const view = {ok: true, goal_id: "synthetic", agent_id: "peer", stage: "explore", + source_hash: `sha256:${"a".repeat(64)}`, explore_basis: null}; + const {changes} = contextProjectionChanges(source, view, undefined, {}) as { + changes: {path: string[]; value: unknown}[]; + }; + const restored: Record = {}; + for (const change of changes) { + assert.ok(change.path.length > 0, "the consumer addresses properties, not a root replacement"); + assert.equal(change.path.length, 1); + restored[change.path[0]] = change.value; + } + assert.deepEqual(restored, view); + assert.deepEqual(source, {quota: {remaining: 1}, coverage_count: 42}); +});