diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 933f241741..662623992a 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -657,6 +657,7 @@ export async function readTodoRequest(goalId: string, todoId: string, signal: Ab const result = z.object({ ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(), status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(), + evidence: z.string().nullable().optional(), note: z.string().nullable().optional(), review_basis: todoUpdateSourceBasisSchema.optional(), source_access: z.object({namespace: z.string()}).optional(), }).parse(await requestJson(url, {signal})); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 06ba3826b1..141179183a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -45,7 +45,7 @@ import type { LarkGoalConnection } from "../../data/chat"; import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n"; import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue, hasGoalUsage } from "./personal-workspace-model"; import { TeamPlanResult } from "./team-plan-result"; -import { TaskRequest } from "./task-request"; +import { TaskRequest, taskRequestIdentity, type TaskRequestRead } from "./task-request"; import { parseTodoResumeCondition } from "./todo-resume-condition"; import { MarkdownText } from "./markdown"; import { formatMonitorDate } from "./monitor-readback"; @@ -145,6 +145,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const { locale, t } = useWorkspaceI18n(); const [correction, setCorrection] = useState(""); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const [taskRequestRead, setTaskRequestRead] = useState(null); const [repositoryCopyState, setRepositoryCopyState] = useState<"idle" | "copied" | "error">("idle"); const [runDrawerTab, setRunDrawerTab] = useState<"record" | "details">("record"); const [runActions, setRunActions] = useState>({}); @@ -355,6 +356,15 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention : selection.item.goalId; const taskNamespace = goals.find(goal => goal.goalId === goalId)?.sourceAccess?.namespace; const taskReadable = !readOnly || Boolean(taskNamespace); + const taskDetail = selection.kind === "todo" && taskRequestRead?.phase === "ready" + && taskRequestRead.identity === taskRequestIdentity(selection.item, taskReadable, taskNamespace) + ? taskRequestRead.detail : null; + // Missing fields retain compatibility with older/local request-only readers; + // an explicit null from the current source clears an outdated display hint. + const taskEvidence = taskDetail && "evidence" in taskDetail ? taskDetail.evidence + : selection.kind === "todo" ? selection.item.evidence : undefined; + const taskNote = taskDetail && "note" in taskDetail ? taskDetail.note + : selection.kind === "todo" ? selection.item.note : undefined; const contextLabel = selection.kind === "attention" ? selection.item.goalTitle ?? t("drawer.currentGoal") : selection.kind === "todo" ? selection.item.goalTitle : selection.kind === "run" ? selection.item.goalTitle @@ -684,7 +694,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention ) : selection.item.detailMode ? (

{t(selection.item.detailMode === "missing_current" ? "drawer.taskReadbackUnavailable" : "drawer.taskRequestOnly")}

- +
) : ( <> @@ -697,6 +707,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")} {selection.item.status === "blocked" ?

{t("drawer.blockedReason")}

@@ -788,13 +799,13 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {t("drawer.taskCompletionProofHint")} } : null} - {selection.item.evidence ?
+ {taskEvidence ?

{t("drawer.evidence")}

- +
: null} - {selection.item.note ?
+ {taskNote ?
{t("drawer.notes")} - +
: null} {todoCompleted ?
{t("drawer.taskCompletedTitle")}{t("drawer.taskCompletedNote")}
: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx index 6205c7fb0a..a99525808f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx @@ -1,4 +1,4 @@ -import {useEffect, useId, useRef, useState} from "react"; +import {useCallback, useEffect, useId, useRef, useState} from "react"; import {compactPythonWhitespace, compactPythonTextLength} from "../../../../../../loopx/control_plane/coordination/python_whitespace.ts"; import {readTodoRequest} from "../../data/chat"; import type {TodoUpdateSourceBasis} from "../../data/action-source-basis"; @@ -7,18 +7,25 @@ import {MarkdownText} from "./markdown"; import type {PersonalWorkspaceCallbacks, WorkspaceActionPreview, WorkspaceTodo} from "./personal-workspace-model"; // Per-open read/draft state, never a second Task or execution owner. -type RequestRead = {identity: string} & ( +export type TaskRequestRead = {identity: string} & ( | {phase: "loading" | "error"; detail?: never} | {phase: "ready"; detail: Awaited>} ); -export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string; +export function taskRequestIdentity(todo: WorkspaceTodo, local: boolean, sourceNamespace?: string) { + return JSON.stringify([local, sourceNamespace, todo.goalId, todo.sourceTodoId, todo.requestText, + todo.status, todo.evidence, todo.note]); +} + +export function TaskRequest({todo, local, sourceNamespace, onSave, onRead}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string; + onRead?: (read: TaskRequestRead) => void; onSave?: PersonalWorkspaceCallbacks["onSaveTodoRequirements"]}) { const {locale, t} = useWorkspaceI18n(); const countId = useId(); const sourceId = todo.sourceTodoId; - const identity = JSON.stringify([local, sourceNamespace, todo.goalId, sourceId, todo.requestText, todo.status]); - const [read, setRead] = useState(null); + const identity = taskRequestIdentity(todo, local, sourceNamespace); + const [read, setRead] = useState(null); + const publishRead = useCallback((value: TaskRequestRead) => {setRead(value); onRead?.(value);}, [onRead]); const [retry, setRetry] = useState(0); const [draft, setDraft] = useState<{text: string; basis: TodoUpdateSourceBasis} | null>(null); const [pending, setPending] = useState(null); @@ -32,10 +39,10 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works useEffect(() => { if (!local || !sourceId) return; const controller = new AbortController(); - setRead({identity, phase: "loading"}); + publishRead({identity, phase: "loading"}); void readTodoRequest(todo.goalId, sourceId, controller.signal, sourceNamespace).then(detail => { if (controller.signal.aborted) return; - setRead({identity, phase: "ready", detail}); + publishRead({identity, phase: "ready", detail}); if (rebaseRef.current && detail.review_basis) { const basis = detail.review_basis; setDraft(previous => previous ? {...previous, basis} : null); @@ -44,10 +51,10 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works } rebaseRef.current = false; }).catch(() => { - if (!controller.signal.aborted) setRead({identity, phase: "error"}); + if (!controller.signal.aborted) publishRead({identity, phase: "error"}); }); return () => controller.abort(); - }, [identity, local, retry, todo.goalId, sourceId, sourceNamespace]); + }, [identity, local, retry, todo.goalId, sourceId, sourceNamespace, publishRead]); const current = local && read?.identity === identity ? read : null; const detail = current?.phase === "ready" ? current.detail : null; const editable = local && onSave && todo.claimedBy && sourceId && detail?.review_basis @@ -68,7 +75,7 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works } const observed = await readTodoRequest(todo.goalId, sourceId, new AbortController().signal, sourceNamespace); if (!mounted.current) return; - setRead({identity, phase: "ready", detail: observed}); + publishRead({identity, phase: "ready", detail: observed}); if (observed.text !== text) throw new Error(t("drawer.requirementsReadbackChanged")); setDraft(null); setPending(null); setSaved(true); } catch (error) { diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index c7c151b8c4..b920675b13 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -2567,6 +2567,188 @@ 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. + +### 2026-11-08 — Report construction loading at Turn composition + +The existing `periodic-report` Python provider and CLI Turn adapter now resolve +report source, preset, stage projection and request exports on demand. At runtime +revision `7858d96e83ef98174cc43870e283f8f0e6f40c0b`, disabled subscriptions do +not import those construction providers. The capability package already had lazy +exports; this change fixes its concrete composition callers. Subscription +validation remains with the same owner, including its cadence-to-profile schedule +validator. Goal overrides, live machine defaults, configuration warnings, original +export identities and patch points, hook profile digest/policy version, source +selection, receipt replay and authority are retained. It adds no capability, +configuration, UI, persisted vocabulary or decision owner. + +Against baseline `c263f7c78353fcaa914f23475d4d99d07eab7c01`, eleven identical +synthetic configuration cases preserve hook output, trigger decisions and complete +configuration error messages. The cold-process refusal oracle fails before the +change when disabled composition imports the report source. Eight focused cases +now cover absence, disabled inheritance, overrides, invalid disabled configuration, +effect-free reads and export/patch compatibility. An initial stronger prohibition +also rejected the canonical profile schedule validator; that failed probe is +retained, and this slice preserves the validator rather than bypassing it. + +Source and separately built wheel File/default-SQLite console journeys pass the +original complete 101-Todo lifecycle: unchanged fixture/artifact, four receipts, +one debit, source/lease/refusal/restart/replay readback and two owned native exits +per journey. Installed package ownership was verified before comparing 1,920 +present tracked entries; seventeen source-only or documentation/media omissions +remain explicit. Wheel test-dependency and frontend-build preparation failures +remain recorded. Isolated PostgreSQL 16 store/service suites pass 391/11 cases +with zero skips; the initial service run's missing environment declaration remains +a skipped run. This does not qualify PostgreSQL host/wheel journeys. Report suites +pass 100 cases. The broader digest inventory check has one identical baseline/head +failure on an unchanged Todo selector; that check remains failed. + +A separate report-composition probe used CPython 3.13.5 on macOS arm64, normal +bytecode in both arms, an uncounted prewarm and eight alternating pairs per +disabled/enabled case. Disabled median wall/CPU observations were 0.255/0.111 s +before and 0.246/0.107 s after; enabled observations were 0.331/0.117 s and +0.321/0.113 s. Loaded LoopX modules fell from 196 to 179/182. These small, +concurrent-test observations do not establish material speedup, sustained tail +latency or complete CLI/Node/model/attention cost benefit. The earlier frozen +20-cost, attribution and normal-bytecode campaigns were not repeated, enlarged or +relabeled. The independently reversible delta is off-state construction isolation. +Exact-head review, CI/readiness and explicit protected merge authority still govern +adoption. Preserve I3 for at least one minor release, M1/#8011, full R0/V10/R1, +D2/D5/D6/D3, context/model/split-install, full-cost/adoption gaps and all historical +failures. The existing runtime-composition work continues through these boundaries. + +### 2026-11-08 — Synthetic Host UTF-8 recovery qualification + +The Windows CPython 3.11 CI arm of #8360 fails six existing original-task +revalidation cases. Its synthetic Python Host inherits a locale decoder for a +pipe, while the existing generic Host transport sends UTF-8 JSON. A disposable +File-provider counterprobe changes only that Host decoder: CP1252 produces +`host_failure` without `task_failure`; UTF-8 reaches the independent artifact +validator and produces `validation_failed` at `task_postcondition`, with +`revalidate_cached_result`. The source macOS arm independently passes all 12 +existing revalidation cases. These observations locate a fixture mismatch; +they do not justify widening production failure classification. + +Fixture commit `a4ce613585fdb329ba3600f0ede768864214ed84`, over main +`4e949f405528fd8087f0623526c62f890c1ce982`, explicitly configures its stdin as +UTF-8 before reading the request. One new File/SQLite regression first sets +only the generated Host's decoder to CP1252, sends a Unicode brief and requires +the rejected artifact to reach task validation. After repairing the artifact, +revalidation must accept it with the original turn key and exactly one Host +invocation. The same regression fails with the immutable baseline Host on the +same CPython 3.13.5/Node 22.22.3 environment, and passes with the correction. +No transport, production owner, persisted state or protocol vocabulary changes. + +On macOS arm64 with Node 22.22.3, CPython 3.11.15 source and a newly installed, +isolated monolithic wheel each pass the new two provider cases plus all 12 +existing original-task recovery cases. Wheel imports are verified outside the +checkout before loading the source-owned fixtures; all 1,920 present packaged +tracked runtime entries match the source bytes. The same 17 source-only +exclusions remain: canary baseline, host plugin/config, fault-test helper and +documentation/media. This qualifies the existing File/SQLite +synthetic Host recovery path, including retained MCP disconnection and lock +timeout recovery cases, not model adoption, split installation or native +Windows. The failed original Windows arm remains historical evidence until a +new exact-head Windows run passes. The independent Frontstage manifest request +still returns HTTP 504 after its bounded retry. + +The original task stays open. Preserve I3 for at least one minor release, +M1/#8011, full R0/V10/R1, D2/D5/D6/D3, context/model/split-install, +PostgreSQL host/wheel, whole-path/full marginal-cost/adoption requirements and +all historical and frozen failures. No cost campaign is repeated or relabeled, +and this fixture qualification establishes no performance benefit. The report +loading proposal #8360 retains its independent review and CI gates; the next +step is exact-head review of this correction and native Windows readback. No +main merge or installed-host upgrade is included. + ## Appendix B: Decision log | Date | Decision | Owner / approval | Alternatives | Normative sections changed | @@ -2652,3 +2834,71 @@ 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. + +### 2026-11-08 — Integrated loading and recovery qualification + +The combined proposal adopts the existing feedback/declaration-codec repair +from #8346, report construction isolation from #8360 and UTF-8 synthetic Host +fixture repair from #8365 over target main `fe36f31fd`. Runtime qualification +uses `251d7eaeff4581d3436340f68c5b6cd8c8ebf1ad`. The existing lexical +`write_scope_codec.ts` already removes the observed dependency on full lease +admission; an additional interaction decoder layer is unnecessary. Error and +vocabulary owners, declared scope spellings and retained receipts stay intact. + +On Node 22.22.3, the loading/digest checks and adjacent work-requirement, +lease and effect-program regressions pass (60 cases), as does the control-plane +typecheck. Selected-owner load failure still rejects without fallback. Four +fresh source/wheel × File/SQLite 101-Todo console correctness episodes preserve +the prior immutable fixture/artifact fingerprints, scopes, original completion +time, four receipts, one debit, refusal/restart/replay and two owned native exits +each. All 1,921 present tracked wheel entries match source bytes; seventeen +source-only, documentation and media omissions are unchanged. + +Real PostgreSQL 16.15 store/service suites pass (391/11, zero skips) on an +isolated disposable server that was stopped afterward. The first service run +omitted its dedicated environment variable and skipped one case; that result +remains a failure of qualification, separate from the corrected run. + +These episodes qualify integration correctness. Whole-path and full marginal +cost and adoption remain unqualified. Preserve I3 for at least one minor +release, M1/#8011, full R0/V10/R1, D2/D5/D6/D3, context/model/split-install, +PostgreSQL host/wheel and every frozen failure. The original task remains open; +this checkpoint does not authorize merge or declare the minimal kernel complete. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index fac481ef09..f08f5b8d78 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,153 @@ 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。 + +### 2026-11-08 — Turn 装配中的报告构造加载 + +既有 `periodic-report` Python provider 与 CLI Turn 适配层改为按需解析报告源、 +预设、阶段投影和请求导出。在 runtime 版本 +`7858d96e83ef98174cc43870e283f8f0e6f40c0b`,禁用订阅不会导入这些构造 provider。 +能力包已有 lazy exports,本次修复的是实际装配调用方。订阅校验仍由原 owner +负责,包括 cadence 使用 profile 中的日程校验器。Goal override、live machine +defaults、配置 warning、原导出身份与 patch 点、hook profile digest/policy version、 +source 选择、receipt replay 和权限保持;不新增 capability、配置、UI、持久化词汇 +或决策 owner。 + +与基线 `c263f7c78353fcaa914f23475d4d99d07eab7c01` 对照,11 个相同合成配置场景 +保持 hook 输出、trigger 决策及完整配置错误消息一致。冷进程拒绝 oracle 在修复前 +因禁用装配导入 report source 而失败;8 个聚焦场景现覆盖缺省、禁用继承、override、 +非法禁用配置、无副作用读取和导出/patch 兼容。最初更强的禁止规则也拒绝了原 +profile 日程校验器;该失败探针保留,本阶段保持校验器,不绕过它。 + +Source 与独立构建 wheel 的 File/default-SQLite console 路径通过原始完整 +101-Todo 生命周期:fixture/artifact 不变、4 个 receipts、一次 debit、source/lease/ +拒绝/重启/重放读回,以及每条路径 2 个自有 native 退出。先验证安装 package +归属,再比对 1,920 个实际存在的 tracked entries;17 个 source-only 或文档/媒体 +省略明确保留。Wheel 测试依赖和 frontend build 准备失败仍记录为失败。 +隔离 PostgreSQL 16 store/service 套件通过 391/11 场景,零 skip;service 首轮 +因缺少环境声明而跳过的记录仍保留。这不资格 PostgreSQL host/wheel 路径。 +报告相关套件通过 100 场景;较广的 digest inventory 检查在未改动 Todo selector +上出现一条完全相同的 baseline/head 失败,该检查仍为失败。 + +独立报告装配探针在 macOS arm64/CPython 3.13.5 下,两侧使用正常 bytecode、一次 +不计样本的预热,对禁用/启用场景各做 8 对交替观察。禁用 wall/CPU 中位数为 +修复前 0.255/0.111 s、修复后 0.246/0.107 s;启用为 0.331/0.117 s 和 +0.321/0.113 s。加载的 LoopX modules 从 196 降到 179/182。小样本与并行测试下 +的这些观察不证明明显提速、持续尾延迟或完整 CLI/Node/model/attention 成本收益。 +早先冻结的 20-cost、归因及正常 bytecode 实验未重跑、扩充或重标签。 +本阶段可独立回滚的增量是关闭态构造隔离。采纳仍需同 head review、CI/readiness +和明确的受保护合并授权。I3 至少保留一个 minor release,M1/#8011、完整 +R0/V10/R1、D2/D5/D6/D3、context/model/split-install、full-cost/adoption 缺口 +及所有历史失败均保持开放;既有 runtime composition 工作继续跨越这些边界。 + +### 2026-11-08 — 合成 Host 的 UTF-8 恢复资格 + +#8360 的 Windows CPython 3.11 CI arm 在六个既有原任务重验证用例中失败。 +合成 Python Host 对 pipe 继承 locale decoder,而原 generic Host transport +发送 UTF-8 JSON。Disposable File-provider counterprobe 只改变该 Host decoder: +CP1252 产生 `host_failure`,没有 `task_failure`;UTF-8 到达独立产物 validator, +在 `task_postcondition` 产生 `validation_failed`,并提供 +`revalidate_cached_result`。源码 macOS arm 独立通过全部12个既有重验证用例。 +这些观察定位了 fixture mismatch,不支持扩大生产 failure classification。 + +Fixture commit `a4ce613585fdb329ba3600f0ede768864214ed84` 基于 main +`4e949f405528fd8087f0623526c62f890c1ce982`,在读取请求前显式将 stdin 配为 +UTF-8。新增 File/SQLite regression 先只把生成 Host 的 decoder 设为 CP1252, +发送 Unicode brief,要求被拒绝的产物到达 task validation。修复产物后,重验证 +必须沿用原 turn key 并接受结果,Host invocation 始终为一次。同一 regression +在相同 CPython3.13.5/Node22.22.3 环境中对不可变 baseline Host 失败,修正后 +通过。不改变 transport、生产 owner、持久状态或协议词汇。 + +macOS arm64/Node22.22.3 下,CPython3.11.15 source 与新安装的隔离单体 wheel +各通过新增两个 provider 用例及全部12个既有原任务恢复用例。先在 checkout 外 +验证 wheel import 归属,再加载 source-owned fixture;1920个实际 packaged +tracked runtime entry 均与源码字节相同。原17个 source-only exclusion 保留: +canary baseline、host plugin/config、fault-test helper 和 documentation/media。 +这验收既有 File/SQLite 合成 Host 恢复路径,包括保留的 MCP +断连及 lock-timeout 恢复用例,不验收模型采纳、split installation 或原生 Windows。 +原失败 Windows arm 保留为历史证据,直到新 exact-head Windows run 通过。 +独立 Frontstage manifest 请求在有界重试后仍为 HTTP504。 + +原任务保持开放。保留 I3 至少一个 minor release、M1/#8011、完整 R0/V10/R1、 +D2/D5/D6/D3、context/model/split-install、PostgreSQL host/wheel、whole-path/ +full marginal-cost/adoption 验收及全部历史/冻结失败。不重跑或重标签成本 +campaign,本 fixture 资格不证明性能收益。报告按需加载提案 #8360 保留独立 +review/CI gate;下一步为本修正的 exact-head 评审与原生 Windows 读回。不包含 +合入 main 或 installed-host 升级。 + ## 附录 B:决策日志 | 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 | @@ -2058,3 +2205,57 @@ 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 保持开放。 + +### 2026-11-08 — 组合加载与恢复资格验证 + +组合提案在目标 main `fe36f31fd` 上采用 #8346 已有的 feedback/声明 codec +修复、#8360 的报告构造隔离和 #8365 的 UTF-8 合成 Host fixture 修复。 +运行时代码资格基于 `251d7eaeff4581d3436340f68c5b6cd8c8ebf1ad`。 +已有的词法 `write_scope_codec.ts` 已解除观察到的完整 lease 准入依赖,无需 +增加 interaction decoder 层。错误与词汇 owner、声明 scope 拼写和保留回执均保持。 + +Node 22.22.3 上的加载/digest 与相邻 work-requirement、lease、effect-program +回归全部通过(60 项),控制面类型检查通过。选中 owner 的加载失败仍拒绝执行, +不会回退。四次新的源码/wheel × File/SQLite 101-Todo console 正确性路径均保留 +前次不可变 fixture/artifact 指纹、scope、原完成时间、四份回执、一次扣额、 +拒绝/重启/重放,以及每条路径两个自有 native 进程退出。wheel 中现存的 1,921 个 +tracked 条目逐字节匹配源码,十七项源码专用、文档和媒体遗漏保持不变。 + +真实 PostgreSQL 16.15 store/service 在隔离临时服务上全部通过(391/11,零 +跳过),自有服务随后停止。首次 service 运行漏传专用环境变量,跳过一项; +该次资格失败单独保留,与修正后的运行区分。 + +这些路径验证组合正确性,whole-path 与完整边际成本、adoption 仍未取得资格。 +保留 I3 至少一个 minor release、M1/#8011、完整 R0/V10/R1、D2/D5/D6/D3、 +context/model/split-install、PostgreSQL host/wheel 以及每项冻结失败。 +原任务保持开放;本检查点不授权合入,也不宣告最小化内核完成。 diff --git a/docs/guides/context-delivery.md b/docs/guides/context-delivery.md index 6b09d061f5..93934c0632 100644 --- a/docs/guides/context-delivery.md +++ b/docs/guides/context-delivery.md @@ -129,6 +129,11 @@ from file deltas and provider caching; it does not prove model retention or lower total cost. Partial reads and lost continuity cannot qualify this reuse. Explicit same-capture selections always recover the complete current value. +An unchanged Goal narrative can also be referenced after an exact read of the +same registered source in the current session and epoch. The current source +revision and Turn controls still appear. A change to the narrative or its +source, an incomplete read, or compaction restores the current text. + ### Read and recover the right object Use the path, digest and command returned by the current response. A native diff --git a/docs/guides/context-delivery.zh-CN.md b/docs/guides/context-delivery.zh-CN.md index fcfabbe2b7..757807833c 100644 --- a/docs/guides/context-delivery.zh-CN.md +++ b/docs/guides/context-delivery.zh-CN.md @@ -102,6 +102,10 @@ Full 表示完整保存视图可用,不要求判断任何问题前先遍历所 这种工具投递假设与文件差分、provider 缓存分别成立,不证明模型保留或完整成本下降。 未读完或连续性丢失不能取得该复用资格;显式同 capture 选择仍可恢复完整当前值。 +当前原生会话在同一上下文周期内完整读取同一注册来源后,也可引用未变的 Goal 正文。 +首读仍交付当前来源修订和 Turn 控制条件。正文或来源变化、读取不完整或发生压缩时, +读者恢复当前正文。 + ### 读对对象,再恢复 使用当前响应返回的路径、摘要与命令。原生响应的 `recovery` 指向当前完整必要视图; diff --git a/examples/personal-workspace-browser/task-inspector-return.mjs b/examples/personal-workspace-browser/task-inspector-return.mjs index a6f22362c3..adb17f9d5a 100644 --- a/examples/personal-workspace-browser/task-inspector-return.mjs +++ b/examples/personal-workspace-browser/task-inspector-return.mjs @@ -14,12 +14,32 @@ export const taskInspectorReturnScenario = { const coverageEntries = []; const tail = " FINAL_ACCEPTANCE: every requirement is visible; stale or unavailable reads remain explicit."; const prefix = "[P0] Full queue follow-up. "; - const request = prefix + "A".repeat(988 - prefix.length - tail.length) + tail; + const request = prefix + "Read the original source, compare its accepted outcome with the retained reference, and preserve conditions and uncertainty. ".repeat(12).slice(0, 988 - prefix.length - tail.length) + tail; + const evidence = "Independent verification. The retained source contains the complete result and comparison conditions. " + + "The bounded list preview does not include the final reference, so opening this Task must read it from the same original source. " + + "Unicode evidence 证据🙂 remains intact. Retained baseline: reference-v2."; + const note = "Conditions and limitations. This synthetic example separates a reported result from independent confirmation. " + + "Missing measurements are still unknown; reading the Task does not launch an analysis or change its status. Attribution remains unknown."; for (const width of [1512, 390]) { + let metadata = {evidence, note}; const context = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width, height: 844 }, - beforeGoto(api) { + async beforeGoto(api, page) { api.todoRequestTexts.set(JSON.stringify(["progress-projection", "todo-progress-full"]), request); + api.transformStatus = payload => { + const goal = payload.attention_queue.items.find(item => item.goal_id === "progress-projection"); + for (const item of goal?.agent_todos?.items ?? []) { + if (item.todo_id === "todo-progress-full") Object.assign(item, {evidence: evidence.slice(0, 100), note: note.slice(0, 100)}); + if (item.todo_id === "todo-progress-current") item.evidence = "Current verification"; + } + }; + await page.route("**/api/chat/todo/detail?*", async route => { + const query = new URL(route.request().url()).searchParams; + if (query.get("todo_id") !== "todo-progress-full") return route.fallback(); + api.todoRequestReads.push({goalId: query.get("goal_id"), todoId: query.get("todo_id")}); + await route.fulfill({json: {ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", + text: request, status: "open", archive_state: "active", updated_at: null, ...metadata}}); + }); }, }); const { page, api } = context; @@ -78,10 +98,37 @@ export const taskInspectorReturnScenario = { await fullOpener.click(); await waitForFull(); assert.equal(await body.innerText(), request, "A cold read restores every one of the 988 source characters beyond the 500-character status projection"); + const evidenceCard = drawer.getByRole("region", {name: "证据", exact: true}); + const notes = drawer.locator('details[aria-label="备注"]'); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), evidence); + await notes.locator("summary").click(); + assert.equal(await notes.locator(".personal-md").innerText(), note); + assert.equal(api.todoRequestReads.filter(read => read.todoId === "todo-progress-full").length, 1, "Requirements, evidence and notes share one source read"); await page.screenshot({ path: resolve(outputDir, `task-request-full-${width}.png`), animations: "disabled" }); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); + // Older readers omit metadata; explicit null from the current source + // must instead clear an outdated bounded hint. + for (const fields of [{}, {evidence: null, note: null}]) { + metadata = fields; + await fullOpener.click(); + await waitForFull(); + if ("evidence" in fields) { + assert.equal(await evidenceCard.count(), 0); + assert.equal(await notes.count(), 0); + } else { + assert.match(await evidenceCard.locator(".personal-md").innerText(), /^Independent verification/); + assert.doesNotMatch(await evidenceCard.innerText(), /Retained baseline/); + await notes.locator("summary").click(); + assert.match(await notes.locator(".personal-md").innerText(), /^Conditions and limitations/); + assert.doesNotMatch(await notes.innerText(), /Attribution remains unknown/); + } + await page.keyboard.press("Escape"); + await drawer.waitFor({state: "hidden"}); + } + metadata = {evidence, note}; + // A failed or mismatched read must discard an earlier successful body, // preserve the received summary, and offer a real retry in the drawer. let nextRead = "failed"; @@ -93,7 +140,7 @@ export const taskInspectorReturnScenario = { nextRead = null; if (outcome === "late") { await new Promise(resolveWait => { releaseLate = resolveWait; }); - await route.fulfill({ json: { ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", text: request, status: "open", archive_state: "active", updated_at: null } }); + await route.fulfill({ json: { ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", text: request, evidence, note, status: "open", archive_state: "active", updated_at: null } }); lateFinished(); return; } @@ -108,8 +155,10 @@ export const taskInspectorReturnScenario = { await retry.waitFor(); assert.equal((await body.innerText()).length, 500); assert.ok(!(await body.innerText()).includes("FINAL_ACCEPTANCE"), "A previous successful body is never reused after a failed exact read"); + assert.doesNotMatch(await drawer.innerText(), /Retained baseline|Attribution remains unknown/); await retry.click(); await waitForFull(); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), evidence); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); } @@ -124,6 +173,8 @@ export const taskInspectorReturnScenario = { releaseLate(); await finished; assert.equal(await body.innerText(), "Current Todo", "A late response from the closed Task cannot overwrite the next selection"); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), "Current verification"); + assert.equal(await notes.count(), 0, "A late source note cannot appear under the next Task"); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); assert.ok(api.todoRequestReads.every(read => read.todoId), "A display-only legacy identity must never be sent as an authority id"); @@ -274,6 +325,6 @@ export const taskInspectorReturnScenario = { await exited; await rm(root, {recursive:true,force:true}); } - return { coverageEntries, note: "The 988-character source survives a bounded 500-character status; failed/mismatched reads, retry and late selection responses are explicit on packaged desktop/390px. Pointer/Escape/Enter/close/More retain the opener with reduced motion. Editing uses a disposable real File authority and Chat HTTP owner: one save, retained concurrent-conflict draft and lost successful response recovery without another apply. Workspace navigation/status is synthetic. File/SQLite hard-lease semantics are qualified separately by HTTP+CLI tests; no active Goal or model execution." }; + return { coverageEntries, note: "The 988-character request plus complete evidence/notes share one read beyond bounded status hints; absent fields preserve legacy compatibility and explicit null clears stale hints. Failed/mismatched reads, retry and late selection responses are explicit on packaged desktop/390px. Pointer/Escape/Enter/close/More retain the opener with reduced motion. Editing uses a disposable real File authority and Chat HTTP owner: one save, retained concurrent-conflict draft and lost successful response recovery without another apply. Workspace navigation/status is synthetic. File/SQLite hard-lease semantics are qualified separately by HTTP+CLI tests; no active Goal or model execution." }; }, }; diff --git a/loopx/capabilities/periodic_report/post_writeback_hook.py b/loopx/capabilities/periodic_report/post_writeback_hook.py index a10e6158e6..51d451a6f7 100644 --- a/loopx/capabilities/periodic_report/post_writeback_hook.py +++ b/loopx/capabilities/periodic_report/post_writeback_hook.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +import sys import warnings from collections.abc import Mapping, Sequence +from importlib import import_module from pathlib import Path from typing import Any @@ -11,26 +13,50 @@ PostWritebackHookRegistration, ) from ...control_plane.digest_envelope import enveloped_sha256, sha256_envelope -from ...control_plane.coordination.local_authority import canonical_todo_summary_fields -from ...control_plane.goals.goal_frontier import ( - build_goal_frontier_projection_from_summaries, -) -from .todo_source import read_report_source_history, read_report_todo_source -from ...control_plane.todos.quota_summary import summarize_user_todos_for_quota -from ...control_plane.todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL from ...history import collect_history, load_index_snapshot, load_registry from ...paths import resolve_runtime_root from ...registry import registry_goals -from ...rollout_event_log import load_rollout_events, rollout_event_log_path -from .stage_completion import STAGE_COMPLETION_RECEIPT_SCHEMA -from .stage_completion import derive_periodic_report_stage_completion_from_runs -from .presets import build_periodic_report_preset_activation -from .project_progress_snapshot import build_project_progress_snapshot_from_fields -from .incremental import read_periodic_report_goal_publication_cursors from .machine_defaults import resolve_goal_periodic_report_subscription from .machine_store import read_periodic_report_machine_defaults -from .triggers import build_periodic_report_trigger_decision -from .request_action import REQUEST_ACTION_SCHEMA + + +# Subscription validation stays on the composition path. Construction and +# source providers are loaded only by their actual caller, with the original +# module-level aliases retained for compatibility and caller patching. +_LAZY_EXPORTS = { + "canonical_todo_summary_fields": "loopx.control_plane.coordination.local_authority", + "build_goal_frontier_projection_from_summaries": "loopx.control_plane.goals.goal_frontier", + "read_report_source_history": "loopx.capabilities.periodic_report.todo_source", + "read_report_todo_source": "loopx.capabilities.periodic_report.todo_source", + "summarize_user_todos_for_quota": "loopx.control_plane.todos.quota_summary", + "MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL": "loopx.control_plane.todos.todo_index", + "load_rollout_events": "loopx.rollout_event_log", + "rollout_event_log_path": "loopx.rollout_event_log", + "STAGE_COMPLETION_RECEIPT_SCHEMA": "loopx.capabilities.periodic_report.stage_completion", + "derive_periodic_report_stage_completion_from_runs": "loopx.capabilities.periodic_report.stage_completion", + "build_periodic_report_preset_activation": "loopx.capabilities.periodic_report.presets", + "build_project_progress_snapshot_from_fields": "loopx.capabilities.periodic_report.project_progress_snapshot", + "read_periodic_report_goal_publication_cursors": "loopx.capabilities.periodic_report.incremental", + "build_periodic_report_trigger_decision": "loopx.capabilities.periodic_report.triggers", + "REQUEST_ACTION_SCHEMA": "loopx.capabilities.periodic_report.request_action", +} + + +def __getattr__(name: str) -> Any: + owner = _LAZY_EXPORTS.get(name) + if owner is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(owner), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_LAZY_EXPORTS)) + + +def _report_export(name: str) -> Any: + return getattr(sys.modules[__name__], name) PERIODIC_REPORT_POST_WRITEBACK_HOOK_ID = "periodic_report.runtime_trigger" @@ -118,7 +144,7 @@ def periodic_report_post_writeback_hooks_for_goal( preset = str(subscription.get("profile_preset") or "").strip() if not preset: return () - activation = build_periodic_report_preset_activation(preset) + activation = _report_export("build_periodic_report_preset_activation")(preset) if activation.get("active") is not True: return () profile = activation.get("profile") @@ -159,9 +185,9 @@ def _frontier_projection( if isinstance(todos.get("agent_todos"), Mapping) else {} ) - user_summary = summarize_user_todos_for_quota(raw_user_summary) or {} - agent_summary = summarize_user_todos_for_quota(raw_agent_summary) or {} - projection = build_goal_frontier_projection_from_summaries( + user_summary = _report_export("summarize_user_todos_for_quota")(raw_user_summary) or {} + agent_summary = _report_export("summarize_user_todos_for_quota")(raw_agent_summary) or {} + projection = _report_export("build_goal_frontier_projection_from_summaries")( goal_id=goal_id, agent_id=agent_id, user_todo_summary=user_summary, @@ -210,21 +236,21 @@ def build_periodic_report_post_writeback_projection( if source_todos.get("status") != "loaded": raise ValueError("original canonical Todo source is unavailable") source_todos["_todo_fields"] = restore_todo_field_evaluations(readback) - source_runs = read_report_source_history( + source_runs = _report_export("read_report_source_history")( runtime_root=runtime_root, goal_id=goal_id, run=payload, ) if (source_todos is None) != (source_runs is None): raise ValueError("a committed source requires both Todo and run history") - events = [] if source_todos is not None else load_rollout_events( - rollout_event_log_path(runtime_root, goal_id), - limit=MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL, + events = [] if source_todos is not None else _report_export("load_rollout_events")( + _report_export("rollout_event_log_path")(runtime_root, goal_id), + limit=_report_export("MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL"), ) state = payload.get("state") state_path = ( state.get("path") if isinstance(state, Mapping) else None ) or payload.get("state_file") if source_todos is not None: - fields = source_todos.get("_todo_fields") or canonical_todo_summary_fields( + fields = source_todos.get("_todo_fields") or _report_export("canonical_todo_summary_fields")( source_todos["todos"], rollout_events=events, available_capabilities=available_capabilities, @@ -232,7 +258,7 @@ def build_periodic_report_post_writeback_projection( goal_acceptance_work_guards=source_todos.get("goal_acceptance_work_guards"), ) else: - fields, _ = read_report_todo_source( + fields, _ = _report_export("read_report_todo_source")( registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, @@ -265,7 +291,7 @@ def build_periodic_report_post_writeback_projection( # Terminal settlement follows the current history. A successor milestone is # only claimed below, from the exact durable refresh that carries its # accepted ACK and selected Vision. - receipt = derive_periodic_report_stage_completion_from_runs( + receipt = _report_export("derive_periodic_report_stage_completion_from_runs")( latest_runs=latest_runs, agent_id=normalized_agent_id, goal_frontier_projection=projection, @@ -314,7 +340,7 @@ def build_periodic_report_post_writeback_projection( ): return {} ack["agent_id"] = attributed_agent_id - receipt = derive_periodic_report_stage_completion_from_runs( + receipt = _report_export("derive_periodic_report_stage_completion_from_runs")( latest_runs=source_refresh_runs, agent_id=normalized_agent_id, goal_frontier_projection=projection, @@ -330,14 +356,14 @@ def build_periodic_report_post_writeback_projection( if receipt is None: return {} result: dict[str, object] = {"stage_completion": receipt} - goal_cursors = read_periodic_report_goal_publication_cursors( + goal_cursors = _report_export("read_periodic_report_goal_publication_cursors")( runtime_root=runtime_root, goal_id=goal_id, ) publication_cursor = next( (c for c in goal_cursors if c["agent_id"] == normalized_agent_id), None ) - project_progress = build_project_progress_snapshot_from_fields( + project_progress = _report_export("build_project_progress_snapshot_from_fields")( fields=fields, goal_id=goal_id, agent_id=normalized_agent_id, @@ -376,7 +402,7 @@ def producer(hook_input: Mapping[str, Any]) -> dict[str, Any]: ) if ( not isinstance(stage, Mapping) - or stage.get("schema_version") != STAGE_COMPLETION_RECEIPT_SCHEMA + or stage.get("schema_version") != _report_export("STAGE_COMPLETION_RECEIPT_SCHEMA") or not isinstance(stage.get("stage_identity"), str) or not stage["stage_identity"] ): @@ -479,7 +505,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( "idempotency_key" ) != "periodic-report:" + window["window_id"]: raise ValueError("cadence intent identity does not match its window") - return build_periodic_report_trigger_decision({ + return _report_export("build_periodic_report_trigger_decision")({ "schema_version": "periodic_report_trigger_request_v0", "evaluated_at": window["due_at"], "profile": {"profile_id": profile_ref.get("profile_id"), @@ -508,7 +534,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( } if ( set(report_request) != expected - or report_request.get("schema_version") != REQUEST_ACTION_SCHEMA + or report_request.get("schema_version") != _report_export("REQUEST_ACTION_SCHEMA") or report_request.get("requester_kind") != "user" or report_request.get("addressing_source") not in {"provider_mention", "verified_reply"} @@ -538,7 +564,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( separators=(",", ":"), ).encode() ) - return build_periodic_report_trigger_decision( + return _report_export("build_periodic_report_trigger_decision")( { "schema_version": "periodic_report_trigger_request_v0", "evaluated_at": requested_at, @@ -570,7 +596,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( "completed_at", ) if ( - stage.get("schema_version") != STAGE_COMPLETION_RECEIPT_SCHEMA + stage.get("schema_version") != _report_export("STAGE_COMPLETION_RECEIPT_SCHEMA") or stage.get("acceptance") != "validated" or stage.get("outcome_checkpoint_satisfied") is not True or any( @@ -618,7 +644,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( } if isinstance(payload.get("last_report"), Mapping): request["last_report"] = dict(payload["last_report"]) - return build_periodic_report_trigger_decision(request) + return _report_export("build_periodic_report_trigger_decision")(request) __all__ = [ diff --git a/loopx/cli_commands/quota_context_reader.py b/loopx/cli_commands/quota_context_reader.py index 668305f281..a7e51ed16e 100644 --- a/loopx/cli_commands/quota_context_reader.py +++ b/loopx/cli_commands/quota_context_reader.py @@ -130,6 +130,17 @@ def _validate_context_page_window(offset: int, size: int, output_limit: int | No "larger pages require a sufficient --max-page-output-bytes within the capture limit.") +def _validate_pinned_selection(selection: dict[str, Any]) -> None: + if (set(selection) - {"present_goal_narrative"} != {"select", "initial_read", "list_fields", "list_depth", "sha256"} + or type(selection["initial_read"]) is not bool or type(selection["list_fields"]) is not bool + or ("present_goal_narrative" in selection and ( + selection["present_goal_narrative"] is not True + or selection["list_fields"] or selection["initial_read"])) + or not isinstance(selection["sha256"], str) + or BARE_SHA256_PATTERN.fullmatch(selection["sha256"]) is None): + raise ValueError("reader selection reference is incomplete or unsupported") + + def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, Any] | None = None, next_page_bytes: int | None = None, next_page_output_bytes: int | None = None, @@ -191,30 +202,31 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An if pinned_selection is not None: if selectors is not None or initial_read or list_fields or list_depth is not None or selection_digest is not None: raise ValueError("a pinned selection reference cannot change selectors; use the original capture reference") - if (set(pinned_selection) - {"present_goal_narrative"} != - {"select", "initial_read", "list_fields", "list_depth", "sha256"} - or type(pinned_selection["initial_read"]) is not bool or type(pinned_selection["list_fields"]) is not bool - or ("present_goal_narrative" in pinned_selection and ( - pinned_selection["present_goal_narrative"] is not True - or pinned_selection["list_fields"] or pinned_selection["initial_read"])) - or not isinstance(pinned_selection["sha256"], str) - or BARE_SHA256_PATTERN.fullmatch(pinned_selection["sha256"]) is None): - raise ValueError("reader selection reference is incomplete or unsupported") + _validate_pinned_selection(pinned_selection) if observation := reference.get("observed_read"): - from ..control_plane.turn_driver.native_first_read import observation_current + from ..control_plane.turn_driver.native_first_read import observation_current, observed_first_read_fallback if not observation_current(observation, path, digest): + fallback = observed_first_read_fallback(observation, path, digest) + if fallback is not None: + try: + _validate_pinned_selection(fallback[1]) + except ValueError: + fallback = None if offset: return {"ok": False, "reason": "Observed first-read scope changed during paging.", - "recovery_command": context_capture_read_command(path, digest, initial_read=True, + "recovery_command": context_capture_read_command(path, digest, initial_read=fallback is None, + reference_file=fallback[0] if fallback is not None else None, page_bytes=size, max_page_output_bytes=output_limit, reader_argv=reader_argv)} # Restart in the ordinary complete view of this same capture. - # Subsequent pages must pin that view even if the journal changes. - pinned_selection, initial_read = None, True - try: - reference_file = write_context_read_reference(path, digest) - except OSError: - reference_file = None + if fallback is not None: + reference_file, pinned_selection = fallback + else: + pinned_selection, initial_read = None, True + try: + reference_file = write_context_read_reference(path, digest) + except OSError: + reference_file = None elif offset == 0: presentation = {**(presentation or {}), "previously_delivered_fields": list(observation["fields"]), "field_recovery": "These unchanged fields still apply. If needed or uncertain, append --select " diff --git a/loopx/cli_commands/turn_post_writeback.py b/loopx/cli_commands/turn_post_writeback.py index 9c07ebbea4..3f1ab63ac1 100644 --- a/loopx/cli_commands/turn_post_writeback.py +++ b/loopx/cli_commands/turn_post_writeback.py @@ -2,15 +2,12 @@ from __future__ import annotations +import sys from collections.abc import Mapping +from importlib import import_module from pathlib import Path from typing import Any -from ..capabilities.periodic_report.post_writeback_hook import ( - build_periodic_report_post_writeback_projection, - periodic_report_post_writeback_hooks_for_goal, -) -from ..capabilities.periodic_report.todo_source import read_report_source_history from ..control_plane.effect_runtime import ( CANONICAL_AUTHORITY_READ_TIMEOUT_SECONDS, effect_runtime_result, @@ -22,6 +19,30 @@ ) +_LAZY_EXPORTS = { + "build_periodic_report_post_writeback_projection": "loopx.capabilities.periodic_report.post_writeback_hook", + "periodic_report_post_writeback_hooks_for_goal": "loopx.capabilities.periodic_report.post_writeback_hook", + "read_report_source_history": "loopx.capabilities.periodic_report.todo_source", +} + + +def __getattr__(name: str) -> Any: + owner = _LAZY_EXPORTS.get(name) + if owner is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(owner), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_LAZY_EXPORTS)) + + +def _report_export(name: str) -> Any: + return getattr(sys.modules[__name__], name) + + def drain_committed_turn_post_writeback_hooks( *, registry_path: Path, @@ -32,7 +53,7 @@ def drain_committed_turn_post_writeback_hooks( ) -> dict[str, Any]: """Drain committed sources on first return and replay without primary effects.""" try: - hooks = periodic_report_post_writeback_hooks_for_goal( + hooks = _report_export("periodic_report_post_writeback_hooks_for_goal")( registry_path=registry_path, goal_id=identity.goal_id, runtime_root=runtime_root ) except Exception: # Optional subscription reads grant no primary failure. @@ -49,7 +70,7 @@ def drain_committed_turn_post_writeback_hooks( run = readback.writeback_run if readback is not None else None if run is None: return {} - source_runs = read_report_source_history( + source_runs = _report_export("read_report_source_history")( runtime_root=runtime_root, goal_id=identity.goal_id, run=run, ) except Exception: # Optional source recovery must preserve primary settlement. @@ -83,7 +104,7 @@ def dispatch(event_kind: str, request: dict[str, Any]) -> dict[str, Any] | None: ) def projection_builder(**kwargs: Any) -> Mapping[str, object]: - return build_periodic_report_post_writeback_projection( + return _report_export("build_periodic_report_post_writeback_projection")( **kwargs, source_todos=source, source_runs=source_runs, ) @@ -122,3 +143,7 @@ def projection_builder(**kwargs: Any) -> Mapping[str, object]: if terminal is not None: result["terminal_post_writeback_hooks"] = terminal return result + + +# Preserve the previous implicit wildcard exports; explicit imports remain lazy. +__all__ = sorted({name for name in globals() if not name.startswith("_")} | set(_LAZY_EXPORTS)) diff --git a/loopx/control_plane/quota/diagnostic_projection.ts b/loopx/control_plane/quota/diagnostic_projection.ts index dfb7616740..d21a989c9b 100644 --- a/loopx/control_plane/quota/diagnostic_projection.ts +++ b/loopx/control_plane/quota/diagnostic_projection.ts @@ -114,7 +114,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/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index c6e10fb594..6d971e43f0 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -3,6 +3,7 @@ import {EffectiveAction} from "./effective_action.generated.ts"; * addressed in the immutable source. This plan changes no canonical state. */ import {isDeepStrictEqual} from "node:util"; import {visionAuthoringContract} from "../goals/vision_checkpoint.ts"; +import {completeGoalNarrative} from "../work_items/goal_context_source.ts"; import type {JsonObject} from "../effect_program.ts"; import {jsonObject, requireBoolean, requireJsonObject} from "../runtime_decode.ts"; import {GOAL_OUTCOMES_SCHEMA, OUTCOME_CONTEXT_NAVIGATION_SCHEMA} from "../goals/outcome_requirements.ts"; @@ -17,6 +18,7 @@ import {createHash} from "node:crypto"; type Path = (string | number)[]; +type ReusableReadField = {path: Path; freshKeys: string[]; minimumBytes: number; sourceFile?: string}; const MIN_REUSABLE_BODY_BYTES = 1024; const pointer = (path: Path): string => path.length ? "/" + path.map(key => String(key).replaceAll("~", "~0").replaceAll("/", "~1")).join("/") : ""; @@ -26,14 +28,21 @@ const known = (row: JsonObject, fields: string[]): boolean => Object.keys(row).e /** One registry for bodies eligible for observed reuse, regardless of whether * the reader delivered the body itself or packaged it in a complete parent. */ -function reusableFirstReadFields(context: JsonObject): {path: Path; freshKeys: string[]; minimumBytes: number}[] { +function reusableFirstReadFields(context: JsonObject): ReusableReadField[] { const base: Path = ["interaction_contract", "agent_channel", "work_context", "sources"]; const sources = at(context, base); if (!Array.isArray(sources)) return []; - return sources.flatMap((value, i) => { + return sources.flatMap((value, i) => { const source = jsonObject(value), content = jsonObject(source?.content); if (!content) return []; const path = [...base, i, "content"]; + // The current source and full-file revision still reach the first read. + // Only the literal narrative may carry forward across generated Todo edits. + if (source?.source === "goal_state" && content.ok !== false && + (content.status === undefined || content.status === "loaded") && + typeof content.state_file === "string" && completeGoalNarrative(content)) + return [{path: [...path, "text"], freshKeys: [], minimumBytes: MIN_REUSABLE_BODY_BYTES, + sourceFile: content.state_file}]; if (source?.kind === "agent_preferences") return [{path, freshKeys: ["ok", "revision", "instruction"], minimumBytes: MIN_REUSABLE_BODY_BYTES}]; if (source?.source !== "goal_acceptance") return []; @@ -66,15 +75,22 @@ function reusableFirstReadFields(context: JsonObject): {path: Path; freshKeys: s }); } +function observedSectionDigest(body: string, field?: ReusableReadField): string { + // Same text at a reused array index is insufficient after a Goal file rebind. + const value = field?.sourceFile === undefined ? body : JSON.stringify([field.sourceFile, body]); + return createHash("sha256").update(value).digest("hex"); +} + /** Digests stay in the private observed-read journal, never repeated to the model. * The existing exact-source identity preserves non-JS numeric spellings. */ export function contextReadSectionDigests(request: JsonObject, paths: Path[]): JsonObject { const context = requireJsonObject(request.context, "read section source"); const identity = contextSourceIdentity(request, request.source_number_tokens); const selected = new Set(paths.map(pointer)); + const fields = new Map(reusableFirstReadFields(context).map(field => [pointer(field.path), field])); const covered = new Map(paths.map(path => [pointer(path), {path, minimumBytes: MIN_REUSABLE_BODY_BYTES}])); - for (const field of reusableFirstReadFields(context)) - if (paths.some(parent => pathStartsWith(field.path, parent))) covered.set(pointer(field.path), field); + for (const [address, field] of fields) + if (paths.some(parent => pathStartsWith(field.path, parent))) covered.set(address, field); const inherited = jsonObject(request.consumed_pointers), coverage = new Set(paths.map(pointer)); // The IO owner authenticates the reader's native observation. Recheck each // inherited value against this exact source before combining it with the @@ -86,7 +102,7 @@ export function contextReadSectionDigests(request: JsonObject, paths: Path[]): J const read = capturedContextSelection({context, select: [address], recover_selection_errors: true}); if (read.rejected_selection) continue; const path = (read.selections as {path: Path}[])[0].path; - if (createHash("sha256").update(identity(at(context, path), ["context", ...path])).digest("hex") === expected) + if (observedSectionDigest(identity(at(context, path), ["context", ...path]), fields.get(address)) === expected) coverage.add(address); } const complete = (path: Path): boolean => { @@ -95,18 +111,18 @@ export function contextReadSectionDigests(request: JsonObject, paths: Path[]): J return value !== null && typeof value === "object" && Object.keys(value).length > 0 && Object.keys(value).every(key => complete([...path, Array.isArray(value) ? Number(key) : key])); }; - for (const field of reusableFirstReadFields(context)) - if (complete(field.path)) covered.set(pointer(field.path), field); + for (const [address, field] of fields) + if (complete(field.path)) covered.set(address, field); } return Object.fromEntries([...covered].flatMap(([address, {path, minimumBytes}]) => { const content = identity(at(context, path), ["context", ...path]); // Parent delivery must not multiply tiny bodies into a larger journal. return selected.has(address) || Buffer.byteLength(content) >= minimumBytes - ? [[address, createHash("sha256").update(content).digest("hex")]] : []; + ? [[address, observedSectionDigest(content, fields.get(address))]] : []; })); } -/** Only previously delivered supplementary bodies may use a reference. Current +/** Only registered, previously delivered bodies may use a reference. Current * decisions, stops, selection, settlement and unknown source shapes stay inline. */ function observedFirstRead(request: JsonObject, pointers: string[]): JsonObject | undefined { const context = requireJsonObject(request.context, "current first read"); @@ -126,7 +142,7 @@ function observedFirstRead(request: JsonObject, pointers: string[]): JsonObject const body = at(context, path); if (typeof seen[address] === "string" && field) { const content = identity(body, ["context", ...path]); - if (Buffer.byteLength(content) >= field.minimumBytes && createHash("sha256").update(content).digest("hex") === seen[address]) { + if (Buffer.byteLength(content) >= field.minimumBytes && observedSectionDigest(content, field) === seen[address]) { reused[address] = seen[address]; return field.freshKeys.filter(key => body !== null && typeof body === "object" && Object.hasOwn(body, key)).map(key => ({pointer: pointer([...path, key]), path: [...path, key]})); @@ -148,8 +164,22 @@ function observedFirstRead(request: JsonObject, pointers: string[]): JsonObject JSON.stringify({pointer: row.pointer}).slice(0, -1) + ',"value":' + identity(at(context, row.path), ["context", ...row.path]) + '}'), 0); // Splitting a container repeats addresses. Count those and the reuse index; // retain a margin for the existing recovery guidance, or deliver it whole. - return bytes(grouped) + Buffer.byteLength(JSON.stringify(Object.keys(reused))) + MIN_REUSABLE_BODY_BYTES < bytes(original) - ? {selections: grouped, reused} : undefined; + if (bytes(grouped) + Buffer.byteLength(JSON.stringify(Object.keys(reused))) + MIN_REUSABLE_BODY_BYTES >= bytes(original)) + return undefined; + if (request.include_initial_read_plan === true) { + // The IO adapter already receives the ordinary plan. Send the changed + // middle of that list, keeping its exact order and coalescing decisions. + let start = 0, end = original.length, stop = grouped.length; + while (start < end && start < stop && original[start].pointer === grouped[start].pointer) start++; + while (end > start && stop > start && original[end - 1].pointer === grouped[stop - 1].pointer) { + end--; + stop--; + } + const selection_splice = {start, end, selections: grouped.slice(start, stop)}; + if (Buffer.byteLength(JSON.stringify(selection_splice)) < Buffer.byteLength(JSON.stringify(grouped))) + return {selection_splice, reused}; + } + return {selections: grouped, reused}; } function inlineReadSections(request: JsonObject, required: JsonObject): JsonObject { 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/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index c949912597..9352e310c3 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -785,7 +785,7 @@ def publish_recovery(reference: dict[str, Any]) -> None: from .native_first_read import observed_first_read_reference reference = observed_first_read_reference(directory, result["target"], recovery, - transaction["context"], plan) + transaction["context"], plan, transaction.get("initial_read_plan")) if reference is not None: state["pending"]["observed_first_read"] = reference _save(root, state) diff --git a/loopx/control_plane/turn_driver/native_first_read.py b/loopx/control_plane/turn_driver/native_first_read.py index 9e805f6f70..1f0c646a5e 100644 --- a/loopx/control_plane/turn_driver/native_first_read.py +++ b/loopx/control_plane/turn_driver/native_first_read.py @@ -1,25 +1,63 @@ """Pin a smaller initial selection to previously observed native tool output.""" from __future__ import annotations +import hashlib import os from pathlib import Path from typing import Any -from .decision_capture import write_initial_context_read_reference +from .decision_capture import read_context_read_reference, write_initial_context_read_reference def observed_first_read_reference(directory: str, target: dict[str, Any], recovery: dict[str, Any], - context: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any] | None: - """Write the typed delivery plan once. The host renders it from the immutable source.""" + context: dict[str, Any], plan: dict[str, Any], + initial_read_plan: dict[str, Any] | None = None) -> dict[str, Any] | None: + """Pin the reduced view and its ordinary fallback from the same capture.""" try: - reference, _ = write_initial_context_read_reference(Path(recovery["path"]), recovery["sha256"], - context, plan["selections"], - observed_read={"directory": directory, "target": target, "fields": plan["reused"]}) + path, digest = Path(recovery["path"]), recovery["sha256"] + observation = {"directory": directory, "target": target, "fields": plan["reused"]} + selections = plan.get("selections") + if initial_read_plan is not None and initial_read_plan.get("initial") is True: + ordinary = initial_read_plan["plan"]["selections"] + if splice := plan.get("selection_splice"): + start, end = splice["start"], splice["end"] + if type(start) is not int or type(end) is not int or not 0 <= start <= end <= len(ordinary): + return None + selections = [*ordinary[:start], *splice["selections"], *ordinary[end:]] + fallback, body = write_initial_context_read_reference(path, digest, context, ordinary) + observation["fallback_reference"] = {"filename": fallback.name, + "selection_sha256": hashlib.sha256(body).hexdigest()} + if selections is None: + return None + reference, _ = write_initial_context_read_reference(path, digest, context, selections, + observed_read=observation) return {"path": str(reference), "pointer_bytes": sum(len(key.encode()) + 4 for key in plan["reused"])} - except (OSError, ValueError): + except (OSError, ValueError, KeyError, TypeError): return None # The ordinary first read remains available. +def observed_first_read_fallback(observation: dict[str, Any], path: Path, digest: str) -> tuple[Path, dict[str, Any]] | None: + """Locate the ordinary pinned selection of this exact capture.""" + fallback = observation.get("fallback_reference") if isinstance(observation, dict) else None + if not isinstance(fallback, dict): + return None + filename = fallback.get("filename") + if not isinstance(filename, str) or not filename.startswith(path.stem + ".") or not filename.endswith(".reader.json"): + return None + try: + reference, metadata = read_context_read_reference(str(path.with_name(filename))) + selection = metadata.get("selection") + source = Path(metadata["path"]).expanduser().absolute() + if (metadata["sha256"] == digest and source.parent.resolve(strict=True) / source.name == path + and "observed_read" not in metadata and isinstance(selection, dict) + and isinstance(fallback.get("selection_sha256"), str) + and selection.get("sha256") == fallback["selection_sha256"]): + return reference, selection + except (OSError, ValueError): + pass + return None + + def observation_current(observation: dict[str, Any], path: Path, digest: str, *, hook_session: str | None = None) -> bool: from .codex_context import _private_root, _state 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/loopx/presentation/goal_source_routing.py b/loopx/presentation/goal_source_routing.py index 179d47d187..55c7d7071b 100644 --- a/loopx/presentation/goal_source_routing.py +++ b/loopx/presentation/goal_source_routing.py @@ -107,6 +107,7 @@ def _todo_detail(self): return self._send_json({"ok": True, "goal_id": goal_id, "todo_id": todo_id, "text": item["text"], "status": item["status"], + "evidence": item.get("evidence"), "note": item.get("note"), "archive_state": item.get("archive_state", "active"), "updated_at": item.get("updated_at"), "source_access": row["source_access"]}) except (ValueError, TypeError, KeyError): diff --git a/tests/control_plane/test_context_first_read.py b/tests/control_plane/test_context_first_read.py index 5a49d8985f..8c409607ec 100644 --- a/tests/control_plane/test_context_first_read.py +++ b/tests/control_plane/test_context_first_read.py @@ -1074,3 +1074,87 @@ def facts(pages): assert facts(restarted)[prefix + "/0"] == outcomes[0] recovered = pages(second["first_read_command"]) assert facts(recovered)[prefix + "/0"] == outcomes[0] + + +@pytest.mark.parametrize("transition", ["unchanged", "corrected", "different_source", "incomplete"]) +def test_native_first_read_goal_narrative_reuse(tmp_path, monkeypatch, decision, transition): + from test_codex_context_lifecycle import deliver, setup + + event = setup(tmp_path, monkeypatch) + narrative = "保留完整目标、独立验收、权限边界和停止条件。\n" * 140 + source = decision["interaction_contract"]["agent_channel"]["work_context"]["sources"][0] + plan = decision["interaction_contract"]["cli_channel"]["settlement_plan"] + prefix = "/interaction_contract/agent_channel/work_context/sources/0/content" + body_pointer = prefix + "/text" + source.update(source="goal_state", ordering="before_work") + + def goal_source(path, text, generated): + def revision(value): + return "sha256:" + hashlib.sha256(value.encode()).hexdigest() + + line = len(text.splitlines()) + 1 + return {"state_file": path, "text": text, "revision": revision(text + generated), + "excluded_todo_regions": [{"role": "agent", "start_line": line, "end_line": line, + "revision": revision(generated)}]} + + def capture(name, content): + source.update(content=content, command=f"cat -- {content['state_file']}") + plan["identity"]["turn_instance_id"] = name + directory = tmp_path / name + directory.mkdir(mode=0o700) + prepared = codex_context.prepare_codex_context(decision, directory=str(tmp_path), + capture_directory=directory, goal_id="fixture", agent_id="agent", + surface="quota_packet", capture_id=name) + return model_context_reference(native_context_capture_view(prepared.delivery, directory, + context_directory=str(tmp_path), initial_read_plan=prepared.initial_read_plan)) + + def read(command): + pages, size, offset = [], 0, 0 + while command: + words = shlex.split(command) + stdout = subprocess.check_output([sys.executable, "-m", "loopx.entrypoint", "--format", "json", + *words[words.index("quota"):]]) + page = json.loads(stdout) + assert page["ok"] and page["offset"] == offset, page + pages.append(page) + size += len(stdout) + offset += len(page["content"].encode()) + command = page.get("next_command") + assert pages[-1]["eof"] and offset == pages[-1]["total_bytes"] + return pages, selected_context_values("".join(page["content"] for page in pages)), size + + first = capture("first", goal_source("goal.md", narrative, "old generated todo\n")) + pages, facts, _ = read(first["first_read_command"]) + assert facts[body_pointer] == narrative and "previously_delivered_fields" not in pages[0] + deliver(tmp_path, event, pages) + assert body_pointer in codex_context._state(tmp_path)["consumed_pointers"] + + text = narrative + "Use the revised owner decision.\n" if transition == "corrected" else narrative + current = goal_source("alternate.md" if transition == "different_source" else "goal.md", + text, "new generated todo\n") + if transition == "incomplete": + current.update(read_complete=False, recovery="Restore the complete Goal source.") + decision["goal_boundary"]["stop_condition"] = "Stop before an external write." + second = capture("second", current) + pages, facts, warm_bytes = read(second["first_read_command"]) + assert facts[prefix + "/revision"] == current["revision"] + assert facts["/goal_boundary"] == decision["goal_boundary"] + assert facts["/interaction_contract/cli_channel/settlement_plan/identity/turn_instance_id"] == "second" + if transition != "unchanged": + assert facts[body_pointer] == text and "previously_delivered_fields" not in pages[0] + if transition == "incomplete": + assert facts[prefix + "/recovery"] == current["recovery"] + return + + assert pages[0]["previously_delivered_fields"] == [body_pointer] and body_pointer not in facts + observed = json.loads(Path(pages[0]["reference_file"]).read_text())["observed_read"] + ordinary = Path(pages[0]["reference_file"]).with_name(observed["fallback_reference"]["filename"]) + recovery = second["recovery"]["command"] + _, restored, _ = read(recovery + " --select " + body_pointer) + assert restored[body_pointer] == narrative + codex_context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PostCompact"}) + fallback, after, fallback_bytes = read(second["first_read_command"]) + assert "previously_delivered_fields" not in fallback[0] and after[body_pointer] == narrative + assert Path(fallback[0]["reference_file"]) == ordinary + assert {key: value for key, value in after.items() if key != body_pointer} == facts + assert fallback_bytes - warm_bytes > 3000 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_periodic_report_loading.py b/tests/control_plane/test_periodic_report_loading.py new file mode 100644 index 0000000000..983d462938 --- /dev/null +++ b/tests/control_plane/test_periodic_report_loading.py @@ -0,0 +1,143 @@ +"""Off-state Turn composition must not load report construction providers.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +_SOURCE = Path(__file__).resolve().parents[2] +_CONSTRUCTION_MODULES = ( + "loopx.capabilities.periodic_report.presets", + # cadence still uses profile's canonical schedule validator, including + # disabled subscriptions. This slice preserves that validation boundary. + "loopx.capabilities.periodic_report.project_progress", + "loopx.capabilities.periodic_report.project_progress_snapshot", + "loopx.capabilities.periodic_report.stage_completion", + "loopx.capabilities.periodic_report.request_action", + "loopx.capabilities.periodic_report.todo_source", +) + + +@pytest.mark.parametrize( + "override,default,warning", + [ + (None, None, None), + ({"enabled": False, "profile_preset": "weekly"}, None, None), + ({"enabled": False}, True, None), + (None, False, None), + ({"enabled": "true"}, None, "enabled must be a boolean"), + ({"enabled": False, "timezone": "unknown/timezone"}, None, "timezone is unknown"), + (None, "true", "enabled must be a boolean"), + ], +) +def test_disabled_turn_composition_keeps_validation_without_construction( + tmp_path: Path, override: object, default: object, warning: str | None +) -> None: + goal: dict[str, object] = {"id": "fixture-goal"} + if override is not None: + goal["control_plane"] = {"periodic_report": override} + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [goal]}), encoding="utf-8") + runtime = tmp_path / "runtime" + if default is not None: + store = runtime / "machine" / "configuration.json" + store.parent.mkdir(parents=True) + store.write_text(json.dumps({ + "schema_version": "loopx_machine_configuration_v0", + "namespaces": {"periodic_report": { + "schema_version": "periodic_report_machine_defaults_v0", + "enabled": default, "profile_preset": "weekly-progress", + "route_ref": "fixture-route", "timezone": "UTC", + }}, + }), encoding="utf-8") + script = f""" +import importlib.abc, os, sys, warnings +from pathlib import Path +from types import SimpleNamespace +sys.dont_write_bytecode = True +class RejectConstruction(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname in {_CONSTRUCTION_MODULES!r}: + raise AssertionError('off-state loaded construction: ' + fullname) +sys.meta_path.insert(0, RejectConstruction()) +def reject_effect(event, args): + assert event not in ('subprocess.Popen', 'os.system', 'os.posix_spawn', 'socket.connect'), event + if event == 'open': + mode, flags = args[1:3] + assert not (isinstance(mode, str) and any(c in mode for c in 'wax+')), args + assert not (isinstance(flags, int) and flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT)), args +sys.addaudithook(reject_effect) +from loopx.cli_commands.turn_post_writeback import drain_committed_turn_post_writeback_hooks +from loopx.capabilities.periodic_report.post_writeback_hook import periodic_report_post_writeback_hooks_for_goal +with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + assert periodic_report_post_writeback_hooks_for_goal( + registry_path=Path({str(registry)!r}), runtime_root=Path({str(runtime)!r}), + goal_id='fixture-goal') == () + assert drain_committed_turn_post_writeback_hooks( + registry_path=Path({str(registry)!r}), runtime_root=Path({str(runtime)!r}), + identity=SimpleNamespace(goal_id='fixture-goal'), goal_ref=None, + available_capabilities=['shell']) == {{}} +expected = {warning!r} +assert len(caught) == (2 if expected else 0), [str(w.message) for w in caught] +assert expected is None or all(expected in str(w.message) for w in caught) +assert not set({_CONSTRUCTION_MODULES!r}) & set(sys.modules) +""" + before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + result = subprocess.run( + [sys.executable, "-c", script], cwd=tmp_path, capture_output=True, text=True, + env={**os.environ, "PYTHONPATH": str(_SOURCE), "LOOPX_USAGE_PING": "0"}, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} == before + + +def test_lazy_report_exports_retain_owner_identity_and_patch_points(tmp_path, monkeypatch): + import importlib + import loopx.capabilities.periodic_report.post_writeback_hook as report + import loopx.cli_commands.turn_post_writeback as turn + + for module in (report, turn): + for name, owner in module._LAZY_EXPORTS.items(): + original = getattr(importlib.import_module(owner), name) + assert getattr(module, name) is original + assert getattr(module, name) is original + assert name in dir(module) + with pytest.raises(AttributeError): + getattr(module, "unknown_report_export") + wildcard = {} + exec("from loopx.cli_commands.turn_post_writeback import *", wildcard) + assert wildcard["read_report_source_history"] is turn.read_report_source_history + assert wildcard["periodic_report_post_writeback_hooks_for_goal"] is report.periodic_report_post_writeback_hooks_for_goal + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": "fixture-goal", "control_plane": { + "periodic_report": {"enabled": True, "profile_preset": "weekly", + "route_ref": "fixture-route"}, + }}]}), encoding="utf-8") + seen = [] + original = report.build_periodic_report_preset_activation + def activation(preset): + seen.append(preset) + return original(preset) + monkeypatch.setattr(report, "build_periodic_report_preset_activation", activation) + hooks = report.periodic_report_post_writeback_hooks_for_goal( + registry_path=registry, runtime_root=tmp_path / "runtime", goal_id="fixture-goal") + assert seen == ["weekly"] + assert len(hooks) == 1 and hooks[0].hook_id == "periodic_report.runtime_trigger" + expected = original("weekly") + assert hooks[0].policy_version == "weekly-" + expected["profile_digest"][-16:] + sentinel = object() + monkeypatch.setattr(turn, "periodic_report_post_writeback_hooks_for_goal", lambda **_: ()) + from types import SimpleNamespace + monkeypatch.setattr(turn, "read_heartbeat_settlement", lambda *a, **k: sentinel) + assert turn.drain_committed_turn_post_writeback_hooks( + registry_path=registry, runtime_root=tmp_path / "runtime", + identity=SimpleNamespace(goal_id="fixture-goal"), goal_ref=None, + available_capabilities=None) == {} diff --git a/tests/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}); +}); diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py index d568779ad5..8a7a6d64da 100644 --- a/tests/presentation/test_linked_goal_sources.py +++ b/tests/presentation/test_linked_goal_sources.py @@ -25,7 +25,8 @@ from loopx.control_plane.status.goal_source import GoalSource -def native_source(w, name='original', task_text='Check the original question against its evidence', done=False): +def native_source(w, name='original', task_text='Check the original question against its evidence', done=False, + task_evidence=None, task_note=None): from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority @@ -44,6 +45,10 @@ def native_source(w, name='original', task_text='Check the original question aga rows, _, _ = parse_todo_source(state.read_text()) for todo in rows['agent']: todo['schema_version'] = 'todo_item_v0' + if task_evidence is not None: + todo['evidence'] = task_evidence + if task_note is not None: + todo['note'] = task_note initialize_canonical_authority(runtime, 'solver-goal', build_todo_runtime_shadow_projection( goal_id='solver-goal', todos=rows['agent'], handoff_mode='soft_claim'), state_path=state, provider='sqlite') for goal in (('research-owner', 'other-owner') if name == 'original' else ()): @@ -78,6 +83,47 @@ def request(path, body=None, origin=None): return raw, request, {path: path.read_bytes() for path in (registry, state, log)} +@pytest.mark.parametrize('done', [False, True]) +def test_linked_task_details_keep_complete_evidence_and_notes(workspace, done): + evidence = 'Independent verification. ' + '证据🙂 ' * 180 + ' Retained baseline: reference-v2.' + note = 'Conditions and limitations. ' + '完整备注 ' * 180 + ' Attribution remains unknown.' + raw, request, before = native_source(workspace, done=done, task_evidence=evidence, task_note=note) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + namespace = GoalSource.parse(raw).namespace + query = {'goal_id': 'solver-goal', 'todo_id': 'todo_original', 'source_namespace': namespace} + endpoint = '/api/chat/todo/detail?' + detail = request(endpoint + urlencode(query)) + assert detail['evidence'] == evidence and detail['note'] == note + assert detail['status'] == ('done' if done else 'open') + assert detail['source_access']['namespace'] == namespace + assert 'review_basis' not in detail + + for changed in ({'source_namespace': 'f' * 64}, {'todo_id': 'todo_missing'}): + with pytest.raises(HTTPError) as refused: + request(endpoint + urlencode({**query, **changed})) + error = json.load(refused.value) + assert not error['ok'] and not {'text', 'evidence', 'note'} & error.keys() + + source_registry = Path(raw['registry']) + source_registry.unlink() + with pytest.raises(HTTPError) as unavailable: + request(endpoint + urlencode(query)) + assert unavailable.value.code == 503 + assert 'Retained baseline' not in unavailable.value.read().decode() + source_registry.write_bytes(before[source_registry]) + assert request(endpoint + urlencode(query))['evidence'] == evidence + assert all(path.read_bytes() == data for path, data in before.items()) + assert not workspace['count'].exists() + + +def test_linked_task_details_preserve_absent_metadata(workspace): + raw, request, _ = native_source(workspace) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + detail = request('/api/chat/todo/detail?' + urlencode({ + 'goal_id': 'solver-goal', 'todo_id': 'todo_original', 'source_namespace': GoalSource.parse(raw).namespace})) + assert detail['evidence'] is None and detail['note'] is None + + @pytest.mark.parametrize("separate_chat_store", [False, True]) def test_app_parent_directory_reads_original_tasks_history_and_explicit_analysis(workspace, separate_chat_store): w = workspace diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index d76b1ae942..e23b5dd6ff 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -38,6 +38,9 @@ from loopx.control_plane.quota.turn_envelope import turn_host_lineage from loopx.control_plane.collaboration.inbox import acknowledge from loopx.control_plane.collaboration.peers import return_result +# The generic Host protocol sends UTF-8 over a pipe, whose Python 3.11 +# Windows default is a locale codec rather than the console's UTF-8 mode. +sys.stdin.reconfigure(encoding='utf-8') request = json.load(sys.stdin) workspace = Path.cwd() root = Path(sys.argv[1]) @@ -98,6 +101,35 @@ def brief(): "return_requirement": "Return the independently checked artifact"} +def test_fixture_host_revalidates_utf8_input_under_a_non_utf8_pipe(service): + """The fixture must reach task validation with the original UTF-8 identity.""" + root, runner = service + host = root / "fixture-host.py" + # Model the Windows Python 3.11 pipe default without changing the parent + # transport or requiring a platform-dependent locale on the test runner. + host.write_text("import sys; sys.stdin.reconfigure(encoding='cp1252')\n" + + host.read_text(encoding="utf-8"), encoding="utf-8") + output = root / "analyst/initial/output.json" + saved = output.read_bytes() + output.write_text("{}", encoding="utf-8") + request = {**brief(), "context": "Read the synthetic résumé: 报告 € → evidence."} + runner.start("analysis", "analysis-1", request) + rejected = wait(runner) + assert rejected["status"] == "rejected", rejected + assert "task_failure" in rejected, rejected + assert rejected["task_failure"]["resume_mode"] == "revalidate_cached_result" + turn_key = rejected["task_failure"]["turn_key"] + assert (root / "analyst/initial/host-invocations").read_text() == "1" + + output.write_bytes(saved) + runner.revalidate("analysis-1") + accepted = wait(runner) + assert accepted["status"] == "accepted", accepted + assert "task_failure" not in accepted + assert json.loads(runner.path("analysis-1").read_text())["turn_key"] == turn_key + assert (root / "analyst/initial/host-invocations").read_text() == "1" + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_delegation_fixture_isolates_cached_and_child_runtime_routes(tmp_path, monkeypatch, provider): """A warmed parent must use the same private Effect server as its CLI."""