refactor(tmux-subagent): stabilize polling, execution and session lifecycle with runner

This commit is contained in:
YeonGyu-Kim
2026-04-28 10:45:17 +09:00
parent 90e6e0cdce
commit c8b225099c
8 changed files with 719 additions and 355 deletions
@@ -10,6 +10,7 @@ export interface ActionResult {
export interface ExecuteContext { export interface ExecuteContext {
config: TmuxConfig config: TmuxConfig
directory: string
serverUrl: string serverUrl: string
windowState: WindowState windowState: WindowState
} }
@@ -55,6 +56,7 @@ export async function executeActionWithDeps(
action.description, action.description,
ctx.config, ctx.config,
ctx.serverUrl, ctx.serverUrl,
ctx.directory,
) )
return { return {
success: result.success, success: result.success,
@@ -67,6 +69,7 @@ export async function executeActionWithDeps(
action.description, action.description,
ctx.config, ctx.config,
ctx.serverUrl, ctx.serverUrl,
ctx.directory,
action.targetPaneId, action.targetPaneId,
action.splitDirection, action.splitDirection,
) )
+14 -14
View File
@@ -10,10 +10,7 @@ import {
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
import { queryWindowState } from "./pane-state-querier" import { queryWindowState } from "./pane-state-querier"
import { log } from "../../shared" import { log } from "../../shared"
import type { import type { ActionResult } from "./action-executor-core"
ActionResult,
ActionExecutorDeps,
} from "./action-executor-core"
export type { ActionExecutorDeps, ActionResult } from "./action-executor-core" export type { ActionExecutorDeps, ActionResult } from "./action-executor-core"
@@ -25,6 +22,7 @@ export interface ExecuteActionsResult {
export interface ExecuteContext { export interface ExecuteContext {
config: TmuxConfig config: TmuxConfig
directory: string
serverUrl: string serverUrl: string
windowState: WindowState windowState: WindowState
sourcePaneId?: string sourcePaneId?: string
@@ -79,10 +77,11 @@ export async function executeAction(
const result = await replaceTmuxPane( const result = await replaceTmuxPane(
action.paneId, action.paneId,
action.newSessionId, action.newSessionId,
action.description, action.description,
ctx.config, ctx.config,
ctx.serverUrl ctx.serverUrl,
) ctx.directory,
)
if (result.success) { if (result.success) {
await enforceLayoutAndMainPane(ctx) await enforceLayoutAndMainPane(ctx)
} }
@@ -94,12 +93,13 @@ export async function executeAction(
const result = await spawnTmuxPane( const result = await spawnTmuxPane(
action.sessionId, action.sessionId,
action.description, action.description,
ctx.config, ctx.config,
ctx.serverUrl, ctx.serverUrl,
action.targetPaneId, ctx.directory,
action.splitDirection action.targetPaneId,
) action.splitDirection
)
if (result.success) { if (result.success) {
await enforceLayoutAndMainPane(ctx) await enforceLayoutAndMainPane(ctx)
@@ -0,0 +1,11 @@
const ATTACHABLE_SESSION_STATUSES = ["idle", "running"] as const
export type AttachableSessionStatus = (typeof ATTACHABLE_SESSION_STATUSES)[number]
export function isAttachableSessionStatus(
status: string | undefined,
): status is AttachableSessionStatus {
return ATTACHABLE_SESSION_STATUSES.some(
(attachableSessionStatus) => attachableSessionStatus === status,
)
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,3 @@
import { spawn } from "../../shared/bun-spawn-shim"
import type { WindowState, TmuxPaneInfo } from "./types" import type { WindowState, TmuxPaneInfo } from "./types"
import { parsePaneStateOutput } from "./pane-state-parser" import { parsePaneStateOutput } from "./pane-state-parser"
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
@@ -7,28 +6,22 @@ import { log } from "../../shared"
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> { export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
const tmux = await getTmuxPath() const tmux = await getTmuxPath()
if (!tmux) return null if (!tmux) return null
const { runTmuxCommand } = await import("../../shared/tmux")
const proc = spawn( const result = await runTmuxCommand(tmux, [
[ "list-panes",
tmux, "-t",
"list-panes", sourcePaneId,
"-t", "-F",
sourcePaneId, "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}",
"-F", ])
"#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}",
],
{ stdout: "pipe", stderr: "pipe" }
)
const exitCode = await proc.exited if (result.exitCode !== 0) {
const stdout = await new Response(proc.stdout).text() log("[pane-state-querier] list-panes failed", { exitCode: result.exitCode })
return null
}
if (exitCode !== 0) { const parsedPaneState = parsePaneStateOutput(result.output)
log("[pane-state-querier] list-panes failed", { exitCode })
return null
}
const parsedPaneState = parsePaneStateOutput(stdout)
if (!parsedPaneState) { if (!parsedPaneState) {
log("[pane-state-querier] failed to parse pane state output", { log("[pane-state-querier] failed to parse pane state output", {
sourcePaneId, sourcePaneId,
+39 -24
View File
@@ -1,11 +1,13 @@
import type { OpencodeClient } from "../../tools/delegate-task/types" import type { OpencodeClient } from "../../tools/delegate-task/types"
import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux" import {
POLL_INTERVAL_BACKGROUND_MS,
SESSION_MISSING_GRACE_MS,
SESSION_TIMEOUT_MS,
} from "../../shared/tmux"
import type { TrackedSession } from "./types" import type { TrackedSession } from "./types"
import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux"
import { log } from "../../shared" import { log } from "../../shared"
import { normalizeSDKResponse } from "../../shared" import { normalizeSDKResponse } from "../../shared"
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000
const STABLE_POLLS_REQUIRED = 3 const STABLE_POLLS_REQUIRED = 3
@@ -86,30 +88,42 @@ export class TmuxPollingManager {
if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) { if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) {
const activityVersion = tracked.activityVersion ?? 0 const activityVersion = tracked.activityVersion ?? 0
if (tracked.observedIdleActivityVersion === activityVersion) { if (tracked.observedIdleActivityVersion !== activityVersion) {
tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1 tracked.stableIdlePolls = 1
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
const recheckResult = await this.client.session.status({ path: undefined })
const recheckStatuses = normalizeSDKResponse(recheckResult, {} as Record<string, { type: string }>)
const recheckStatus = recheckStatuses[sessionId]
if (recheckStatus?.type === "idle") {
shouldCloseViaStability = true
} else {
tracked.stableIdlePolls = 0
log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", {
sessionId,
recheckStatus: recheckStatus?.type,
})
}
}
} else {
tracked.stableIdlePolls = 0
tracked.observedIdleActivityVersion = activityVersion tracked.observedIdleActivityVersion = activityVersion
} else {
tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1
}
if ((tracked.stableIdlePolls ?? 0) >= STABLE_POLLS_REQUIRED) {
const stableWindowActivityVersion = tracked.observedIdleActivityVersion ?? activityVersion
const recheckResult = await this.client.session.status({ path: undefined })
const recheckStatuses = normalizeSDKResponse(recheckResult, {} as Record<string, { type: string }>)
const recheckStatus = recheckStatuses[sessionId]
const latestTracked = this.sessions.get(sessionId) ?? tracked
const recheckActivityVersion = latestTracked.activityVersion ?? 0
if (recheckActivityVersion !== stableWindowActivityVersion) {
latestTracked.stableIdlePolls = 0
latestTracked.observedIdleActivityVersion = recheckActivityVersion
log("[tmux-session-manager] stability recheck aborted after new activity", {
sessionId,
stableWindowActivityVersion,
recheckActivityVersion,
})
} else if (recheckStatus?.type === "idle") {
shouldCloseViaStability = true
} else {
latestTracked.stableIdlePolls = 0
log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", {
sessionId,
recheckStatus: recheckStatus?.type,
})
}
} }
} else if (!isIdle) { } else if (!isIdle) {
tracked.stableIdlePolls = 0 tracked.stableIdlePolls = 0
tracked.observedIdleActivityVersion = undefined
} }
log("[tmux-session-manager] session check", { log("[tmux-session-manager] session check", {
@@ -126,7 +140,8 @@ export class TmuxPollingManager {
shouldCloseViaStability, shouldCloseViaStability,
}) })
if (shouldCloseViaStability || missingTooLong || isTimedOut) { if (!tracked.closePending && (shouldCloseViaStability || missingTooLong || isTimedOut)) {
tracked.closePending = true
sessionsToClose.push(sessionId) sessionsToClose.push(sessionId)
} }
} }
+7 -1
View File
@@ -30,6 +30,7 @@ export interface SessionPollingController {
export function createSessionPollingController(params: { export function createSessionPollingController(params: {
client: OpencodeClient client: OpencodeClient
tmuxConfig: TmuxConfig tmuxConfig: TmuxConfig
directory: string
serverUrl: string serverUrl: string
sourcePaneId: string | undefined sourcePaneId: string | undefined
sessions: Map<string, TrackedSession> sessions: Map<string, TrackedSession>
@@ -49,7 +50,12 @@ export function createSessionPollingController(params: {
if (state) { if (state) {
await executeAction( await executeAction(
{ type: "close", paneId: tracked.paneId, sessionId }, { type: "close", paneId: tracked.paneId, sessionId },
{ config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state }, {
config: params.tmuxConfig,
directory: params.directory,
serverUrl: params.serverUrl,
windowState: state,
},
) )
} }
@@ -4,6 +4,7 @@ import {
SESSION_READY_TIMEOUT_MS, SESSION_READY_TIMEOUT_MS,
} from "../../shared/tmux" } from "../../shared/tmux"
import { log } from "../../shared" import { log } from "../../shared"
import { isAttachableSessionStatus } from "./attachable-session-status"
import { parseSessionStatusMap } from "./session-status-parser" import { parseSessionStatusMap } from "./session-status-parser"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@@ -18,11 +19,12 @@ export async function waitForSessionReady(params: {
try { try {
const statusResult = await params.client.session.status({ path: undefined }) const statusResult = await params.client.session.status({ path: undefined })
const allStatuses = parseSessionStatusMap(statusResult.data) const allStatuses = parseSessionStatusMap(statusResult.data)
const sessionStatus = allStatuses[params.sessionId]?.type
if (allStatuses[params.sessionId]) { if (isAttachableSessionStatus(sessionStatus)) {
log("[tmux-session-manager] session ready", { log("[tmux-session-manager] session ready", {
sessionId: params.sessionId, sessionId: params.sessionId,
status: allStatuses[params.sessionId].type, status: sessionStatus,
waitedMs: Date.now() - startTime, waitedMs: Date.now() - startTime,
}) })
return true return true