fix(team-mode): surface port-0 fallback and silent layout skip (#3963)
When ctx.serverUrl had a port string of "0", TmuxSessionManager silently replaced it with the localhost:4096 fallback and createTeamLayout subsequently skipped pane creation without any user-visible signal. The two-step silent failure made team_mode tmux_visualization look broken in default TUI mode. Surface the failure path: - TmuxSessionManager now retains ctx.serverUrl on the instance and exposes it via getCtxServerUrl(), and emits a structured warning log on the port-0 fallback branch naming both the discarded URL and the fallback it landed on. - createTeamLayout's "opencode server not reachable" log is upgraded to a structured warning including ctxServerUrl and a hint to launch with --port N + OPENCODE_PORT=N. No behavior change to the fallback resolution itself - only the silence. Existing port-0 fallback tests still pass; two new tests assert the warning fires on port 0 and is absent for real ports.
This commit is contained in:
@@ -121,7 +121,17 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
|
|||||||
try {
|
try {
|
||||||
const serverUrl = tmuxMgr.getServerUrl()
|
const serverUrl = tmuxMgr.getServerUrl()
|
||||||
if (!(await deps.isServerRunning(serverUrl))) {
|
if (!(await deps.isServerRunning(serverUrl))) {
|
||||||
log("opencode server not reachable, skipping team layout", { serverUrl })
|
const ctxServerUrl = tmuxMgr.getCtxServerUrl?.()
|
||||||
|
log("opencode server not reachable, skipping team layout (see issue #3963)", {
|
||||||
|
kind: "warning",
|
||||||
|
teamRunId,
|
||||||
|
serverUrl,
|
||||||
|
ctxServerUrl: ctxServerUrl && ctxServerUrl !== serverUrl ? ctxServerUrl : undefined,
|
||||||
|
hint:
|
||||||
|
ctxServerUrl && ctxServerUrl !== serverUrl
|
||||||
|
? "ctx.serverUrl was discarded (likely port 0); launch opencode with --port N and OPENCODE_PORT=N to bind a real port"
|
||||||
|
: "no opencode server is listening on the fallback URL",
|
||||||
|
})
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -468,6 +468,69 @@ describe('TmuxSessionManager', () => {
|
|||||||
// then
|
// then
|
||||||
expect(getManagerInternals(manager).serverUrl).toBe('http://localhost:4096')
|
expect(getManagerInternals(manager).serverUrl).toBe('http://localhost:4096')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('logs a structured warning when ctx.serverUrl has port 0 (#3963)', async () => {
|
||||||
|
// given
|
||||||
|
const previousOpenCodePort = process.env.OPENCODE_PORT
|
||||||
|
delete process.env.OPENCODE_PORT
|
||||||
|
const logCalls: Array<{ message: string; data?: unknown }> = []
|
||||||
|
const trackingDeps: TmuxUtilDeps = {
|
||||||
|
...mockTmuxDeps,
|
||||||
|
log: (message, data) => { logCalls.push({ message, data }) },
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const ctx = {
|
||||||
|
...createMockContext(),
|
||||||
|
serverUrl: new URL('http://127.0.0.1:0/'),
|
||||||
|
}
|
||||||
|
const config = createTmuxConfig({ enabled: true })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const manager = new TmuxSessionManager(ctx, config, trackingDeps)
|
||||||
|
|
||||||
|
// then
|
||||||
|
const warning = logCalls.find((entry) => entry.message.includes('ctx.serverUrl has port 0'))
|
||||||
|
expect(warning).toBeDefined()
|
||||||
|
expect(warning?.data).toMatchObject({
|
||||||
|
kind: 'warning',
|
||||||
|
ctxServerUrl: 'http://127.0.0.1:0/',
|
||||||
|
fallbackUrl: 'http://localhost:4096',
|
||||||
|
})
|
||||||
|
expect(manager.getCtxServerUrl()).toBe('http://127.0.0.1:0/')
|
||||||
|
} finally {
|
||||||
|
if (previousOpenCodePort === undefined) {
|
||||||
|
delete process.env.OPENCODE_PORT
|
||||||
|
} else {
|
||||||
|
process.env.OPENCODE_PORT = previousOpenCodePort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not warn when ctx.serverUrl has a real port', async () => {
|
||||||
|
// given
|
||||||
|
const logCalls: Array<{ message: string; data?: unknown }> = []
|
||||||
|
const trackingDeps: TmuxUtilDeps = {
|
||||||
|
...mockTmuxDeps,
|
||||||
|
log: (message, data) => { logCalls.push({ message, data }) },
|
||||||
|
}
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const ctx = {
|
||||||
|
...createMockContext(),
|
||||||
|
serverUrl: new URL('http://127.0.0.1:12345/'),
|
||||||
|
}
|
||||||
|
const config = createTmuxConfig({ enabled: true })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const manager = new TmuxSessionManager(ctx, config, trackingDeps)
|
||||||
|
|
||||||
|
// then
|
||||||
|
const warning = logCalls.find((entry) => entry.message.includes('ctx.serverUrl has port 0'))
|
||||||
|
expect(warning).toBeUndefined()
|
||||||
|
expect(manager.getCtxServerUrl()).toBe('http://127.0.0.1:12345/')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getServerUrl', () => {
|
describe('getServerUrl', () => {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export class TmuxSessionManager {
|
|||||||
private tmuxConfig: TmuxConfig
|
private tmuxConfig: TmuxConfig
|
||||||
private projectDirectory: string
|
private projectDirectory: string
|
||||||
private serverUrl: string
|
private serverUrl: string
|
||||||
|
private ctxServerUrl: string | undefined
|
||||||
private sourcePaneId: string | undefined
|
private sourcePaneId: string | undefined
|
||||||
private sessions = new Map<string, TrackedSession>()
|
private sessions = new Map<string, TrackedSession>()
|
||||||
private pendingSessions = new Set<string>()
|
private pendingSessions = new Set<string>()
|
||||||
@@ -122,11 +123,22 @@ export class TmuxSessionManager {
|
|||||||
: "4096"
|
: "4096"
|
||||||
const fallbackUrl = `http://localhost:${defaultPort}`
|
const fallbackUrl = `http://localhost:${defaultPort}`
|
||||||
const rawServerUrl = ctx.serverUrl?.toString()
|
const rawServerUrl = ctx.serverUrl?.toString()
|
||||||
|
this.ctxServerUrl = rawServerUrl
|
||||||
try {
|
try {
|
||||||
if (rawServerUrl) {
|
if (rawServerUrl) {
|
||||||
const parsed = new URL(rawServerUrl)
|
const parsed = new URL(rawServerUrl)
|
||||||
const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
|
const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
|
||||||
this.serverUrl = port === '0' ? fallbackUrl : rawServerUrl
|
if (port === '0') {
|
||||||
|
this.deps.log(
|
||||||
|
"[tmux-session-manager] ctx.serverUrl has port 0; falling back. " +
|
||||||
|
"team_mode tmux visualization will silently skip if nothing is listening on the fallback URL. " +
|
||||||
|
"Launch opencode with --port N and OPENCODE_PORT=N to bind a real port (see issue #3963).",
|
||||||
|
{ kind: "warning", ctxServerUrl: rawServerUrl, fallbackUrl },
|
||||||
|
)
|
||||||
|
this.serverUrl = fallbackUrl
|
||||||
|
} else {
|
||||||
|
this.serverUrl = rawServerUrl
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.serverUrl = fallbackUrl
|
this.serverUrl = fallbackUrl
|
||||||
}
|
}
|
||||||
@@ -256,6 +268,10 @@ export class TmuxSessionManager {
|
|||||||
return this.serverUrl
|
return this.serverUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCtxServerUrl(): string | undefined {
|
||||||
|
return this.ctxServerUrl
|
||||||
|
}
|
||||||
|
|
||||||
private removeTrackedSession(sessionId: string): void {
|
private removeTrackedSession(sessionId: string): void {
|
||||||
this.sessions.delete(sessionId)
|
this.sessions.delete(sessionId)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user