Skip to content

Commit f12061b

Browse files
tildesrcPanopticon Agentclaude
authored
Resolve codex resume target from session_meta instead of trusting --last (#394)
$CODEX_HOME/sessions is shared by codex exec subprocesses and internal subagent threads (e.g. compaction), so `resume --last` can resume the wrong or non-resumable session. Add _find_resume_target(), which reads only the first line of each .jsonl file and filters to sessions with originator=codex-tui and thread_source=user, then passes the winner's id explicitly as `codex resume <session_id>`. Also implement the interrupt prompt on resume when turn=agent (previously deferred as unverified; it is verified). Co-authored-by: Panopticon Agent <agent@panopticon> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a32d469 commit f12061b

2 files changed

Lines changed: 226 additions & 22 deletions

File tree

src/panopticon/container/cli/codex.py

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
- **auth** → an API key (``CODEX_API_KEY`` / ``OPENAI_API_KEY``) materialized into
1616
``$CODEX_HOME/auth.json`` (a bare env var does *not* log codex in), or a ChatGPT workspace
1717
access token (``CODEX_ACCESS_TOKEN``) read straight from the env — see :meth:`write_credentials`;
18-
- **launch / resume** → ``codex`` first-run vs ``codex resume --last`` (the ``claude --continue``
19-
analogue), probing ``$CODEX_HOME/sessions`` for a prior transcript.
18+
- **launch / resume** → ``codex`` first-run vs ``codex resume <session_id>`` (the ``claude
19+
--continue`` analogue), selecting the resumable session via :func:`_find_resume_target`.
2020
2121
Scope now includes the **turn-flip hooks** (M3.6): :meth:`~CodexAgentCLI.write_settings` wires
2222
codex's ``[hooks]`` ``Stop`` / ``UserPromptSubmit`` block to the shared callback, and the hook-payload
@@ -43,6 +43,55 @@
4343
from panopticon.container.skills import write_agent_operation_skills, write_agent_skills
4444
from panopticon.core.models import Skill
4545

46+
47+
def _find_resume_target(sessions_dir: Path) -> str | None:
48+
"""Return the session id of the newest resumable codex session, or ``None``.
49+
50+
``$CODEX_HOME/sessions`` is shared by **all** codex invocations in the container —
51+
``codex exec`` subprocesses (anything the agent shells out to) and codex-tui's own
52+
internal subagent threads (e.g. compaction) all write ``.jsonl`` rollout files there.
53+
Resuming by ``--last`` (newest mtime) can therefore land on a non-resumable or wrong
54+
session. This function reads only the **first line** of each file (the ``session_meta``
55+
record, cheap regardless of session length) and filters to sessions where:
56+
57+
- ``payload["originator"] == "codex-tui"`` — interactive TUI, not ``codex_exec``
58+
- ``payload["thread_source"] == "user"`` — root thread, not an internal subagent thread
59+
60+
Returns ``payload["id"]`` of the eligible file with the highest ``st_mtime_ns`` (integer
61+
nanoseconds — float mtime loses sub-second precision). Malformed or empty first lines and
62+
any ``OSError`` are silently skipped. Returns ``None`` when nothing qualifies.
63+
"""
64+
best_mtime: int = -1
65+
best_id: str | None = None
66+
67+
for path in sessions_dir.rglob("*.jsonl"):
68+
try:
69+
first_line = path.read_text().split("\n", 1)[0].strip()
70+
if not first_line:
71+
continue
72+
record = json.loads(first_line)
73+
if not isinstance(record, dict):
74+
continue
75+
payload = record.get("payload", {})
76+
if not isinstance(payload, dict):
77+
continue
78+
if payload.get("originator") != "codex-tui":
79+
continue
80+
if payload.get("thread_source") != "user":
81+
continue
82+
session_id = payload.get("id")
83+
if not session_id or not isinstance(session_id, str):
84+
continue
85+
mtime = path.stat().st_mtime_ns
86+
if mtime > best_mtime:
87+
best_mtime = mtime
88+
best_id = session_id
89+
except (OSError, json.JSONDecodeError, ValueError):
90+
continue
91+
92+
return best_id
93+
94+
4695
#: The control plane's abstract model **tiers** mapped to codex's concrete model ids (ADR 0014 §3a).
4796
#: The only place a provider model name appears; ``core``/``workflows`` name only the tier. ``primary``
4897
#: maps to codex's flagship (``gpt-5.6-sol``), verified against the pinned codex release (the
@@ -273,26 +322,32 @@ def launch_argv(
273322
turn: str | None = None,
274323
starting_model: str | None = None,
275324
) -> list[str]:
276-
"""`codex` argv, resuming the config dir's most recent session if one exists.
325+
"""`codex` argv, resuming the most recent resumable session by id if one exists.
277326
278327
The agent runs unattended in a throwaway container on a per-task clone, so it launches with
279328
``--dangerously-bypass-approvals-and-sandbox`` (the ``claude --dangerously-skip-permissions``
280329
analogue) — no operator to answer prompts, blast radius the task's own checkout. Codex keeps
281-
session transcripts under ``$CODEX_HOME/sessions``; when one is present we ``resume --last``
282-
instead of starting fresh. The config dir is a **per-task volume**, so this resumes both
283-
within a container's life and **across respawn/recreate**.
284-
285-
On a **first run** (no prior session) the ``starting_model`` tier is resolved via
286-
:meth:`resolve_model` and passed as ``--model`` (on resume codex uses the session's model),
287-
and an ``initial_prompt`` is appended as codex's first message. ``turn`` is accepted for
288-
signature parity with the claude adapter; auto-continuing a resumed session on the agent's
289-
turn (claude's interrupt prompt) is deferred with the rest of the turn wiring to M3.6, since
290-
injecting a prompt into a resumed codex session isn't yet verified.
330+
session transcripts under ``$CODEX_HOME/sessions``; :func:`_find_resume_target` scans them
331+
and returns the id of the newest session whose first-line ``session_meta`` record marks it as
332+
a resumable interactive TUI root thread (``originator=codex-tui``, ``thread_source=user``).
333+
When one is found, ``codex resume <session_id>`` is used instead of starting fresh. The
334+
config dir is a **per-task volume**, so this resumes both within a container's life and
335+
**across respawn/recreate**.
336+
337+
On **resume** with ``turn == "agent"`` (the agent was interrupted mid-turn), the interrupt
338+
prompt ``"You were interrupted. Continue."`` is appended as codex's first positional message
339+
so the agent picks up where it left off. On a **first run** (no resumable session) the
340+
``starting_model`` tier is resolved via :meth:`resolve_model` and passed as ``--model`` (on
341+
resume codex uses the session's model), and ``initial_prompt`` is appended as the first
342+
message.
291343
"""
292344
argv = ["codex", "--dangerously-bypass-approvals-and-sandbox"]
293-
sessions = config_dir / self.SESSIONS_DIRNAME
294-
if sessions.exists() and any(sessions.rglob("*.jsonl")):
295-
argv += ["resume", "--last"] # resume the config dir's most recent session
345+
sessions_dir = config_dir / self.SESSIONS_DIRNAME
346+
session_id = _find_resume_target(sessions_dir) if sessions_dir.exists() else None
347+
if session_id:
348+
argv += ["resume", session_id]
349+
if turn == "agent":
350+
argv.append("You were interrupted. Continue.")
296351
else:
297352
if starting_model: # first run only — on resume codex uses the session's model
298353
argv += ["--model", self.resolve_model(starting_model)]

tests/container/test_codex.py

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66

77
import io
88
import json
9+
import time
910
import tomllib
1011
from pathlib import Path
1112

1213
import pytest
1314

14-
from panopticon.container.cli.codex import CodexAgentCLI
15+
from panopticon.container.cli.codex import CodexAgentCLI, _find_resume_target
1516

1617

1718
class _FakeClient:
@@ -241,15 +242,16 @@ def test_launch_argv_starts_fresh_without_a_session(tmp_path: Path) -> None:
241242
]
242243

243244

244-
def test_launch_argv_resumes_when_a_session_transcript_exists(tmp_path: Path) -> None:
245+
def test_launch_argv_resumes_when_an_interactive_session_exists(tmp_path: Path) -> None:
245246
sessions = tmp_path / "sessions" / "2026" / "08"
246247
sessions.mkdir(parents=True)
247-
(sessions / "rollout-abc.jsonl").write_text("{}")
248+
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "sess-abc"}}'
249+
(sessions / "rollout-abc.jsonl").write_text(meta)
248250
assert CodexAgentCLI().launch_argv(tmp_path, Path("/workspace")) == [
249251
"codex",
250252
"--dangerously-bypass-approvals-and-sandbox",
251253
"resume",
252-
"--last",
254+
"sess-abc",
253255
]
254256

255257

@@ -260,7 +262,8 @@ def test_launch_argv_appends_initial_prompt_on_first_run(tmp_path: Path) -> None
260262

261263
def test_launch_argv_omits_initial_prompt_when_resuming(tmp_path: Path) -> None:
262264
(tmp_path / "sessions").mkdir()
263-
(tmp_path / "sessions" / "s.jsonl").write_text("{}")
265+
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
266+
(tmp_path / "sessions" / "s.jsonl").write_text(meta)
264267
argv = CodexAgentCLI().launch_argv(tmp_path, Path("/workspace"), initial_prompt="review plan")
265268
assert "resume" in argv and "review plan" not in argv
266269

@@ -277,7 +280,8 @@ def test_launch_argv_passes_the_resolved_model_on_first_run(tmp_path: Path) -> N
277280

278281
def test_launch_argv_omits_model_on_resume(tmp_path: Path) -> None:
279282
(tmp_path / "sessions").mkdir()
280-
(tmp_path / "sessions" / "s.jsonl").write_text("{}")
283+
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
284+
(tmp_path / "sessions" / "s.jsonl").write_text(meta)
281285
argv = CodexAgentCLI().launch_argv(tmp_path, Path("/workspace"), starting_model="primary")
282286
assert "--model" not in argv and "resume" in argv
283287

@@ -295,6 +299,151 @@ def test_launch_argv_passes_model_before_initial_prompt_on_first_run(tmp_path: P
295299
]
296300

297301

302+
# -- _find_resume_target -----------------------------------------------------------------------
303+
304+
305+
def _session_meta(
306+
session_id: str, originator: str = "codex-tui", thread_source: str = "user"
307+
) -> str:
308+
"""One-liner session_meta first-line JSON for tests."""
309+
import json
310+
311+
return json.dumps(
312+
{"payload": {"originator": originator, "thread_source": thread_source, "id": session_id}}
313+
)
314+
315+
316+
def test_find_resume_target_returns_none_when_no_sessions_dir(tmp_path: Path) -> None:
317+
assert _find_resume_target(tmp_path / "sessions") is None
318+
319+
320+
def test_find_resume_target_returns_none_when_no_jsonl_files(tmp_path: Path) -> None:
321+
(tmp_path / "sessions").mkdir()
322+
assert _find_resume_target(tmp_path / "sessions") is None
323+
324+
325+
def test_find_resume_target_skips_codex_exec_rollout(tmp_path: Path) -> None:
326+
# originator != "codex-tui" → not eligible
327+
d = tmp_path / "sessions"
328+
d.mkdir()
329+
(d / "exec.jsonl").write_text(_session_meta("exec-1", originator="codex_exec"))
330+
assert _find_resume_target(d) is None
331+
332+
333+
def test_find_resume_target_skips_subagent_thread(tmp_path: Path) -> None:
334+
# thread_source != "user" → internal subagent thread, not resumable
335+
d = tmp_path / "sessions"
336+
d.mkdir()
337+
(d / "sub.jsonl").write_text(_session_meta("sub-1", thread_source="agent"))
338+
assert _find_resume_target(d) is None
339+
340+
341+
def test_find_resume_target_skips_malformed_first_line(tmp_path: Path) -> None:
342+
d = tmp_path / "sessions"
343+
d.mkdir()
344+
(d / "bad.jsonl").write_text("not json\n")
345+
assert _find_resume_target(d) is None
346+
347+
348+
def test_find_resume_target_skips_empty_first_line(tmp_path: Path) -> None:
349+
d = tmp_path / "sessions"
350+
d.mkdir()
351+
(d / "empty.jsonl").write_text("\n{}\n") # empty first line
352+
assert _find_resume_target(d) is None
353+
354+
355+
def test_find_resume_target_skips_bare_object_without_payload(tmp_path: Path) -> None:
356+
d = tmp_path / "sessions"
357+
d.mkdir()
358+
(d / "bare.jsonl").write_text("{}") # valid JSON, but no payload → skip
359+
assert _find_resume_target(d) is None
360+
361+
362+
def test_find_resume_target_returns_id_of_interactive_session(tmp_path: Path) -> None:
363+
d = tmp_path / "sessions"
364+
d.mkdir()
365+
(d / "sess.jsonl").write_text(_session_meta("interactive-1"))
366+
assert _find_resume_target(d) == "interactive-1"
367+
368+
369+
def test_find_resume_target_newest_interactive_beats_older_exec(tmp_path: Path) -> None:
370+
# A newer exec rollout must not shadow an older interactive session.
371+
d = tmp_path / "sessions"
372+
d.mkdir()
373+
old = d / "old-interactive.jsonl"
374+
old.write_text(_session_meta("good-sess"))
375+
time.sleep(0.01)
376+
new = d / "new-exec.jsonl"
377+
new.write_text(_session_meta("exec-sess", originator="codex_exec"))
378+
# exec is newer by mtime but ineligible → interactive wins
379+
assert _find_resume_target(d) == "good-sess"
380+
381+
382+
def test_find_resume_target_picks_newest_of_multiple_interactive(tmp_path: Path) -> None:
383+
d = tmp_path / "sessions"
384+
d.mkdir()
385+
first = d / "first.jsonl"
386+
first.write_text(_session_meta("old-sess"))
387+
time.sleep(0.01)
388+
second = d / "second.jsonl"
389+
second.write_text(_session_meta("new-sess"))
390+
assert _find_resume_target(d) == "new-sess"
391+
392+
393+
def test_find_resume_target_searches_subdirectories(tmp_path: Path) -> None:
394+
d = tmp_path / "sessions"
395+
sub = d / "2026" / "08"
396+
sub.mkdir(parents=True)
397+
(sub / "deep.jsonl").write_text(_session_meta("deep-sess"))
398+
assert _find_resume_target(d) == "deep-sess"
399+
400+
401+
# -- launch_argv resume + interrupt prompt -------------------------------------------------------
402+
403+
404+
def test_launch_argv_resumes_with_interrupt_prompt_when_agent_turn(tmp_path: Path) -> None:
405+
(tmp_path / "sessions").mkdir()
406+
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
407+
(tmp_path / "sessions" / "s.jsonl").write_text(meta)
408+
argv = CodexAgentCLI().launch_argv(tmp_path, Path("/workspace"), turn="agent")
409+
assert argv == [
410+
"codex",
411+
"--dangerously-bypass-approvals-and-sandbox",
412+
"resume",
413+
"s1",
414+
"You were interrupted. Continue.",
415+
]
416+
417+
418+
def test_launch_argv_resumes_without_interrupt_prompt_when_user_turn(tmp_path: Path) -> None:
419+
(tmp_path / "sessions").mkdir()
420+
meta = '{"payload": {"originator": "codex-tui", "thread_source": "user", "id": "s1"}}'
421+
(tmp_path / "sessions" / "s.jsonl").write_text(meta)
422+
argv = CodexAgentCLI().launch_argv(tmp_path, Path("/workspace"), turn="user")
423+
assert argv == [
424+
"codex",
425+
"--dangerously-bypass-approvals-and-sandbox",
426+
"resume",
427+
"s1",
428+
]
429+
430+
431+
def test_launch_argv_falls_back_to_first_run_when_only_exec_sessions(tmp_path: Path) -> None:
432+
(tmp_path / "sessions").mkdir()
433+
exec_meta = '{"payload": {"originator": "codex_exec", "thread_source": "user", "id": "e1"}}'
434+
(tmp_path / "sessions" / "exec.jsonl").write_text(exec_meta)
435+
argv = CodexAgentCLI().launch_argv(
436+
tmp_path, Path("/workspace"), initial_prompt="hi", starting_model="primary"
437+
)
438+
assert argv == [
439+
"codex",
440+
"--dangerously-bypass-approvals-and-sandbox",
441+
"--model",
442+
"gpt-5.6-sol",
443+
"hi",
444+
]
445+
446+
298447
# -- hook seam (M3.6) ---------------------------------------------------------------------------
299448

300449

0 commit comments

Comments
 (0)