fix(omp): resolve and pin the respawn session id only at actual respawn time

findLatestOmpSessionId()'s newest-mtime pin ran eagerly inside
_buildRespawnPaneOptions(), which startInteractive() calls unconditionally
on every boot-recovery reattach — before anything checks whether the pane
is actually dead. With two omp tabs in the same case dir, this could pin
an ALIVE pane's session onto whichever sibling's file happened to be
newest on disk, purely as a side effect of building options that might
never lead to a respawn (reported in Ark0N/Codeman#353 review).

Move resolution out of the eager builder into _pinOmpRespawnId(), called
explicitly only where a respawn is actually confirmed: the dead-pane
branch in _setupOrAttachMuxSession() and reattachRemote(). Add
resolveAndClaimOmpSessionId(), which verifies each candidate's own file
header (cwd) rather than trusting the mangled-directory match alone, and
tracks claimed ids in a process-wide registry so two ambiguous resolutions
can't both pick the same sibling's conversation.
This commit is contained in:
timkjr
2026-08-28 13:18:25 -05:00
parent ab83d8ffec
commit c4f6eb1e5e
3 changed files with 195 additions and 62 deletions
+59 -17
View File
@@ -1,18 +1,23 @@
/**
* @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27.
* @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27,
* and its follow-on fix for the sibling-aliasing bug found in upstream PR
* review (Ark0N/Codeman#353).
*
* Session._resolvedOmpRespawnConfig() resolves-and-pins the newest on-disk omp
* conversation as a side effect on `this._ompConfig`. That is correct when
* reattaching to an ALREADY-TRACKED mux session (a dead-pane respawn, or a
* boot-recovery reattach — the constructor sets `_muxSession` from persisted
* state before startInteractive() ever runs there). It is wrong for a
* genuinely brand-new session: startInteractive() computes
* `respawnPaneOptions: this._buildRespawnPaneOptions()` EAGERLY in the same
* object literal that builds `createSessionOptions.ompConfig: this._ompConfig`,
* so the resolve-and-pin side effect ran and poisoned `this._ompConfig` before
* that field was even read — a fresh "Run OMP" click in a working directory
* with any prior omp history silently launched `--resume <old-id>` instead of
* a clean `omp` invocation.
* Session._pinOmpRespawnId() resolves-and-pins the newest on-disk omp
* conversation as a side effect on `this._ompConfig`. That is correct ONLY
* immediately before an ACTUAL respawn (a confirmed-dead pane, or a genuine
* remote reattach) — never while merely building options that might not
* lead to one. It used to run eagerly inside `_buildRespawnPaneOptions()`,
* which startInteractive() calls unconditionally (including for a genuinely
* brand-new session, and for a boot-recovery reattach to a pane that turns
* out to still be alive): a fresh "Run OMP" click in a working directory
* with any prior omp history silently launched `--resume <old-id>` instead
* of a clean `omp` invocation, and — with two omp tabs in the same case dir
* — a live pane's `_ompConfig`/`claudeSessionId` could get mis-pinned to
* whichever sibling's file happened to be newest on disk, even though
* nothing was actually being respawned. Resolution now happens only inside
* `_pinOmpRespawnId()`, called by a caller that has already confirmed a
* real respawn is happening.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
@@ -35,7 +40,11 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
function seedOmpSessionFile(id: string) {
mkdirSync(workingDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), '{}');
// resolveAndClaimOmpSessionId() verifies the file's own header (not just
// the filename), mirroring the real `omp` session-file shape — the
// header's `cwd` must match `workingDir` for the candidate to count.
const header = `${JSON.stringify({ type: 'session', id, cwd: workingDir })}\n`;
writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), header);
}
it('a brand-new session (no prior mux session) never inherits an on-disk conversation', async () => {
@@ -56,8 +65,13 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
expect(session.claudeSessionId).toBe(session.id);
});
it('a reattach to an existing tracked mux session still resolves and pins the real id', async () => {
seedOmpSessionFile('real-omp-uuid');
it('a plain reattach to an existing mux session (pane still alive) does NOT pin', async () => {
// Regression for the sibling-aliasing bug: pinning must never be a side
// effect of merely building respawn options for a pane that might still
// be alive (isPaneDead is unconditionally false under IS_TEST_MODE,
// which is what a real "just reattaching, nothing died" boot recovery
// looks like from Session's perspective).
seedOmpSessionFile('sibling-conversation-id');
const muxSession: MuxSession = {
sessionId: 'placeholder',
@@ -81,7 +95,35 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
await session.startInteractive();
const state = session.toState();
expect(state.ompConfig?.resumeSessionId).toBe('real-omp-uuid');
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
expect(session.claudeSessionId).toBe(session.id);
});
it('_pinOmpRespawnId() resolves and pins the real id once a respawn is confirmed', () => {
seedOmpSessionFile('real-omp-uuid');
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
});
sessions.push(session);
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
expect(session.claudeSessionId).toBe('real-omp-uuid');
});
});