0465562fa4
`opencode attach` was invoked inside a freshly-split tmux pane before the child session appeared in the opencode server's status map. The process exited immediately (session not found), tmux auto-closed the pane, and the subagent ran invisibly in the background — the race documented in #3505. Fix: call `waitForSessionReady` *before* `executeActions` in `session-created-handler.ts`, mirroring the guard already present in `TmuxSessionManager.ensureSessionReadyBeforeSpawn()`. If the session does not become attachable within the timeout the handler returns early without spawning a pane at all, eliminating the transient-pane and silent-close failure modes. The now-unreachable post-spawn readiness-check / pane-close cleanup branch is removed. Adds a regression test suite (session-created-handler.test.ts) covering: - not-ready session → no pane spawned, no polling started - duplicate session.created → idempotent - non session.created event type → no action - already-tracked session → idempotent Closes #3505 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
173 lines
5.3 KiB
TypeScript
173 lines
5.3 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import type { TmuxConfig } from "../../config/schema"
|
|
import type { CapacityConfig, TrackedSession } from "./types"
|
|
import { log } from "../../shared"
|
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
|
import { queryWindowState } from "./pane-state-querier"
|
|
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
|
|
import { executeActions } from "./action-executor"
|
|
import type { SessionCreatedEvent } from "./session-created-event"
|
|
import { createTrackedSession } from "./tracked-session-state"
|
|
|
|
type OpencodeClient = PluginInput["client"]
|
|
|
|
export interface SessionCreatedHandlerDeps {
|
|
client: OpencodeClient
|
|
tmuxConfig: TmuxConfig
|
|
directory: string
|
|
serverUrl: string
|
|
sourcePaneId: string | undefined
|
|
sessions: Map<string, TrackedSession>
|
|
pendingSessions: Set<string>
|
|
isInsideTmux: () => boolean
|
|
isEnabled: () => boolean
|
|
getCapacityConfig: () => CapacityConfig
|
|
getSessionMappings: () => SessionMapping[]
|
|
waitForSessionReady: (sessionId: string) => Promise<boolean>
|
|
startPolling: () => void
|
|
}
|
|
|
|
export async function handleSessionCreated(
|
|
deps: SessionCreatedHandlerDeps,
|
|
event: SessionCreatedEvent,
|
|
): Promise<void> {
|
|
const enabled = deps.isEnabled()
|
|
log("[tmux-session-manager] onSessionCreated called", {
|
|
enabled,
|
|
tmuxConfigEnabled: deps.tmuxConfig.enabled,
|
|
isInsideTmux: deps.isInsideTmux(),
|
|
eventType: event.type,
|
|
infoId: event.properties?.info?.id,
|
|
infoParentID: event.properties?.info?.parentID,
|
|
})
|
|
|
|
if (!enabled) return
|
|
if (event.type !== "session.created") return
|
|
|
|
const info = event.properties?.info
|
|
const sessionId = resolveSessionEventID(event.properties)
|
|
if (!sessionId || !info?.parentID) return
|
|
|
|
const title = info.title ?? "Subagent"
|
|
|
|
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
|
|
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
|
return
|
|
}
|
|
|
|
if (!deps.sourcePaneId) {
|
|
log("[tmux-session-manager] no source pane id")
|
|
return
|
|
}
|
|
|
|
deps.pendingSessions.add(sessionId)
|
|
|
|
try {
|
|
const state = await queryWindowState(deps.sourcePaneId)
|
|
if (!state) {
|
|
log("[tmux-session-manager] failed to query window state")
|
|
return
|
|
}
|
|
|
|
log("[tmux-session-manager] window state queried", {
|
|
windowWidth: state.windowWidth,
|
|
mainPane: state.mainPane?.paneId,
|
|
agentPaneCount: state.agentPanes.length,
|
|
agentPanes: state.agentPanes.map((p) => p.paneId),
|
|
})
|
|
|
|
const decision = decideSpawnActions(
|
|
state,
|
|
sessionId,
|
|
title,
|
|
deps.getCapacityConfig(),
|
|
deps.getSessionMappings(),
|
|
)
|
|
|
|
log("[tmux-session-manager] spawn decision", {
|
|
canSpawn: decision.canSpawn,
|
|
reason: decision.reason,
|
|
actionCount: decision.actions.length,
|
|
actions: decision.actions.map((a) => {
|
|
if (a.type === "close") return { type: "close", paneId: a.paneId }
|
|
if (a.type === "replace") {
|
|
return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
|
|
}
|
|
return { type: "spawn", sessionId: a.sessionId }
|
|
}),
|
|
})
|
|
|
|
if (!decision.canSpawn) {
|
|
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
|
return
|
|
}
|
|
|
|
// Wait for the child session to be registered in the opencode server's status
|
|
// map BEFORE spawning the tmux pane. If we spawn first, `opencode attach`
|
|
// exits immediately (session not yet visible), tmux auto-closes the pane, and
|
|
// the subagent runs invisibly in the background — the bug described in #3505.
|
|
const sessionReady = await deps.waitForSessionReady(sessionId)
|
|
if (!sessionReady) {
|
|
log("[tmux-session-manager] session readiness failed before spawn", {
|
|
sessionId,
|
|
stage: "session.created",
|
|
})
|
|
return
|
|
}
|
|
|
|
const result = await executeActions(decision.actions, {
|
|
config: deps.tmuxConfig,
|
|
directory: deps.directory,
|
|
serverUrl: deps.serverUrl,
|
|
windowState: state,
|
|
})
|
|
|
|
for (const { action, result: actionResult } of result.results) {
|
|
if (action.type === "close" && actionResult.success) {
|
|
deps.sessions.delete(action.sessionId)
|
|
log("[tmux-session-manager] removed closed session from cache", {
|
|
sessionId: action.sessionId,
|
|
})
|
|
}
|
|
if (action.type === "replace" && actionResult.success) {
|
|
deps.sessions.delete(action.oldSessionId)
|
|
log("[tmux-session-manager] removed replaced session from cache", {
|
|
oldSessionId: action.oldSessionId,
|
|
newSessionId: action.newSessionId,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (!result.success || !result.spawnedPaneId) {
|
|
log("[tmux-session-manager] spawn failed", {
|
|
success: result.success,
|
|
results: result.results.map((r) => ({
|
|
type: r.action.type,
|
|
success: r.result.success,
|
|
error: r.result.error,
|
|
})),
|
|
})
|
|
return
|
|
}
|
|
|
|
deps.sessions.set(
|
|
sessionId,
|
|
createTrackedSession({
|
|
sessionId,
|
|
paneId: result.spawnedPaneId,
|
|
description: title,
|
|
}),
|
|
)
|
|
|
|
log("[tmux-session-manager] pane spawned and tracked", {
|
|
sessionId,
|
|
paneId: result.spawnedPaneId,
|
|
sessionReady,
|
|
})
|
|
|
|
deps.startPolling()
|
|
} finally {
|
|
deps.pendingSessions.delete(sessionId)
|
|
}
|
|
}
|