Skip to content

Commit 75034bf

Browse files
authored
fix(context): publish current recovery before optional planning (#7628)
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent ee7b65f commit 75034bf

3 files changed

Lines changed: 93 additions & 15 deletions

File tree

‎docs/reference/required-work-context.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,11 @@ as a substitute. Both paths establish a new chain only after a complete verified
519519
full read. Ordinary tools and turn endings do not invalidate it. An interrupted
520520
pending delivery requires full recovery. Repeated reset events are idempotent.
521521
Recovery reads the same immutable capture without re-admission or spend.
522+
The adapter commits the current complete capture reference and pending delivery
523+
under the existing session lock before optional material planning or encoding.
524+
A process exit during planning therefore recovers the current source rather
525+
than its predecessor. The planner reuses that archive; an unread current source
526+
still cannot establish a full native baseline.
522527

523528
This deliberately replaces per-request complete-input proof with lifecycle
524529
assumptions. Downstream output loss or truncation not exposed to the hook, a
@@ -758,6 +763,9 @@ code-mode 父调用的保留输出须包含嵌套 reader hook 观察到的全部
758763
原捕获,不能用新 guard 替代。两条路径都只在完整且已校验的全量读取后建立新差分链。
759764
普通工具和正常轮次结束不无条件失效。尚未完成的交付被中断
760765
后须完整恢复。重复重置事件幂等;恢复读取同一不可变捕获,不重新准入或扣额。
766+
Adapter 在可选材料规划或编码前,先持既有 session 锁提交当前完整 capture 引用及
767+
pending delivery。规划过程中进程退出后,恢复指向当前来源而非上一份;planner
768+
复用同一 archive。尚未读取的当前来源仍不能建立完整 native baseline。
761769

762770
这是以生命周期假设替换逐轮完整输入证明。Hook 未暴露的下游丢失/截断、漏掉失效事件,
763771
或在声明开关之外静默禁用 hooks,仍可能漏检;不把这些假设称为实测保留,也不新增审批门。

‎loopx/control_plane/turn_driver/codex_context.py‎

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -470,8 +470,9 @@ def register_native_context_view(directory: str | None, view: dict[str, Any]) ->
470470
_save(root, state)
471471

472472

473-
def _capture_required_context(payload: dict[str, Any], *, directory: Path,
474-
surface: str) -> tuple[dict[str, Any], dict[str, Any]]:
473+
def _capture_required_context(
474+
payload: dict[str, Any], *, directory: Path, surface: str, archive: dict[str, Any] | None = None,
475+
) -> tuple[dict[str, Any], dict[str, Any]]:
475476
"""Publish the archive first, then the model view selected by the typed owner.
476477
477478
Only the required view may become a full/delta baseline. Detail pointers
@@ -480,8 +481,9 @@ def _capture_required_context(payload: dict[str, Any], *, directory: Path,
480481
from ..quota.turn_envelope import plan_progressive_context
481482
from ..effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError
482483

483-
archive = capture_context_projection(payload, directory=directory,
484-
read_guidance=CONTEXT_CAPTURE_PAGE_GUIDANCE)
484+
if archive is None:
485+
archive = capture_context_projection(payload, directory=directory,
486+
read_guidance=CONTEXT_CAPTURE_PAGE_GUIDANCE)
485487
try:
486488
plan = plan_progressive_context(payload, surface=surface)
487489
except (EffectRuntimeRemoteError, EffectRuntimeStartupError):
@@ -569,19 +571,29 @@ def project_codex_context(payload: dict[str, Any], *, directory: str | None, cap
569571
if continuity_generation is not None:
570572
scope["continuity_generation"] = continuity_generation
571573
request = _native_context_request(state, scope)
572-
required, recovery = _capture_required_context(payload, directory=capture_directory, surface=surface)
574+
recovery = capture_context_projection(payload, directory=capture_directory,
575+
read_guidance=CONTEXT_CAPTURE_PAGE_GUIDANCE)
573576
page_bytes, output_bytes = native_context_page_budget()
574-
recovery["command"] = context_capture_read_command(Path(recovery["path"]), recovery["sha256"],
575-
page_bytes=page_bytes, max_page_output_bytes=output_bytes)
576-
state["recovery"] = {**{key: recovery[key] for key in ("path", "sha256", "command")},
577-
"goal_id": goal_id, "agent_id": agent_id,
578-
"total_bytes": Path(recovery["path"]).stat().st_size}
579-
# Block reuse before optional wire IO, not after a first page
580-
# or EOF. A codec/read interruption keeps this incomplete.
581-
state["pending"] = {"path": recovery["path"], "sha256": recovery["sha256"],
582-
"total_bytes": state["recovery"]["total_bytes"], "ranges": {}}
583-
_save(root, state)
577+
578+
def publish_recovery(reference: dict[str, Any]) -> None:
579+
reference["command"] = context_capture_read_command(Path(reference["path"]), reference["sha256"],
580+
page_bytes=page_bytes, max_page_output_bytes=output_bytes)
581+
state["recovery"] = {**{key: reference[key] for key in ("path", "sha256", "command")},
582+
"goal_id": goal_id, "agent_id": agent_id,
583+
"total_bytes": Path(reference["path"]).stat().st_size}
584+
state["pending"] = {"path": reference["path"], "sha256": reference["sha256"],
585+
"total_bytes": state["recovery"]["total_bytes"], "ranges": {}}
586+
_save(root, state)
587+
588+
# Publish the current complete source before optional planning
589+
# or encoding can be interrupted. Never recover its predecessor.
590+
publish_recovery(recovery)
584591
recovery_committed = True
592+
required, planned_recovery = _capture_required_context(payload, directory=capture_directory,
593+
surface=surface, archive=recovery)
594+
if planned_recovery is not recovery:
595+
recovery = planned_recovery
596+
publish_recovery(recovery)
585597

586598
result = project_captured_context(required, directory=capture_directory,
587599
request=request, captured_recovery=recovery,

‎tests/control_plane/test_saved_context_project.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,64 @@ def fail_optional_wire(path, payload, **kwargs):
245245
assert json.loads(recovered) == original and path.read_bytes() == raw
246246

247247

248+
@pytest.mark.parametrize("source", ["compact", "resume"])
249+
def test_saved_projection_planner_process_exit_keeps_current_recovery(tmp_path, monkeypatch, source):
250+
import subprocess
251+
from loopx.control_plane.turn_driver import codex_context
252+
from tests.control_plane.test_codex_context_lifecycle import setup, project, deliver, read_pages
253+
254+
native, captures = tmp_path / "native", tmp_path / "captures"
255+
native.mkdir(mode=0o700)
256+
captures.mkdir(mode=0o700)
257+
event = setup(native, monkeypatch)
258+
prior = {"ok": True, "goal_id": "fixture", "agent_identity": {"agent_id": "agent"},
259+
"heartbeat_receipt": {"turn_instance_id": "prior-turn"}, "requirement": "Stop before publication."}
260+
_, delivered = project(native, payload=prior)
261+
deliver(native, event, read_pages(native, delivered))
262+
assert codex_context._state(native)["candidate"]
263+
prior_file = Path(delivered["recovery"]["path"])
264+
prior_bytes = prior_file.read_bytes()
265+
266+
current = {**prior, "heartbeat_receipt": {"turn_instance_id": "current-turn"},
267+
"requirement": "Retain this changed requirement and stop before deployment."}
268+
original = tmp_path / "current.json"
269+
raw = json.dumps(current).encode()
270+
original.write_bytes(raw)
271+
registry, runtime = tmp_path / "absent-registry.json", tmp_path / "absent-runtime"
272+
script = (
273+
"import os\n"
274+
"from loopx.control_plane.quota import turn_envelope\n"
275+
"def interrupt(*args, **kwargs): os._exit(37)\n"
276+
"turn_envelope.plan_progressive_context = interrupt\n"
277+
"from loopx.cli import main\n"
278+
"raise SystemExit(main())\n"
279+
)
280+
arguments = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json",
281+
"quota", "context-project", "--capture-file", str(original),
282+
"--capture-sha256", hashlib.sha256(raw).hexdigest(), "--goal-id", "fixture", "--agent-id", "agent",
283+
"--turn-instance-id", "current-turn", "--context-delivery", "on", "--codex-app",
284+
"--codex-context-dir", str(native), "--decision-output-root", str(captures)]
285+
stopped = subprocess.run([sys.executable, "-c", script, *arguments], cwd=native,
286+
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[2])},
287+
capture_output=True, text=True, timeout=30)
288+
assert stopped.returncode == 37, stopped.stdout + stopped.stderr
289+
current_files = list(captures.rglob("projection.json"))
290+
assert len(current_files) == 1 and json.loads(current_files[0].read_bytes()) == current
291+
journal = codex_context._state(native)
292+
assert journal["recovery"]["path"] == str(current_files[0])
293+
assert journal["pending"]["ranges"] == {}
294+
295+
resumed = codex_context.codex_context_hook(str(native), {**event, "source": source})
296+
assert journal["recovery"]["sha256"] in resumed["hookSpecificOutput"]["additionalContext"]
297+
assert "candidate" not in codex_context._state(native)
298+
code, page = _run_cli(registry, runtime, "quota", "context-read", "--codex-context-dir", str(native),
299+
"--goal-id", "fixture", "--agent-id", "agent", cwd=native)
300+
assert code == 0 and page["eof"] and json.loads(page["content"]) == current
301+
assert page["sha256"] == journal["recovery"]["sha256"]
302+
assert prior_file.read_bytes() == prior_bytes and original.read_bytes() == raw
303+
assert not registry.exists() and not runtime.exists()
304+
305+
248306
def test_saved_projection_recovery_failure_returns_typed_error_and_current_reference(
249307
tmp_path, monkeypatch, capsys,
250308
):

0 commit comments

Comments
 (0)