Skip to content

Commit 3dcb237

Browse files
committed
fix(context): retain the first recovery read across native resume
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent 08e290f commit 3dcb237

3 files changed

Lines changed: 99 additions & 3 deletions

File tree

‎loopx/control_plane/turn_driver/codex_context_history.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,23 @@ def observe_native_read(state: dict[str, Any], event: dict[str, Any],
236236
"""Collect content-free IO facts alongside the existing pending byte ranges."""
237237
pending = state.get("pending")
238238
call = event.get("tool_use_id")
239-
if (not isinstance(pending, dict) or not isinstance(call, str) or not call
239+
if (not isinstance(call, str) or not call
240240
or not pages or any(page.get("ok") is not True for page in pages)):
241241
return state
242+
if pending is None:
243+
recovery, candidate = state.get("recovery"), state.get("candidate") or {}
244+
if (not isinstance(recovery, dict) or not isinstance(candidate, dict)
245+
or any(page.get("sha256") != recovery.get("sha256") for page in pages)
246+
or (candidate.get("capture_sha256") == recovery.get("sha256")
247+
and candidate.get("capture_bytes_verified") is True)):
248+
return state
249+
# Invalidation clears pending reads. Record the first original-capture
250+
# recovery read too, including a full capture delivered in one page.
251+
# These are IO annotations only: the typed lifecycle still authenticates
252+
# coverage and decides whether a complete baseline can be established.
253+
pending = {}
254+
if not isinstance(pending, dict):
255+
return state
242256
read = {"call_id": call, "pages_sha256": _digest(pages)}
243257
reads = pending.get("native_reads", [])
244258
if not isinstance(reads, list):

‎tests/control_plane/test_codex_context_capture.py‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def _last_page(request):
8787
+ [(host, surface, False, True, False) for host in ("benchmark", "code_mode")
8888
for surface in ("quota_packet", "turn_envelope")]
8989
+ [("code_mode", surface, False, True, True) for surface in ("quota_packet", "turn_envelope")])
90-
def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False, checkpoint_handoff=False, handoff_socket=None, native_pragma=False, native_read_batch=2, native_history_tokens=None, resume_loss=None, bounded_pages=False, ssh_goal=False, reader_output_bytes=None):
90+
def test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch, host, surface, failed_primary, resumed, adaptive, hook_loss=None, shared_root=False, checkpoint_handoff=False, handoff_socket=None, native_pragma=False, native_read_batch=2, native_history_tokens=None, resume_loss=None, bounded_pages=False, ssh_goal=False, reader_output_bytes=None, recovery_resume=False):
9191
# Real worker qualification follows returned cursors; output budgets can
9292
# shrink a page, so precomputed fixed offsets are not its production path.
9393
bounded_pages = bounded_pages or host == "benchmark"
@@ -399,11 +399,27 @@ def response(request, number, message):
399399
return tool(page["next_command"], number)
400400
stages["phase"] = "recovered"
401401
return response(request, number, message)
402+
elif phase == "recovery_resuming":
403+
# The actual host has exited and reconstructed history again. Its
404+
# model input must retain the recovered full body, and SessionStart
405+
# must preserve that new baseline without another recovery read.
406+
from loopx.control_plane.turn_driver.codex_context import _state
407+
408+
assert delivered(request, "recovery") == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n")
409+
journal = _state(lifecycle)
410+
assert journal["candidate"]["target"]["context_epoch"] == stages["recovered_epoch"]
411+
assert journal["candidate"]["capture_sha256"] == page_digests["recovery"]
412+
stages["phase"] = "done"
413+
return message
402414
else:
403415
assert phase == "recovered"
404416
recovered = delivered(request, "recovery")
405417
assert recovered == Path(wires["warm"]["recovery"]["path"]).read_text().rstrip("\n")
406-
stages["phase"] = "done"
418+
if recovery_resume:
419+
from loopx.control_plane.turn_driver.codex_context import _state
420+
421+
stages["recovered_epoch"] = _state(lifecycle)["context_epoch"]
422+
stages["phase"] = "recovery_resuming" if recovery_resume else "done"
407423
return message
408424
return tool(cmd, number)
409425

@@ -587,6 +603,9 @@ def held(*args, **kwargs):
587603
if resumed:
588604
assert _app_server(binary, env, workspace, home, True, sandbox=sandbox,
589605
resume=session_id, turn_timeout_sec=60) == session_id
606+
if recovery_resume:
607+
assert _app_server(binary, env, workspace, home, True, sandbox=sandbox,
608+
resume=session_id, turn_timeout_sec=60) == session_id
590609
assert not errors, errors
591610

