Skip to content

Commit 2510aeb

Browse files
authored
Merge pull request #6222 from loopx-project/codex/c1-lease-capture-recovery-20261011
2 parents ae1f237 + 953327a commit 2510aeb

7 files changed

Lines changed: 133 additions & 6 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ is disabled. This removes optional capture work from ordinary legacy writes,
7777
without changing their output, history retention or outbox recovery. Python
7878
retains this Host IO adapter until its last supported source writer is replaced;
7979
this is neither whole-writer retirement nor default-provider qualification.
80+
Unpromoted lease acquire now loads the capture adapter only when the typed
81+
result carries a capture request. The real CLI and normal-wheel oracle cover
82+
acquire/replay, rejected stale renewal, renewal, transfer and release with the
83+
four legacy shadow producer modules physically absent and with no enabled
84+
capture or active binding. An active binding still delivers capture after the
85+
setting is removed. This retires a default-off dependency, not the supported
86+
source writer or pending capture/history recovery.
8087

8188
Handoff/continuation display decisions now belong to the existing TypeScript Todo
8289
context owner. Summary reuses its normalized identity/claim/exclusion/class

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ normalization crossing,不删除仍受支持的 writer 或迁移 reader。
6565
省去可选捕获工作,输出、历史保留及 outbox 恢复保持原契约。Python 保留这层
6666
Host IO 适配,直到最后受支持的源 writer 被替换;本批不代表整个 writer 退役或
6767
默认 provider 资格通过。
68+
未迁移 lease acquire 现在仅在 typed 结果带有 capture 请求时加载 capture 适配器。
69+
真实 CLI 与正常 wheel 检查覆盖领取/重放、陈旧续约拒绝、续约、转交及释放:没有
70+
启用 capture 或活跃 binding 时,可物理移除四个旧 shadow producer 模块。已有活跃
71+
binding 在移除配置后仍完成 capture。这只退役默认关闭时的依赖,不删除仍受支持的
72+
源 writer,也不结束待处理 capture/历史恢复窗口。
6873

6974
handoff/continuation 显示决策已归入既有 TypeScript Todo context owner。summary
7075
在同一个 batch 复用已归一化的身份、claim、exclusion 和任务类型事实;index 在单个

‎docs/reference/canonical-lease-renew.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,3 +570,29 @@ L3/R5 的独立领取/接管及维护由此可用,跨外部 effect 的 executo
570570
锁和自动结果返回仍需各自验收。D2 soak、D3、默认 profile、活动 Goal 迁移和跨主机
571571
部署没有改变。回滚保留 canonical state/receipt/fence 并恢复兼容代码,不得复活旧
572572
lease 文件。演练仅修改隔离副本,核对源与无关记录不变,不能代替完整持久性资格。
573+
574+
## Unpromoted lease capture isolation / 未迁移租约的 capture 隔离
575+
576+
For an unpromoted Goal with no enabled runtime shadow and no active durable
577+
binding, lease acquire, replay, renew, transfer and release do not require the
578+
legacy shadow producer modules. Python loads capture settlement only when the
579+
existing typed lease result carries a capture request. The normal CLI keeps
580+
the original source and registry unchanged, creates no shadow outbox, and
581+
preserves lease versions, generations, scopes and idempotent receipts. A stale
582+
renewal still fails without changing the stored lease.
583+
584+
Removing a setting does not retire an active binding: enabled capture and
585+
active-binding recovery retain durable preparation and delivery. This removes
586+
an unused default-off dependency; it does not change provider defaults,
587+
promote a Goal, retire supported Markdown writers, or authorize deleting
588+
capture history and pending outbox records.
589+
590+
未迁移 Goal 没有启用 runtime shadow、也没有活跃持久 binding 时,领取、重放、
591+
续约、转交和释放不依赖旧 shadow producer 模块。Python 仅在既有 typed 租约结果
592+
带有 capture 请求时加载结算适配器。正常 CLI 保持源和 registry 不变,不产生
593+
shadow outbox,并保留租约版本、generation、scope 及幂等回执;陈旧续约仍须拒绝,
594+
且不修改已存租约。
595+
596+
移除配置不会退役活跃 binding:启用 capture 及已有 binding 的恢复仍须持久准备
597+
并送达。本项只移除默认关闭时无用途的依赖,不改变 provider 默认值、不迁移 Goal、
598+
不删除受支持的 Markdown writer,也不授予删除 capture 历史和待处理 outbox 的权限。

