From a6ac2da341a00f8bea2ee4c518b99053fe594ad0 Mon Sep 17 00:00:00 2001 From: Agent Manager Date: Thu, 30 Jul 2026 16:07:30 +0000 Subject: [PATCH] codex/opencode: keep looking for the conversation id, not just for 45s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A codex session that came back from a Space restart with its history gone (agent-manager-2 on the live Space) had no `codexSessionId` pin at all, so it launched `codex resume --last` and got a blank conversation. The pin was missing because capture gave up too early. Codex writes NOTHING identifying to disk until the first message is submitted: measured on 0.146, an idle TUI produced no rollout file for 60s+, and one appeared within 3s of pressing enter. Capture polled at +5s/+15s/+45s and then stopped forever, so it only ever pinned sessions whose first message arrived inside that window. The Space's own history shows the split cleanly — every codex session whose rollout appeared within 11s of creation is pinned; every one that took 16s or longer (agent-manager-2 at 81s, my-assistant at 107s) is not. Once unpinned, nothing retried, and the next launch fell through to `codex resume --last`. That resolves through codex's own thread index — state_5.sqlite under $CODEX_HOME, which lives on local disk and is wiped by every restart, then rebuilt from the rollouts at boot. This boot it rebuilt 17 of 18, missing exactly the agent-manager conversation, with its backfill cursor stranded on the previous file. No index row for that cwd means `resume --last` starts a FRESH thread and exits 0, so `|| exec codex` can't even notice: the pane returns looking new. (A pin, by contrast, works with no index at all — `codex resume ` restored the full conversation from a CODEX_HOME holding only that rollout file.) So keep looking for as long as the pane lives: the same opening ladder, then a steady 60s beat, ending when the pin lands or the session is gone. opencode gets the same treatment through a shared pollForPin — its ses_ row also only appears once the conversation has content, which its old 90s ceiling had the same problem with. Claude is untouched: it gets an id up front via --session-id, so its capture is only verifying a pin we already asked for. Exercised against a live pane (isolated DATA_DIR + CODEX_HOME): a session left idle 75s before its first message — dead ground under the old ladder — got its rollout at t≈80s and was pinned on the following beat, and relaunching it brought the conversation back. An unpinned session abandoned at 20s stayed unpinned across two beats after a fresh matching rollout appeared in its folder, so an outlived loop can't hijack a later conversation. Co-Authored-By: Claude Opus 5 --- server/src/runner.js | 74 ++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/server/src/runner.js b/server/src/runner.js index e032431..44117eb 100644 --- a/server/src/runner.js +++ b/server/src/runner.js @@ -262,31 +262,62 @@ function pinIsStale(session) { } catch { return false; } } +/** + * Poll until `attempt()` reports it has pinned the conversation. + * + * Both codex and opencode write NOTHING identifying to disk until the FIRST + * MESSAGE is submitted — measured on codex 0.146: an idle TUI left running for + * 60s+ produced no rollout file at all, and one appeared within 3s of pressing + * enter. A fixed burst of attempts (the old ladder gave up at 45s / 90s) could + * therefore only pin sessions whose first message arrived while it was still + * looking. A pane opened and typed into a minute later stayed unpinned FOREVER + * — nothing ever retried — and its next launch fell back to `codex resume + * --last`, which resolves through codex's own thread index (local disk, wiped + * on every Space restart) and silently starts a FRESH conversation when that + * comes up empty. It exits 0 while doing so, so even `|| exec codex` can't + * notice, and the pane comes back with its history gone. + * + * So keep looking for as long as the pane lives: the ladder covers a prompt + * typed straight away, then a steady beat catches the conversation whenever it + * actually begins — minutes or hours later. + * + * Cost per tick is one directory walk plus a `tmux has-session`; the pane-alive + * gate is what bounds it, and `agentInfo()` already shells out to tmux per + * session every 1.5s. + */ +function pollForPin(sessionId, inflight, attempt, ladder = [5000, 15000, 45000], steady = 60_000) { + inflight.add(sessionId); + const tick = (i) => { + // These run in a bare timer, so a throw here would take the process down. + let pinned = false; + try { pinned = attempt(); } catch {} + // Past the ladder, keep going only while the session is still up. + if (pinned || (i + 1 >= ladder.length && !isRunning(sessionId))) { + inflight.delete(sessionId); + return; + } + const wait = i + 1 < ladder.length ? ladder[i + 1] - ladder[i] : steady; + const t = setTimeout(() => tick(i + 1), wait); + if (t.unref) t.unref(); + }; + const t0 = setTimeout(() => tick(0), ladder[0]); + if (t0.unref) t0.unref(); +} + function scheduleCodexCapture(session, workdir) { if (session.codexSessionId && pinIsStale(session)) { session = update(session.id, { codexSessionId: undefined, codexRollout: undefined }) || session; } if (session.codexSessionId || codexCapturing.has(session.id)) return; - codexCapturing.add(session.id); const since = Date.now() - 2000; - const delays = [5000, 15000, 45000]; // rollout appears ~instantly; retries cover slow starts - const attempt = (i) => { - if (tryCaptureCodexId(session.id, workdir, since) || i + 1 >= delays.length) { - codexCapturing.delete(session.id); - return; - } - const t = setTimeout(() => attempt(i + 1), delays[i + 1] - delays[i]); - if (t.unref) t.unref(); - }; - const t0 = setTimeout(() => attempt(0), delays[0]); - if (t0.unref) t0.unref(); + pollForPin(session.id, codexCapturing, () => tryCaptureCodexId(session.id, workdir, since)); } // opencode has no per-conversation handle we can pass on launch, so we can't // mint an id like Claude's --session-id. Instead, capture the ses_ row opencode // writes to its db and pin it — mirrors the codex approach. The row appears -// only once the conversation has content (the user's first message), so retry -// on a longer, sparser schedule than codex. +// only once the conversation has content (the user's first message), so it gets +// the same keep-looking treatment (see pollForPin). // ---------- Claude conversation re-pinning ---------- // We ASK for a conversation id up front (`claude --session-id `), which // normally makes the transcript filename equal session.sessionUuid. But the @@ -377,19 +408,14 @@ function scheduleClaudeCapture(session, workdir) { const opencodeCapturing = new Set(); function scheduleOpencodeCapture(session, workdir) { if (session.opencodeSessionId || opencodeCapturing.has(session.id)) return; - opencodeCapturing.add(session.id); const since = Date.now() - 2000; - const delays = [3000, 8000, 20000, 45000, 90000]; - const attempt = (i) => { + pollForPin(session.id, opencodeCapturing, () => { const claimed = new Set(list().filter((s) => s.id !== session.id && s.opencodeSessionId).map((s) => s.opencodeSessionId)); const hit = captureOpencodeSession(workdir, since, claimed); - if (hit) { update(session.id, { opencodeSessionId: hit.id }); opencodeCapturing.delete(session.id); return; } - if (i + 1 >= delays.length) { opencodeCapturing.delete(session.id); return; } - const t = setTimeout(() => attempt(i + 1), delays[i + 1] - delays[i]); - if (t.unref) t.unref(); - }; - const t0 = setTimeout(() => attempt(0), delays[0]); - if (t0.unref) t0.unref(); + if (!hit) return false; + update(session.id, { opencodeSessionId: hit.id }); + return true; + }, [3000, 8000, 20000, 45000]); } // Single-quote a string for embedding in an `sh -lc` command line.