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
@@ -58,6 +58,7 @@ const mockSpawnTmuxSession = mock<(
paneId: '%isolated-session',
}))
const mockKillTmuxSessionIfExists = mock<(sessionName: string) => Promise<boolean>>(async () => true)
const mockSweepStaleOmoAgentSessions = mock<() => Promise<number>>(async () => 0)
const mockIsInsideTmux = mock<() => boolean>(() => true)
const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
@@ -102,6 +103,7 @@ mock.module('../../shared/tmux', () => {
spawnTmuxSession: mockSpawnTmuxSession,
killTmuxSessionIfExists: mockKillTmuxSessionIfExists,
getIsolatedSessionName: (pid: number = 12345) => `omo-agents-${pid}`,
sweepStaleOmoAgentSessions: mockSweepStaleOmoAgentSessions,
}
})
+25
View File
@@ -12,6 +12,7 @@ import {
spawnTmuxSession,
killTmuxSessionIfExists,
getIsolatedSessionName,
sweepStaleOmoAgentSessions,
} from "../../shared/tmux"
import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
@@ -65,6 +66,7 @@ export class TmuxSessionManager {
private isolatedContainerPaneId: string | undefined
private isolatedWindowPaneId: string | undefined
private isolatedContainerNullStateCount = 0
private staleSweepCompleted = false
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
this.client = ctx.client
this.tmuxConfig = tmuxConfig
@@ -668,6 +670,7 @@ export class TmuxSessionManager {
return
}
await this.sweepStaleIsolatedSessionsOnce()
await this.retryPendingCloses()
if (
@@ -985,6 +988,28 @@ export class TmuxSessionManager {
}
}
this.staleSweepCompleted = false
}
private async sweepStaleIsolatedSessionsOnce(): Promise<void> {
if (this.staleSweepCompleted) return
if (this.tmuxConfig.isolation !== "session") {
this.staleSweepCompleted = true
return
}
this.staleSweepCompleted = true
try {
const killed = await sweepStaleOmoAgentSessions()
if (killed > 0) {
log("[tmux-session-manager] stale isolated sessions swept", { killed })
}
} catch (error) {
log("[tmux-session-manager] stale sweep failed", {
error: String(error),
})
}
log("[tmux-session-manager] cleanup complete")
}
}