Skip to content

Commit 023c6f3

Browse files
committed
refactor(lease): read source todos without the mutation facade
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent 43173c0 commit 023c6f3

5 files changed

Lines changed: 205 additions & 7 deletions

File tree

‎docs/architecture/rfcs/typescript-control-plane-migration-v0.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,6 +1515,15 @@ Python writer retirement remain separate exits.
15151515
change, and provider outage without a Markdown fallback. Unpromoted writers,
15161516
lease capture recovery and the legacy migration window remain separate
15171517
retirement obligations; this closes the canonical setter's import dependency.
1518+
- Source task-lease facts now use `todos/list_readback.py` directly instead of
1519+
the historic mutation facade. TypeScript retains acquire/lifecycle decisions;
1520+
Markdown IO, source capture and public compatibility exports remain live.
1521+
The CLI distribution counterfactual removes the facade and disables editable
1522+
install finders, which could otherwise borrow deleted modules from a checkout.
1523+
Source and installed-wheel coverage includes capture off/on, all three handoff
1524+
modes, replay, ownership/version rejection, transfer, release and retained
1525+
history. This removes a reader-to-writer dependency; full writer deletion still
1526+
needs its remaining callers and migration window qualified.
15181527

15191528
**Validation and stop rules for every card**
15201529

‎docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,13 @@ acceptance 和 vision。展示限制不能变成结算限制。在 legacy、File
10411041
File/SQLite CLI 验证覆盖预览、写入、后续模式变化后的原操作回放及 provider
10421042
故障时禁止 Markdown 回退。本批闭合 canonical setter 的 import 依赖;未晋升
10431043
writer、lease capture 恢复及旧源迁移窗口仍保留独立退役验收。
1044+
- 旧源 task-lease facts 直接复用 `todos/list_readback.py`,移除对历史 mutation
1045+
facade 的依赖。领取及生命周期决策仍由 TypeScript 拥有;Markdown IO、source
1046+
capture 与公开兼容 export 仍有实际 caller。CLI 分发反例移除 facade 并禁用
1047+
editable install finder,防止从 checkout 偷读已删除模块。源码与安装 wheel
1048+
覆盖 capture 关闭/开启、三种 handoff 模式、回放、owner/version 拒绝、转交、
1049+
释放及保留历史。本批闭合 reader 到 writer facade 的依赖;完整 writer 删除
1050+
仍须验收剩余 caller 与迁移窗口。
10441051

10451052
**每张执行卡的验证与停止规则**
10461053

‎loopx/control_plane/work_items/task_lease_acquire_adapter.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def _task_lease_authority_projection(
165165
state_file: Path | None,
166166
project_todos: bool = True,
167167
) -> tuple[Any, list[Any], list[dict[str, Any]], dict[str, Any] | None]:
168-
from ...todos import list_goal_todos
168+
from ..todos.list_readback import list_goal_todos
169169
from ..goals.active_state_metadata import parse_state_frontmatter
170170

171171
if goal is None:

‎tests/control_plane/test_canonical_todo_writer_isolation.py‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import shutil
1010
import subprocess
1111
import sys
12+
import sysconfig
1213

1314
import pytest
1415

