codex: capture the conversation pin in a shared folder, so a restart can resume it - #36
Closed
lvwerra wants to merge 1 commit into
Closed
codex: capture the conversation pin in a shared folder, so a restart can resume it#36lvwerra wants to merge 1 commit into
lvwerra wants to merge 1 commit into
Conversation
A codex pane sharing its folder with another live codex session never got a
conversation pin at all: the watcher tick checked folderIsShared FIRST and
skipped tryCaptureCodexId entirely, so codexSessionId/codexRollout stayed
empty for the life of the pane. resumeCmd then correctly refuses
`resume --last` in a shared folder and runs bare `codex`, so every restart
came back empty with the conversation still on disk.
Observed live: am-image-inputs-aee9c3 ran ~3h in /data/workspaces/agent-manager
alongside agent-manager-5-b0fa07, logged
[codex] am-image-inputs-aee9c3: folder shared with another live session
— not following thread resets here
twice, and restarted into an empty pane while its 277-line rollout
(019fd29a-97e5-7d11-b36e-3ce290b0a0c1) sat unreferenced.
The guard was written for reset-following, where a new conversation in a
shared folder is genuinely unattributable — our /clear, or a sibling's? The
INITIAL capture has no such ambiguity, and tryCaptureCodexId already carries
the checks that make it safe: the claimed set, the cwd match, born-after-launch,
and the subagent filter. Split the two: capture always runs, reset-following
still stops at a shared folder.
- codexCandidate() is split out of tryCaptureCodexId (mirrors claudeCandidate)
and takes a `bornBefore` cap, so a sibling's LATER conversation stays out of
reach while our own launch-window one is claimable.
- codexCaptureMode() is the pure policy: follow / window / pinned / expired.
- 'expired' logs once, so a pane that really found no rollout says so instead
of restarting empty in silence.
Codex launches within ~15s of each other in one folder remain ambiguous — the
existing born-after-launch slack — and unlike claude (#23, #35) codex has no
breadcrumb to disambiguate. Noted, not fixed here.
server/test/repin.test.mjs: 34 checks, up from 21. Full npm test green (5
suites, 50 checks). Verified the new checks have teeth — dropping the
bornBefore cap fails 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The reported symptom
A codex session was stopped and restarted from the UI and came back empty — no conversation, no history — after ~3h of work in
/data/workspaces/agent-manager.Nothing was lost on disk. The rollout was intact the whole time:
019fd29a-97e5-7d11-b36e-3ce290b0a0c1, 277 lines, 1.06MB,payload.cwdexactly the session's workdir,thread_source: user. The manager simply had no record of it.What it actually was
am-image-inputs-aee9c3had nocodexSessionIdand nocodexRollout, three hours in. The watcher's tick checked the shared-folder guard first and skipped capture entirely:folderIsSharedwas true for the life of the pane:agent-manager-5-b0fa07(codex) was live in the same folder. So the pin was never captured — not the initial one, not any. The server said so itself, twice:Unpinned,
resumeCmdskips the pinned branch and reaches the generic codex one, which correctly refusesresume --lastin a shared folder —--lastscopes to the cwd, so it could resume a sibling's thread — and runs bareexec codex. An empty pane was the only possible outcome.agent-manager-2-93de86, same folder, is pinned: it launched while it was the only codex there.Ruled out along the way:
CODEX_HOMEin the server's own env (set correctly,/home/node/local/codex-home); thesessionssymlink onto the bucket breaking the walk (readdirSyncresolves it,2026/is a real dir, depth 3 of 5); the rollout's own metadata failing a gate (cwdmatched, born 3.5s after launch,thread_source: user, not a subagent); FUSE mtime hiding the file fromcodexRolloutsSince.Why the guard was there, and what it should have covered
The guard is right about reset-following. A new conversation appearing in a shared folder is genuinely unattributable — our
/clear, or a sibling's? — and claiming one would take a live agent's thread away from it. That is the same problem #23 solved for claude with a breadcrumb, and #35 is what finally makes that breadcrumb work. Codex has no breadcrumb, so it can only refuse.The initial capture has no such ambiguity, and
tryCaptureCodexIdalready carries the checks that make it safe in a shared folder: theclaimedset (never take a rollout another session pinned), thecwdmatch, born-after-this-launch, and the subagent filter. Those are precisely the "don't take someone else's thread" checks. Skipping the whole call threw them away along with the bug they prevent.Three changes
1.
codexCandidate(), split out oftryCaptureCodexIdMirrors
claudeCandidate: all the gates, no writes, returns{ id, p }or null. Exported, so the gates are directly testable — they never were before.2. A
bornBeforecapcodexCandidatetakes an upper bound on how late a conversation may have been born and still count as ours. Used only in the shared case, set to launch + 2 min. Codex writes itssession_metaline as the TUI starts (3.5s after launch in this session), so it is ~20x the margin needed, and a sibling's reset minutes or hours in stays out of reach — the one hazard that made capture-in-a-shared-folder look unsafe.3.
codexCaptureMode()— the policy, as a pure functionfollowwindowpinnedexpiredexpiredis new behaviour worth naming: a pane that genuinely never produced a rollout now says so once, instead of silently restarting empty like this one did.What this does not fix
Two codex panes launching in the same folder within ~15s of each other are still ambiguous — that is the existing born-after-launch slack, and the sibling's rollout is only excluded by it. Back-to-back restarts further apart are fine: each pane's window opens at its own launch and the earlier rollout falls outside it, plus whoever pins first claims it. A breadcrumb-style trust signal for codex, the equivalent of #23/#35 for claude, is the real fix for that and is not attempted here.
Also unchanged:
resumeCmd's shared-folder check counts stopped siblings too. That is correct —resume --lastdoesn't care whether the sibling is running, its rollout is still the most recent in the cwd — and it matters much less now, since the pinned branch returns first.Testing
server/test/repin.test.mjs— 34 checks, up from 21. Fullnpm testgreen (5 suites, 50 checks).codexCandidateagainst real rollout files in a tempCODEX_HOME: own conversation captured; another folder ignored; a pre-launch rollout rejected despite a fresh mtime; a subagent rollout skipped; a later conversation followed when the folder is ours but out of reach when shared; a rollout another session pinned left alone.codexCaptureMode, including both sides of the window boundary (SINCE + 120_000→window,+120_001→expired).bornBeforeline fails 2 of them.Not verified end-to-end on the live Space: that a folder-sharing codex pane now restarts onto its own conversation needs this deployed — the running container still skips capture, which is why the log evidence above shows only the refusal.
🤖 Generated with Claude Code