Merge pull request #147 from omdsh-dev/fix/node-pty-load-failure-repair
fix: node-pty 加载失败降级为终端修复提示,不再拖垮 server(#140)
This commit is contained in:
+59
-16
@@ -37,6 +37,12 @@ import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import {
|
||||
DSH_NODE_PTY_RANGE,
|
||||
depsStatus,
|
||||
loadNodePty,
|
||||
PTY_DEPS_MISSING,
|
||||
} from './pty-deps.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
@@ -179,8 +185,8 @@ export interface SidebarSettingsFace {
|
||||
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
|
||||
function buildApi(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ptyManager: PtyManager | null,
|
||||
agentPtyRegistry: AgentPtyRegistry | null,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Record<string, ApiMethod> {
|
||||
@@ -313,7 +319,9 @@ function buildApi(
|
||||
'pty.close': (payload) => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const tab = requireString(payload, 'tab')
|
||||
ptyManager.close(`${sessionId}:${tab}`)
|
||||
// Degraded mode (node-pty unavailable): no live pty can exist, so a
|
||||
// no-op ok is the honest answer — never an error the client must show.
|
||||
ptyManager?.close(`${sessionId}:${tab}`)
|
||||
return { ok: true }
|
||||
},
|
||||
// Release an agent terminal by uuid. The WS close frame already does
|
||||
@@ -322,9 +330,14 @@ function buildApi(
|
||||
// tab never leaves a zombie pty behind. Idempotent.
|
||||
'agent-pty.close': (payload) => {
|
||||
const uuid = requireString(payload, 'uuid')
|
||||
agentPtyRegistry.close(uuid)
|
||||
agentPtyRegistry?.close(uuid)
|
||||
return { ok: true }
|
||||
},
|
||||
// Terminal dependency status (issue #140): after a WS close 1011 with
|
||||
// reason `pty-deps-missing` the client fetches the full repair details
|
||||
// here — the close reason itself is capped at 123 bytes, too small for
|
||||
// the pasteable command.
|
||||
'terminal.deps': () => depsStatus(),
|
||||
// Background jobs: read one job's output (a REPLAY of what the model
|
||||
// has read so far, from the owner session's event log — the model's
|
||||
// job_output cursor is never touched, so the human pane can never steal
|
||||
@@ -436,13 +449,25 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// gateway fence derives its list from. Read per request from the live
|
||||
// service value; a replaced list takes effect without a plugin restart.
|
||||
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
|
||||
const ptyManager = new PtyManager(terminalShell, resolved.terminalsPerSession)
|
||||
// node-pty is loaded lazily, never at module top level (issue #140): a
|
||||
// missing or broken install must degrade THIS plugin — terminal tab shows
|
||||
// a repair command, agent terminal tools stay unregistered — instead of
|
||||
// failing the plugin load and taking the whole `dsh web` server down.
|
||||
const nodePty = loadNodePty()
|
||||
if (nodePty === null) {
|
||||
const status = depsStatus()
|
||||
const detail = status.ok
|
||||
? 'unknown cause'
|
||||
: `${status.cause}. Repair: ${status.command}`
|
||||
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
|
||||
}
|
||||
const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, nodePty) : null
|
||||
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
|
||||
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
|
||||
// uncapped, and torn down with the plugin. The model creates terminals here
|
||||
// through the terminal_create tool; the sidebar view attaches through the
|
||||
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
|
||||
const agentPtyRegistry = new AgentPtyRegistry(terminalShell)
|
||||
const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, nodePty) : null
|
||||
|
||||
// ── User-facing "Side card" preferences ──────────────────────────────────
|
||||
// Register the namespace with the settings provider so the Settings page
|
||||
@@ -461,6 +486,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
||||
if (scope.get().agentTerminalTools) {
|
||||
if (toolsDisposers === null) {
|
||||
// Degraded mode (node-pty unavailable): never register the terminal
|
||||
// tools — every one of them would fail at spawn time.
|
||||
if (agentPtyRegistry === null) return
|
||||
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
|
||||
}
|
||||
} else if (toolsDisposers !== null) {
|
||||
@@ -469,7 +497,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// The feature is off: release every agent terminal the model created
|
||||
// while it was on (they are only reachable through the tools). The
|
||||
// registry change fires the push, so the sidebar reconciles them away.
|
||||
agentPtyRegistry.disposeAll()
|
||||
agentPtyRegistry?.disposeAll()
|
||||
}
|
||||
}
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
@@ -700,8 +728,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
|
||||
ctx.effect(() => () => {
|
||||
toolsDisposers?.()
|
||||
ptyManager.disposeAll()
|
||||
agentPtyRegistry.disposeAll()
|
||||
ptyManager?.disposeAll()
|
||||
agentPtyRegistry?.disposeAll()
|
||||
wss.close()
|
||||
agentListWss.close()
|
||||
}, 'dsh-better-sidebar: teardown')
|
||||
@@ -709,7 +737,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
|
||||
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
||||
async function attachAgentList(
|
||||
registry: AgentPtyRegistry,
|
||||
registry: AgentPtyRegistry | null,
|
||||
ws: WebSocket,
|
||||
req: SidebarHttpRequest,
|
||||
): Promise<void> {
|
||||
@@ -722,13 +750,15 @@ async function attachAgentList(
|
||||
}
|
||||
const send = (): void => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(registry.list(sessionId)))
|
||||
// Degraded mode (node-pty unavailable): no agent terminal can exist,
|
||||
// so the honest push is the empty list.
|
||||
ws.send(JSON.stringify(registry?.list(sessionId) ?? []))
|
||||
}
|
||||
}
|
||||
send()
|
||||
const unsubscribe = registry.subscribe(send)
|
||||
ws.on('close', () => { unsubscribe() })
|
||||
ws.on('error', () => { unsubscribe() })
|
||||
const unsubscribe = registry?.subscribe(send)
|
||||
ws.on('close', () => { unsubscribe?.() })
|
||||
ws.on('error', () => { unsubscribe?.() })
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
@@ -748,8 +778,8 @@ async function attachAgentList(
|
||||
*/
|
||||
async function attachTerminal(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
agentPtyRegistry: AgentPtyRegistry,
|
||||
ptyManager: PtyManager | null,
|
||||
agentPtyRegistry: AgentPtyRegistry | null,
|
||||
ws: WebSocket,
|
||||
req: SidebarHttpRequest,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
@@ -758,6 +788,12 @@ async function attachTerminal(
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const uuid = url.searchParams.get('uuid')
|
||||
if (uuid !== null) {
|
||||
// Degraded mode (node-pty unavailable): no agent terminal can exist,
|
||||
// so the lookup behaves exactly like a missing uuid.
|
||||
if (agentPtyRegistry === null) {
|
||||
ws.close(1011, `agent terminal "${uuid}" not found`)
|
||||
return
|
||||
}
|
||||
const handle = agentPtyRegistry.get(uuid)
|
||||
if (handle === undefined) {
|
||||
ws.close(1011, `agent terminal "${uuid}" not found`)
|
||||
@@ -772,6 +808,13 @@ async function attachTerminal(
|
||||
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
|
||||
return
|
||||
}
|
||||
if (ptyManager === null) {
|
||||
// Degraded mode (issue #140): node-pty unavailable. The close reason
|
||||
// is a SHORT marker — a WS close reason is capped at 123 bytes, so the
|
||||
// client fetches the full repair command from /sidebar/api/terminal.deps.
|
||||
ws.close(1011, PTY_DEPS_MISSING)
|
||||
return
|
||||
}
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
|
||||
// Replay the transcript, then follow live output.
|
||||
|
||||
Reference in New Issue
Block a user