Files
orca/src/main/daemon/daemon-errors.ts
T
Jinwoo-H 987e36ed72 fix(terminal): let main own a fenced worker's PTY session
A settled orchestration worker's pane (automaticResumeBlockedBy:
'legacy-orchestration-worker') attached renderer-only: transport.attach
registered IPC listeners without ever asking main to attach the daemon
session. Main is the daemon's only client, so after an app restart its
adapter never learned the id, pty:hasPty answered a fabricated false, and
the visibility reconciler tore the tab down while the process still ran.

The fence's intent is right, the mechanism was wrong. A fenced pane now
takes the SAME deferred reattach as every other restored pane and carries
the fence as attachOnly on the connect: main attaches the live session or
refuses to mint one. A session its owner proves absent comes back as the
existing exitedBeforeAttach terminal state, so the pane neither respawns
nor retires a binding it may never replace.

Deletes the renderer-only attach path (retained-legacy-pty-attach.ts, the
legacyAttachOnlyPtyId short-circuit, and the SSH gate that skipped the
deferred flow for these panes).

Pre-commit hook skipped: it runs `pnpm install`, which fails on this
worktree's symlinked node_modules. Its checks (oxlint, react-doctor
oxlint, oxfmt) were run manually on every staged file.
2026-09-07 17:00:50 -04:00

96 lines
3.4 KiB
TypeScript

// Error classes shared across the daemon protocol boundary (client, server,
// host). Split from types.ts, which is capped for wire-shape declarations.
import { SESSION_NOT_FOUND_MESSAGE_PREFIX } from '../../shared/pty-attach-absence-evidence'
const ATTACH_CANCELED_PREFIX = 'Attach canceled for session '
export class TerminalAttachCanceledError extends Error {
constructor(sessionId: string) {
super(`${ATTACH_CANCELED_PREFIX}${sessionId}`)
this.name = 'TerminalAttachCanceledError'
}
}
/** Recognizes the daemon's attach-cancellation across the wire, where only the message survives. */
export function isTerminalAttachCanceledMessage(message: string): boolean {
return message.startsWith(ATTACH_CANCELED_PREFIX)
}
export class DaemonProtocolError extends Error {
constructor(message: string) {
super(message)
this.name = 'DaemonProtocolError'
}
}
/**
* The control connection itself failed: nothing was delivered and no reply is
* coming. Distinct from a DaemonProtocolError the daemon actually answered with
* (or one we raised on our own timeout) — only this class proves the socket, and
* therefore every session sharing it, is already lost. Extends
* DaemonProtocolError and keeps the same messages so existing instanceof and
* message checks (isDaemonGoneError) still match.
*/
export class DaemonConnectionLostError extends DaemonProtocolError {
constructor(message: string) {
super(message)
this.name = 'DaemonConnectionLostError'
}
}
/**
* Nothing came back before our own deadline. The daemon may still be alive and merely
* slow, so this is not a connection loss — but a request AND the cancel sent to clean
* it up both hitting this means the daemon is wedged with its socket still open.
*/
export class DaemonRequestTimeoutError extends DaemonProtocolError {
constructor(message: string) {
super(message)
this.name = 'DaemonRequestTimeoutError'
}
}
/**
* The one message `isDaemonGoneError` matches that a still-open connection can produce,
* so it is the only way a wedged-but-connected daemon reaches the respawn path.
*/
export const DAEMON_UNAVAILABLE_RECONNECT_MESSAGE = 'Daemon temporarily unavailable; reconnect'
export class SessionNotFoundError extends Error {
constructor(sessionId: string) {
super(`${SESSION_NOT_FOUND_MESSAGE_PREFIX}${sessionId}`)
this.name = 'SessionNotFoundError'
}
}
export class TerminalSessionOwnerUnverifiedError extends Error {
constructor(sessionId: string) {
super(`Terminal session owner could not be verified: ${sessionId}`)
this.name = 'TerminalSessionOwnerUnverifiedError'
}
}
export class TerminalHostGoneError extends Error {
constructor() {
super('terminal_host_gone')
this.name = 'TerminalHostGoneError'
}
}
// Connect ENOENT/ECONNREFUSED proves the endpoint is absent; open ENOENT can be a missing token file.
export function isDaemonEndpointGoneError(err: unknown): boolean {
const candidate = err as { code?: unknown; syscall?: unknown } | null
return (
typeof candidate === 'object' &&
candidate !== null &&
candidate.syscall === 'connect' &&
(candidate.code === 'ENOENT' || candidate.code === 'ECONNREFUSED')
)
}
export function decodeDaemonResponseError(message: string): Error {
return message.startsWith(SESSION_NOT_FOUND_MESSAGE_PREFIX)
? new SessionNotFoundError(message.slice(SESSION_NOT_FOUND_MESSAGE_PREFIX.length))
: new DaemonProtocolError(message)
}