@@ -46,7 +47,7 @@ def isolated_todo_distribution(tmp_path, monkeypatch, request):
4647
isolate_sqlite_runtime(tmp_path, monkeypatch)
4748
package_root = tmp_path / "package"
4849
package = package_root / "loopx"
49-
command = [sys.executable, "-m", "loopx.cli"]
50+
command = [sys.executable, "-S", "-m", "loopx.cli"]
5051
if request.param == "source":
5152
shutil.copytree(Path(__file__).resolve().parents[2] / "loopx", package,
5253
ignore=shutil.ignore_patterns("__pycache__"))
@@ -59,10 +60,13 @@ def isolated_todo_distribution(tmp_path, monkeypatch, request):
5960
assert installed.returncode == 0, installed.stdout + installed.stderr
6061
entry = package_root / "bin/loopx"
6162
assert entry.is_file()
62-
command = [sys.executable, str(entry)]
63-
monkeypatch.setenv("PYTHONPATH", str(package_root))
63+
command = [sys.executable, "-S", str(entry)]
64+
# Keep dependencies available without processing editable-install .pth files.
65+
# Their import finder can silently borrow a deleted module from the checkout.
66+
dependencies = dict.fromkeys(sysconfig.get_paths()[key] for key in ("purelib", "platlib"))
67+
monkeypatch.setenv("PYTHONPATH", os.pathsep.join([str(package_root), *dependencies]))
6468
provenance = json.loads(subprocess.check_output(
65-
[sys.executable, "-c", "import json,loopx; from importlib import metadata; "
69+
[sys.executable, "-S", "-c", "import json,loopx; from importlib import metadata; "
6670
"print(json.dumps({'module':loopx.__file__, "
6771
"'distribution':str(metadata.distribution('loopx').locate_file(''))}))"],
6872
cwd=tmp_path, text=True,
@@ -122,7 +126,7 @@ def test_pending_source_outbox_survives_producer_retirement(
122126

123127
def history():
124128
result = subprocess.run(
125-
[sys.executable, "-c",
129+
[sys.executable, "-S", "-c",
126130
"import json,sys;from pathlib import Path;"
127131
"from loopx.control_plane.coordination.local_authority_shadow_adapter "
128132
"import read_local_authority_shadow;"
@@ -253,7 +257,7 @@ def test_handoff_mode_without_source_todo_writers(tmp_path, provider, without_so
253257

254258
def cli(*args, succeeds=True, override=False):
255259
result = subprocess.run(
256-
[sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(registry),
260+
[*without_source_todo_writers, "--format", "json", "--registry", str(registry),
257261
*(["--runtime-root", str(runtime)] if override else []), *args, "--goal-id", "goal-a"],
258262
cwd=caller, capture_output=True, text=True, timeout=45,
259263
)
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Supported source leases retain readback without the historic mutation facade."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import subprocess
7+
import sys
8+
9+
import pytest
10+
11+
from shadow_e2e_fixture import workspace
12+
from test_canonical_todo_writer_isolation import (
13+
isolated_todo_distribution, # noqa: F401 -- distribution fixture
14+
retirement_wheel, # noqa: F401 -- distribution fixtures
15+
)
16+
17+
18+
@pytest.mark.parametrize("facade", ["present", "absent"])
19+
@pytest.mark.parametrize("capture", [False, True], ids=["capture-off", "capture-on"])
20+
@pytest.mark.parametrize("mode", ["legacy", "hard_lease", "soft_claim"])
21+
def test_source_lease_lifecycle_uses_retained_reader(
22+
tmp_path,
23+
isolated_todo_distribution, # noqa: F811
24+
facade,
25+
capture,
26+
mode, # noqa: F811
27+
):
28+
package, command = isolated_todo_distribution
29+
w = workspace(tmp_path / "source", bootstrap=False)
30+
w.command, w.cwd, w.package = tuple(command), tmp_path, package
31+
w.state.write_text(
32+
w.state.read_text().replace("handoff_mode: hard_lease", f"handoff_mode: {mode}")
33+
+ "- [ ] [P1] Preserve source work — 完整内容.\n"
34+
" <!-- loopx:todo todo_id=todo_source status=open task_class=advancement_task -->\n"
35+
)
36+
config = json.loads(w.registry.read_text())
37+
config["goals"][0]["coordination"]["runtime_shadow"]["enabled"] = capture
38+
w.registry.write_text(json.dumps(config))
39+
if capture:
40+
assert (
41+
w.cli("coordination-shadow", "bootstrap", "--execute")["bootstrap"][
42+
"status"
43+
]
44+
== "applied"
45+
)
46+
if facade == "absent":
47+
(package / "todos.py").unlink()
48+
subprocess.run(
49+
[
50+
sys.executable,
51+
"-S",
52+
"-c",
53+
"from importlib.util import find_spec;assert find_spec('loopx.todos') is None",
54+
],
55+
cwd=tmp_path,
56+
check=True,
57+
capture_output=True,
58+
text=True,
59+
timeout=30,
60+
)
61+
original = w.state.read_bytes(), w.registry.read_bytes()
62+
63+
def lease(action, *args, success=True):
64+
return w.cli(
65+
"task-lease", action, "--todo-id", "todo_source", *args, success=success
66+
)
67+
68+
first = lease(
69+
"acquire",
70+
"--owner",
71+
"agent-a",
72+
"--idempotency-key",
73+
"source-a",
74+
"--ttl-seconds",
75+
"600",
76+
success=mode != "soft_claim",
77+
)
78+
if mode == "soft_claim":
79+
assert not first["ok"] and first["error_code"] == "handoff_mode_forbids_lease"
80+
assert lease("inspect")["lease"] is None
81+
assert (w.state.read_bytes(), w.registry.read_bytes()) == original
82+
return
83+
84+
assert first["ok"] and first["acquired"] and first["lease"]["owner"] == "agent-a"
85+
assert first["lease"]["version"] == first["lease"]["lease_epoch"] == 1
86+
assert lease("inspect")["active"] is True
87+
retry = lease(
88+
"acquire",
89+
"--owner",
90+
"agent-a",
91+
"--idempotency-key",
92+
"source-a",
93+
"--ttl-seconds",
94+
"600",
95+
)
96+
assert retry["idempotent"] and retry["lease"] == first["lease"]
97+
denied = lease(
98+
"acquire", "--owner", "agent-b", "--idempotency-key", "other", success=False
99+
)
100+
assert not denied["ok"] and denied["error_code"] == "todo_lease_conflict"
101+
renewed = lease(
102+
"renew",
103+
"--owner",
104+
"agent-a",
105+
"--idempotency-key",
106+
"source-a",
107+
"--expected-version",
108+
"1",
109+
)
110+
assert renewed["lease"]["version"] == 2 and renewed["lease"]["lease_epoch"] == 1
111+
# The exact original renewal is receipt recovery, not a stale new write.
112+
replay = lease(
113+
"renew",
114+
"--owner",
115+
"agent-a",
116+
"--idempotency-key",
117+
"source-a",
118+
"--expected-version",
119+
"1",
120+
)
121+
assert replay["idempotent"] and replay["lease"] == renewed["lease"]
122+
assert lease("inspect")["lease"] == renewed["lease"]
123+
transferred = lease(
124+
"transfer",
125+
"--owner",
126+
"agent-a",
127+
"--idempotency-key",
128+
"source-a",
129+
"--expected-version",
130+
"2",
131+
"--new-owner",
132+
"agent-b",
133+
"--new-idempotency-key",
134+
"source-b",
135+
)
136+
assert (
137+
transferred["lease"]["version"] == 3
138+
and transferred["lease"]["lease_epoch"] == 2
139+
)
140+
assert transferred["lease"]["owner"] == "agent-b"
141+
stale = lease(
142+
"renew",
143+
"--owner",
144+
"agent-b",
145+
"--idempotency-key",
146+
"source-b",
147+
"--expected-version",
148+
"2",
149+
success=False,
150+
)
151+
assert not stale["ok"] and stale["error_code"] == "version_mismatch"
152+
assert lease("inspect")["lease"] == transferred["lease"]
153+
released = lease(
154+
"release",
155+
"--owner",
156+
"agent-b",
157+
"--idempotency-key",
158+
"source-b",
159+
"--expected-version",
160+
"3",
161+
)
162+
assert released["lease"]["status"] == "released"
163+
retained = lease("inspect")
164+
assert not retained["active"] and retained["lease"] == released["lease"]
165+
assert (w.state.read_bytes(), w.registry.read_bytes()) == original
166+
if capture:
167+
# Read the retained journal after the lifecycle; no capture remains pending.
168+
history = json.loads(subprocess.check_output(
169+
[sys.executable, "-S", "-c",
170+
"import json,sys;from pathlib import Path;"
171+
"from loopx.control_plane.coordination.local_authority_shadow_history "
172+
"import outbox_summary;"
173+
"print(json.dumps(outbox_summary(Path(sys.argv[1]),sys.argv[2])))",
174+
str(w.runtime), w.goal],
175+
cwd=tmp_path, text=True, timeout=30,
176+
))
177+
assert history["leases"]["prepared_only"] == 0
178+
assert history["leases"]["committed_pending"] == 0

0 commit comments

Comments
 (0)