592611
assert stages["phase"] == "done"
@@ -678,6 +697,12 @@ def held(*args, **kwargs):
678697
assert bounded_sizes and max(bounded_sizes) > 3072
679698

680699

700+
@pytest.mark.parametrize("surface", ["quota_packet", "turn_envelope"])
701+
def test_native_compact_recovery_survives_process_resume(tmp_path, monkeypatch, surface):
702+
test_native_cli_capture_delta_and_compact_recovery(tmp_path, monkeypatch,
703+
"code_mode", surface, False, True, True, bounded_pages=True, recovery_resume=True)
704+
705+
681706
@pytest.mark.parametrize("surface", ["quota_packet", "turn_envelope"])
682707
@pytest.mark.parametrize("loss", ["replace", "unqualified_profile"])
683708
def test_native_worker_resume_history_change_reads_current_full(tmp_path, monkeypatch, surface, loss):

‎tests/control_plane/test_codex_context_resume.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,63 @@ def test_completed_native_resume_keeps_delta_without_old_capture_prefetch(tmp_pa
8282
assert wire["changes"]["values"]["heartbeat_receipt"] == current["heartbeat_receipt"]
8383

8484

85+
@pytest.mark.parametrize("single_page", [False, True])
86+
@pytest.mark.parametrize("lose_first", [False, True])
87+
@pytest.mark.parametrize("code_mode", [False, True])
88+
def test_compact_full_recovery_becomes_next_resume_baseline(tmp_path, monkeypatch, single_page, lose_first, code_mode):
89+
from argparse import Namespace
90+
from loopx.cli_commands.quota_capture import read_context_capture
91+
92+
event, history, payload = observed_read(tmp_path, monkeypatch)
93+
before = context._state(tmp_path)
94+
context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PreCompact"})
95+
append(history, "compacted", {"message": "Summary without captured source bodies."})
96+
context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PostCompact"})
97+
recovery_instruction = context.codex_context_hook(str(tmp_path), {**event, "source": "compact"})
98+
invalid = context._state(tmp_path)
99+
assert "candidate" not in invalid and "pending" not in invalid
100+
assert invalid["context_epoch"] != before["context_epoch"]
101+
assert invalid["recovery"] == before["recovery"]
102+
assert invalid["recovery"]["command"] in str(recovery_instruction)
103+
104+
reference = invalid["recovery"]
105+
offset, index = 0, 0
106+
while True:
107+
page = read_context_capture(Namespace(capture_file=reference["path"], capture_sha256=reference["sha256"],
108+
offset=offset, page_bytes=64000 if single_page else 4096,
109+
max_page_output_bytes=64000 if single_page else None, format="json"))
110+
assert page["ok"]
111+
call = f"recovery-{index}"
112+
if code_mode:
113+
# The nested hook fires before its parent emits the retained output.
114+
append(history, "response_item", {"type": "custom_tool_call", "call_id": f"parent-{call}"})
115+
else:
116+
append(history, "response_item", {"type": "function_call", "call_id": call})
117+
retained = "truncated" if lose_first and index == 0 else json.dumps(page)
118+
if not code_mode:
119+
append(history, "response_item", {"type": "function_call_output", "call_id": call, "output": retained})
120+
context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PostToolUse",
121+
"tool_use_id": call, "tool_response": json.dumps(page)})
122+
if code_mode:
123+
append(history, "response_item", {"type": "custom_tool_call_output", "call_id": f"parent-{call}",
124+
"output": [{"type": "input_text", "text": retained}]})
125+
index += 1
126+
if page["eof"]:
127+
break
128+
offset = page["next_offset"]
129+
assert (index == 1) == single_page
130+
recovered = context._state(tmp_path)
131+
assert recovered["candidate"]["capture_sha256"] == reference["sha256"]
132+
assert recovered["candidate"]["target"]["context_epoch"] == invalid["context_epoch"]
133+
append(history, "event_msg", {"type": "task_complete"})
134+
context.codex_context_hook(str(tmp_path), {**event, "source": "resume"})
135+
current = {**payload, "heartbeat_receipt": {"turn_instance_id": "after-compact"}}
136+
_, wire = project(tmp_path, "after-compact", payload=current)
137+
assert wire["mode"] == ("full" if lose_first else "delta")
138+
if not lose_first:
139+
assert wire["changes"]["values"]["heartbeat_receipt"] == current["heartbeat_receipt"]
140+
141+
85142
@pytest.mark.parametrize("change", ["compact", "rollback", "retained", "unknown", "truncate",
86143
"replace", "rewrite", "missing", "output", "duplicate", "model", "workspace", "session", "permission", "writable", "malformed"])
87144
@pytest.mark.parametrize("version", ["0.162.1", "0.163.0-alpha.11.1"])

0 commit comments

Comments
 (0)