Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/architecture/rfcs/monorepo-distribution-split-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -2567,6 +2567,65 @@ 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.

## Appendix B: Decision log

| Date | Decision | Owner / approval | Alternatives | Normative sections changed |
Expand Down
49 changes: 49 additions & 0 deletions docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1993,6 +1993,55 @@ 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 的完整验收继续开放。

## 附录 B:决策日志

| 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 |
Expand Down
39 changes: 35 additions & 4 deletions loopx/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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_.-]*$")


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
Expand All @@ -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
Expand Down Expand Up @@ -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)
90 changes: 90 additions & 0 deletions tests/control_plane/test_feedback_adapter_loading.py
Original file line number Diff line number Diff line change
@@ -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
Loading