‎loopx/control_plane/work_items/task_lease_acquire_adapter.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,14 +333,17 @@ def _finalize_native_acquire_result(
333333
registry_path: Path,
334334
runtime_root: Path,
335335
goal_id: str,
336-
todo_id: str,
337336
legacy_provider_projection: bool,
338337
) -> dict[str, Any]:
339338
result = dict(payload)
340339
if legacy_provider_projection and result.get("ok") is True:
341340
result["handoff_mode"] = authority.get("handoff_mode") or HANDOFF_MODE_LEGACY
342341
result.pop("settlement", None)
343-
if result.get("ok") is True and result.get("acquired") is True:
342+
if (
343+
result.get("ok") is True
344+
and result.get("acquired") is True
345+
and "coordination_runtime_shadow_capture" in result
346+
):
344347
from ..coordination.runtime_shadow_writer_adapter import (
345348
settle_lease_runtime_shadow_capture,
346349
)
@@ -432,7 +435,6 @@ def execute_native_task_lease_acquire(
432435
registry_path=registry_path,
433436
runtime_root=runtime_root,
434437
goal_id=str(goal_id),
435-
todo_id=str(todo_id),
436438
legacy_provider_projection=_legacy_provider_projection,
437439
)
438440
raise RuntimeError("native task-lease acquire exhausted source-CAS retries")

