refactor(tmux-subagent): stabilize polling, execution and session lifecycle with runner
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user