fix(tmux): defer subagent attach until pane focus
This commit is contained in:
committed by
YeonGyu-Kim
parent
6e5a127f88
commit
688bb551b2
@@ -12,6 +12,7 @@ import {
|
||||
killTmuxSessionIfExists,
|
||||
getIsolatedSessionName,
|
||||
sweepStaleOmoAgentSessions,
|
||||
activateTmuxPane,
|
||||
} from "../../shared/tmux"
|
||||
import { queryWindowState as defaultQueryWindowState } from "./pane-state-querier"
|
||||
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
|
||||
@@ -133,7 +134,9 @@ export class TmuxSessionManager {
|
||||
this.client,
|
||||
this.sessions,
|
||||
this.closeSessionFromPolling.bind(this),
|
||||
this.retryPendingCloses.bind(this)
|
||||
this.retryPendingCloses.bind(this),
|
||||
this.queryWindowStateSafely.bind(this),
|
||||
this.activateTrackedSessionPane.bind(this),
|
||||
)
|
||||
this.deps.log("[tmux-session-manager] initialized", {
|
||||
configEnabled: this.tmuxConfig.enabled,
|
||||
@@ -337,6 +340,10 @@ export class TmuxSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async activateTrackedSessionPane(tracked: TrackedSession): Promise<boolean> {
|
||||
return activateTmuxPane(tracked.paneId, tracked.sessionId, this.serverUrl, this.projectDirectory)
|
||||
}
|
||||
|
||||
private windowStateContainsPane(state: WindowState, paneId: string): boolean {
|
||||
return state.mainPane?.paneId === paneId
|
||||
|| state.agentPanes.some((pane) => pane.paneId === paneId)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
import type { TrackedSession } from "./types"
|
||||
import type { TrackedSession, WindowState } from "./types"
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
|
||||
describe("TmuxPollingManager overlap", () => {
|
||||
@@ -11,6 +11,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
sessionId: "ses-1",
|
||||
paneId: "%1",
|
||||
description: "test",
|
||||
attachActivated: true,
|
||||
createdAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
closePending: false,
|
||||
@@ -64,6 +65,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
sessionId: "ses-1",
|
||||
paneId: "%1",
|
||||
description: "test",
|
||||
attachActivated: true,
|
||||
createdAt: new Date(Date.now() - 15_000),
|
||||
lastSeenAt: new Date(),
|
||||
closePending: false,
|
||||
@@ -117,6 +119,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
sessionId: "ses-1",
|
||||
paneId: "%1",
|
||||
description: "test",
|
||||
attachActivated: true,
|
||||
createdAt: new Date(now - 1_000),
|
||||
lastSeenAt: new Date(now - 7_000),
|
||||
closePending: false,
|
||||
@@ -156,6 +159,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
sessionId: "ses-1",
|
||||
paneId: "%1",
|
||||
description: "test",
|
||||
attachActivated: true,
|
||||
createdAt: new Date(now - 11 * 60 * 1000),
|
||||
lastSeenAt: new Date(now),
|
||||
closePending: false,
|
||||
@@ -194,6 +198,7 @@ describe("TmuxPollingManager overlap", () => {
|
||||
sessionId: "ses-1",
|
||||
paneId: "%1",
|
||||
description: "test",
|
||||
attachActivated: true,
|
||||
createdAt: new Date(Date.now() - 15_000),
|
||||
lastSeenAt: new Date(),
|
||||
closePending: false,
|
||||
@@ -237,4 +242,98 @@ describe("TmuxPollingManager overlap", () => {
|
||||
// then
|
||||
expect(closedSessionIds).toEqual([])
|
||||
})
|
||||
|
||||
test("activates focused panes once before polling statuses", async () => {
|
||||
//#given
|
||||
const sessions = new Map<string, TrackedSession>()
|
||||
const tracked: TrackedSession = {
|
||||
sessionId: "ses-1",
|
||||
paneId: "%1",
|
||||
description: "test",
|
||||
attachActivated: false,
|
||||
createdAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
closePending: false,
|
||||
closeRetryCount: 0,
|
||||
activityVersion: 0,
|
||||
}
|
||||
sessions.set("ses-1", tracked)
|
||||
|
||||
const activatedSessionIds: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { "ses-1": { type: "running" } } }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const windowState: WindowState = {
|
||||
windowWidth: 160,
|
||||
windowHeight: 48,
|
||||
mainPane: null,
|
||||
agentPanes: [
|
||||
{ paneId: "%1", width: 80, height: 24, left: 0, top: 0, title: "agent", isActive: true },
|
||||
],
|
||||
}
|
||||
const manager = new TmuxPollingManager(
|
||||
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||
sessions,
|
||||
async () => {},
|
||||
undefined,
|
||||
async () => windowState,
|
||||
async (session) => {
|
||||
activatedSessionIds.push(session.sessionId)
|
||||
return true
|
||||
},
|
||||
)
|
||||
const pollSessions = unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager).pollSessions
|
||||
|
||||
//#when
|
||||
await pollSessions.call(manager)
|
||||
await pollSessions.call(manager)
|
||||
|
||||
//#then
|
||||
expect(activatedSessionIds).toEqual(["ses-1"])
|
||||
expect(tracked.attachActivated).toBe(true)
|
||||
})
|
||||
|
||||
test("does not close non-activated panes before focus activation", async () => {
|
||||
//#given
|
||||
const sessions = new Map<string, TrackedSession>()
|
||||
sessions.set("ses-1", {
|
||||
sessionId: "ses-1",
|
||||
paneId: "%1",
|
||||
description: "test",
|
||||
attachActivated: false,
|
||||
createdAt: new Date(Date.now() - 15_000),
|
||||
lastSeenAt: new Date(),
|
||||
closePending: false,
|
||||
closeRetryCount: 0,
|
||||
activityVersion: 0,
|
||||
stableIdlePolls: 3,
|
||||
observedIdleActivityVersion: 0,
|
||||
})
|
||||
|
||||
const closedSessionIds: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { "ses-1": { type: "idle" } } }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new TmuxPollingManager(
|
||||
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||
sessions,
|
||||
async (sessionId) => {
|
||||
closedSessionIds.push(sessionId)
|
||||
},
|
||||
)
|
||||
const pollSessions = unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager).pollSessions
|
||||
|
||||
//#when
|
||||
await pollSessions.call(manager)
|
||||
|
||||
//#then
|
||||
expect(closedSessionIds).toEqual([])
|
||||
expect(sessions.has("ses-1")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
SESSION_TIMEOUT_MS,
|
||||
} from "../../shared/tmux"
|
||||
import type { TrackedSession } from "./types"
|
||||
import type { TrackedSession, WindowState } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||
@@ -20,7 +20,9 @@ export class TmuxPollingManager {
|
||||
private client: OpencodeClient,
|
||||
private sessions: Map<string, TrackedSession>,
|
||||
private closeSessionById: (sessionId: string) => Promise<void>,
|
||||
private retryPendingCloses?: () => Promise<void>
|
||||
private retryPendingCloses?: () => Promise<void>,
|
||||
private getWindowState?: () => Promise<WindowState | null>,
|
||||
private activateSessionPane?: (tracked: TrackedSession) => Promise<boolean>,
|
||||
) {}
|
||||
|
||||
handleEvent(event: { type: string; properties?: Record<string, unknown> }): void {
|
||||
@@ -60,6 +62,8 @@ export class TmuxPollingManager {
|
||||
return
|
||||
}
|
||||
|
||||
await this.activateFocusedPanes()
|
||||
|
||||
const statusResult = await this.client.session.status({ path: undefined })
|
||||
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
||||
|
||||
@@ -72,6 +76,14 @@ export class TmuxPollingManager {
|
||||
const sessionsToClose: string[] = []
|
||||
|
||||
for (const [sessionId, tracked] of this.sessions.entries()) {
|
||||
if (!tracked.attachActivated) {
|
||||
log("[tmux-session-manager] skipping close checks for non-activated pane", {
|
||||
sessionId,
|
||||
paneId: tracked.paneId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const status = allStatuses[sessionId]
|
||||
const isIdle = status?.type === "idle"
|
||||
|
||||
@@ -185,4 +197,34 @@ export class TmuxPollingManager {
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async activateFocusedPanes(): Promise<void> {
|
||||
if (!this.getWindowState || !this.activateSessionPane || this.sessions.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = await this.getWindowState().catch(() => null)
|
||||
if (!state) return
|
||||
|
||||
const panes = [state.mainPane, ...state.agentPanes].filter((pane): pane is NonNullable<typeof pane> => Boolean(pane))
|
||||
const activePaneIds = new Set(panes.filter((pane) => pane.isActive).map((pane) => pane.paneId))
|
||||
if (activePaneIds.size === 0) return
|
||||
|
||||
for (const tracked of this.sessions.values()) {
|
||||
if (tracked.attachActivated) continue
|
||||
if (!activePaneIds.has(tracked.paneId)) continue
|
||||
|
||||
const activated = await this.activateSessionPane(tracked)
|
||||
if (activated) {
|
||||
tracked.attachActivated = true
|
||||
tracked.lastSeenAt = new Date()
|
||||
tracked.stableIdlePolls = 0
|
||||
tracked.observedIdleActivityVersion = tracked.activityVersion
|
||||
log("[tmux-session-manager] activated focused pane", {
|
||||
sessionId: tracked.sessionId,
|
||||
paneId: tracked.paneId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function createTrackedSession(params: {
|
||||
sessionId: params.sessionId,
|
||||
paneId: params.paneId,
|
||||
description: params.description,
|
||||
attachActivated: false,
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
closePending: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface TrackedSession {
|
||||
sessionId: string
|
||||
paneId: string
|
||||
description: string
|
||||
attachActivated: boolean
|
||||
createdAt: Date
|
||||
lastSeenAt: Date
|
||||
closePending: boolean
|
||||
|
||||
Reference in New Issue
Block a user