‎loopx/semantics/project_registry_io_manifest_v1.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,15 +1799,15 @@
17991799
},
18001800
{
18011801
"site": "loopx/control_plane/work_items/task_lease_acquire_adapter.py::<module>.execute_native_task_lease_acquire::codec_read:load_registry#1",
1802-
"line": 403,
1802+
"line": 406,
18031803
"column": 20,
18041804
"kind": "codec_read",
18051805
"api": "load_registry",
18061806
"classification": "codec_api"
18071807
},
18081808
{
18091809
"site": "loopx/control_plane/work_items/task_lease_acquire_adapter.py::<module>.execute_native_task_lease_lifecycle::codec_read:load_registry#1",
1810-
"line": 718,
1810+
"line": 720,
18111811
"column": 24,
18121812
"kind": "codec_read",
18131813
"api": "load_registry",

‎tests/control_plane/test_cold_source_import_cli.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,84 @@ def cli(action, *args, success=True, output_format="json", runtime_override=None
7979
return cli, state, backup, body, runtime, receiver, env
8080

8181

82+
@pytest.mark.parametrize("old_producers_present", [True, False])
83+
@pytest.mark.parametrize("configuration", ["absent", "disabled", "retired"])
84+
def test_unpromoted_default_off_lease_lifecycle_without_shadow_producers(
85+
tmp_path, monkeypatch, old_producers_present, configuration,
86+
):
87+
_, state, _, _, runtime, receiver, env = workspace(tmp_path, monkeypatch)
88+
registry = tmp_path / "project/.loopx/registry.json"
89+
data = json.loads(registry.read_text())
90+
coordination = data["goals"][0]["coordination"]
91+
coordination["registered_agents"] = ["agent-a", "agent-b"]
92+
if configuration == "disabled":
93+
coordination["runtime_shadow"] = {
94+
"schema_version": "loopx_coordination_runtime_shadow_config_v0",
95+
"enabled": False, "provider": "file_v0",
96+
}
97+
elif configuration == "retired":
98+
coordination["authority_shadow"] = {
99+
"schema_version": "loopx_local_authority_shadow_config_v0",
100+
"mode": "file_one_way",
101+
}
102+
registry.write_text(json.dumps(data))
103+
state.write_text(state.read_text().replace("handoff_mode: legacy", "handoff_mode: hard_lease")
104+
.replace(" claimed_by=agent-a", ""))
105+
original_state, original_registry = state.read_bytes(), registry.read_bytes()
106+
if not old_producers_present:
107+
for name in ("runtime_shadow.py", "runtime_shadow_writer_adapter.py",
108+
"local_authority_shadow_adapter.py", "local_authority_shadow_outbox.py"):
109+
(receiver / "loopx/control_plane/coordination" / name).unlink()
110+
111+
def lease(action, *args, owner="agent-a", key="original", success=True):
112+
child = subprocess.run([sys.executable, "-c",
113+
"import loopx,runpy,sys; print(loopx.__file__,file=sys.stderr); runpy.run_module('loopx.cli',run_name='__main__')",
114+
"--registry", str(registry), "--runtime-root", str(runtime), "--format", "json",
115+
"task-lease", action, "--goal-id", "cold", "--todo-id", "todo_current",
116+
"--owner", owner, "--idempotency-key", key, *map(str, args)],
117+
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=60)
118+
assert str(receiver / "loopx/__init__.py") in child.stderr
119+
assert "Traceback" not in child.stderr, child.stderr
120+
assert (child.returncode == 0) is success, child.stdout + child.stderr
121+
result = json.loads(child.stdout)
122+
assert "coordination_runtime_shadow_capture" not in result
123+
assert "coordination_runtime_shadow" not in result
124+
assert state.read_bytes() == original_state
125+
assert registry.read_bytes() == original_registry
126+
assert not (runtime / "authority-shadow").exists()
127+
assert not any(path.is_file() for path in (runtime / "authority-transition").rglob("*"))
128+
return result
129+
130+
acquired = lease("acquire", "--ttl-seconds", 120, "--write-scope", "docs/**")
131+
assert acquired["acquired"] is True
132+
version = acquired["lease"]["version"]
133+
path = Path(acquired["lease_path"])
134+
original_lease = path.read_bytes()
135+
replay = lease("acquire", "--ttl-seconds", 120, "--write-scope", "docs/**")
136+
assert replay["idempotent"] is True and replay["lease"] == acquired["lease"]
137+
assert path.read_bytes() == original_lease
138+
rejected = lease("renew", "--expected-version", version + 1, "--ttl-seconds", 180, success=False)
139+
assert rejected["error_code"] == "version_mismatch"
140+
assert path.read_bytes() == original_lease
141+
renewed = lease("renew", "--expected-version", version, "--ttl-seconds", 180)
142+
assert renewed["lease"]["version"] == version + 1
143+
assert renewed["lease"]["lease_epoch"] == acquired["lease"]["lease_epoch"]
144+
transferred = lease("transfer", "--expected-version", version + 1,
145+
"--new-owner", "agent-b", "--new-idempotency-key", "receiver",
146+
"--ttl-seconds", 180)
147+
assert transferred["lease"]["owner"] == "agent-b"
148+
assert transferred["lease"]["version"] == version + 2
149+
assert transferred["lease"]["lease_epoch"] == acquired["lease"]["lease_epoch"] + 1
150+
released = lease("release", "--expected-version", version + 2, owner="agent-b", key="receiver")
151+
assert released["released"] is True and released["lease"]["status"] == "released"
152+
assert released["lease"]["write_scopes"] == ["docs/**"]
153+
assert released["lease"]["lease_epoch"] == transferred["lease"]["lease_epoch"]
154+
retained = path.read_bytes()
155+
assert lease("release", "--expected-version", version + 2,
156+
owner="agent-b", key="receiver")["idempotent"] is True
157+
assert path.read_bytes() == retained
158+
159+
82160
def test_coordination_command_help_and_rejections_match_full_cli(tmp_path):
83161
# Independently compare the existing compatibility parser/handler with the
84162
# selected dispatcher, including diagnostics that deliberately fall back.

‎tests/control_plane/test_runtime_shadow_writer_capture.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import sys
66
from pathlib import Path
77

8+
import pytest
9+
810
from loopx.control_plane.coordination import local_authority_shadow_adapter as adapter
911
from loopx.control_plane.work_items.task_lease import (
1012
acquire_task_lease,
@@ -161,7 +163,10 @@ def test_runtime_shadow_todo_writer_is_zero_effect_by_default(tmp_path: Path) ->
161163
assert not (runtime_root / "authority-shadow").exists()
162164

163165

164-
def test_runtime_shadow_native_lease_writers_capture_complete_records(tmp_path: Path) -> None:
166+
@pytest.mark.parametrize("configuration_cleared", [False, True])
167+
def test_runtime_shadow_native_lease_writers_capture_complete_records(
168+
tmp_path: Path, configuration_cleared: bool,
169+
) -> None:
165170
registry, _state, runtime_root = _fixture(tmp_path, enabled=True)
166171
added = add_goal_todo(
167172
registry_path=registry,
@@ -171,6 +176,10 @@ def test_runtime_shadow_native_lease_writers_capture_complete_records(tmp_path:
171176
task_class="advancement_task",
172177
)
173178
todo_id = str(added["todo_id"])
179+
if configuration_cleared:
180+
config = json.loads(registry.read_text(encoding="utf-8"))
181+
config["goals"][0]["coordination"].pop("runtime_shadow")
182+
registry.write_text(json.dumps(config), encoding="utf-8")
174183

175184
acquired = acquire_task_lease(
176185
registry_path=registry,

0 commit comments

Comments
 (0)