feat(tmux): sweep stale omo-agents-<pid> sessions on first spawn

Follow-up to PR #3507 addressing the Oracle-noted operational limitation:
per-PID isolated session names (getIsolatedSessionName(process.pid)) mean
that when an opencode process is SIGKILL'd (or the machine hard-reboots),
the old omo-agents-<old-pid> tmux session survives forever because nothing
is around to kill it.

Added sweepStaleOmoAgentSessions() that:
1. Lists tmux sessions matching /^omo-agents-(\d+)$/
2. For each, checks process.kill(pid, 0) to detect a dead PID
3. Skips our own PID
4. Calls killTmuxSessionIfExists for every session whose owner process is gone

Wired into TmuxSessionManager.onSessionCreated() as a one-shot (guarded by
staleSweepCompleted flag) so it runs lazily on the first subagent spawn when
isolation="session". The flag is reset in cleanup() so subsequent process
restarts re-run the sweep.

6 new tests cover: outside-tmux no-op, no matching sessions, multiple dead
PIDs, current PID skip, live PID skip, list-sessions failure.

Manual E2E verified on real tmux:
- Created omo-agents-99999, sweep killed it
- Spawned our own omo-agents-<pid>, closeTmuxPane returned true even after
  pane auto-destroy from Ctrl+C
- Final tmux list-sessions shows zero omo-agents-* orphans
This commit is contained in:
YeonGyu-Kim
2026-04-18 20:22:00 +09:00
parent a503989a52
commit 104523051d
5 changed files with 283 additions and 0 deletions
@@ -0,0 +1,72 @@
const STALE_SESSION_PATTERN = /^omo-agents-(\d+)$/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
return err?.code === "EPERM"
}
}
async function listOmoAgentSessions(tmux: string): Promise<string[]> {
const { spawn } = await import("./spawn-process")
const proc = spawn([tmux, "list-sessions", "-F", "#{session_name}"], {
stdout: "pipe",
stderr: "pipe",
})
const [stdout, , exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (exitCode !== 0) {
return []
}
return stdout
.split("\n")
.map((line) => line.trim())
.filter((name) => STALE_SESSION_PATTERN.test(name))
}
export async function sweepStaleOmoAgentSessions(): Promise<number> {
const [{ log }, { isInsideTmux }, { getTmuxPath }, { killTmuxSessionIfExists }] = await Promise.all([
import("../../logger"),
import("./environment"),
import("../../../tools/interactive-bash/tmux-path-resolver"),
import("./session-kill"),
])
if (!isInsideTmux()) {
return 0
}
const tmux = await getTmuxPath()
if (!tmux) {
return 0
}
const candidateSessions = await listOmoAgentSessions(tmux)
let killedCount = 0
for (const sessionName of candidateSessions) {
const pidMatch = sessionName.match(STALE_SESSION_PATTERN)
if (!pidMatch) continue
const pid = Number.parseInt(pidMatch[1], 10)
if (!Number.isFinite(pid)) continue
if (pid === process.pid) continue
if (isProcessAlive(pid)) continue
log("[sweepStaleOmoAgentSessions] killing stale session", { sessionName, deadPid: pid })
const killed = await killTmuxSessionIfExists(sessionName)
if (killed) {
killedCount += 1
}
}
return killedCount
}