From 81780b9e3e50bf1ca40418ddceb44621f0c51ba1 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 13 Aug 2026 11:10:49 -0700 Subject: [PATCH 1/2] fix(host-service): classify terminal session-creation failures instead of blanket 500s --- .../src/trpc/router/agents/agents.ts | 6 ++-- .../src/trpc/router/terminal/errors.ts | 25 ++++++++++++++++ .../src/trpc/router/terminal/terminal.ts | 30 ++----------------- 3 files changed, 29 insertions(+), 32 deletions(-) create mode 100644 packages/host-service/src/trpc/router/terminal/errors.ts diff --git a/packages/host-service/src/trpc/router/agents/agents.ts b/packages/host-service/src/trpc/router/agents/agents.ts index 7bd87573755..1d547ea50ce 100644 --- a/packages/host-service/src/trpc/router/agents/agents.ts +++ b/packages/host-service/src/trpc/router/agents/agents.ts @@ -20,6 +20,7 @@ import { createTerminalSessionInternal } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; import { resolveAttachmentPath } from "../attachments/storage"; +import { toTerminalIoError } from "../terminal/errors"; interface ResolvedHostAgentConfig { id: string; @@ -394,10 +395,7 @@ async function runTerminalAgent( }); if ("error" in result) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: result.error, - }); + throw toTerminalIoError(result.error); } return { diff --git a/packages/host-service/src/trpc/router/terminal/errors.ts b/packages/host-service/src/trpc/router/terminal/errors.ts new file mode 100644 index 00000000000..a78f96bede8 --- /dev/null +++ b/packages/host-service/src/trpc/router/terminal/errors.ts @@ -0,0 +1,25 @@ +import { TRPCError } from "@trpc/server"; + +export function toTerminalIoError(message: string): TRPCError { + if (message.includes("belong")) { + return new TRPCError({ code: "FORBIDDEN", message }); + } + // A worktree deleted underneath an open terminal is a routine lifecycle + // state, not a bug — the runtime reports it as a plain string, so match + // the stable prefix here to keep it out of Sentry. + if (message.startsWith("Workspace worktree no longer exists")) { + return new TRPCError({ + code: "NOT_FOUND", + message, + cause: { kind: "WORKTREE_GONE" }, + }); + } + if ( + message.includes("not found") || + message.includes("not active") || + message.includes("exited") + ) { + return new TRPCError({ code: "NOT_FOUND", message }); + } + return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); +} diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index 72875be45f5..cc5f9499871 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -17,30 +17,7 @@ import { } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; - -function toTerminalIoError(message: string): TRPCError { - if (message.includes("belong")) { - return new TRPCError({ code: "FORBIDDEN", message }); - } - // A worktree deleted underneath an open terminal is a routine lifecycle - // state, not a bug — the runtime reports it as a plain string, so match - // the stable prefix here to keep it out of Sentry. - if (message.startsWith("Workspace worktree no longer exists")) { - return new TRPCError({ - code: "NOT_FOUND", - message, - cause: { kind: "WORKTREE_GONE" }, - }); - } - if ( - message.includes("not found") || - message.includes("not active") || - message.includes("exited") - ) { - return new TRPCError({ code: "NOT_FOUND", message }); - } - return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); -} +import { toTerminalIoError } from "./errors"; const createSessionInputSchema = z.object({ workspaceId: z.string(), @@ -73,10 +50,7 @@ async function createTerminalSessionFromInput({ }); if ("error" in result) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: result.error, - }); + throw toTerminalIoError(result.error); } return { From fb993dcec2095062e4a5e5142a8f6543e318787d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 13 Aug 2026 11:30:34 -0700 Subject: [PATCH 2/2] fix(host-service): classify terminal session failures by kind, not message text Replace the prose-matching classifier with a discriminated union on the producer. Every session producer now returns a TerminalSessionError carrying a `kind`, and the tRPC boundary switches on it exhaustively. Fixes three misclassifications and removes the mechanism behind them: - agents.run and terminal.createSession filed worktree-gone as a 500 - terminal.writeInput filed a wrong-workspace access as NOT_FOUND rather than FORBIDDEN - terminalAgents.getOrCreate filed every creation outcome as a 500 The daemon-readiness timeout now maps to SERVICE_UNAVAILABLE via the `transient` signal the producer already computed, so a healable daemon blip stops paging while a permanent bootstrap failure still reports. --- .../host-service/src/terminal/terminal.ts | 101 ++++++++++++++---- .../src/trpc/router/agents/agents.ts | 4 +- .../router/terminal-agents/terminal-agents.ts | 6 +- .../src/trpc/router/terminal/errors.ts | 58 ++++++---- .../src/trpc/router/terminal/terminal.ts | 13 +-- 5 files changed, 124 insertions(+), 58 deletions(-) diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 2942887d697..8f7cf951e63 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -726,6 +726,49 @@ export interface TerminalSessionSummary { title: string | null; } +/** + * Why this session operation failed, as a value rather than as prose. + * + * Every session producer in this module returns one of these, and each + * consumer (tRPC, the HTTP attach route, the websocket) decides its own + * mapping by switching on `kind`. `error` stays the human-readable string + * those consumers already surface, so it can be reworded freely without + * changing anyone's behaviour. + */ +export type TerminalSessionErrorKind = + /** No session with this id, in memory or on the daemon. */ + | "SESSION_NOT_FOUND" + /** The session exists but is owned by a different workspace. */ + | "SESSION_WRONG_WORKSPACE" + /** The session existed and its process has already ended. */ + | "SESSION_EXITED" + /** Adoption was requested but the daemon has no live session by that id. */ + | "SESSION_NOT_ACTIVE" + /** No workspace row for the requested workspace id. */ + | "WORKSPACE_NOT_FOUND" + /** The workspace row exists but its worktree is gone from disk. */ + | "WORKTREE_GONE" + /** + * The daemon could not be reached (stalled, restarting, bootstrap + * pending). The supervisor heals these and the session may still come up + * under the same id, so callers should retry rather than go dead. + */ + | "DAEMON_UNAVAILABLE" + /** + * Bootstrap failed permanently (missing daemon binary, no socket path, + * crash circuit open, handshake rejected) or the PTY would not open. + * Retrying will fail the same way; this is the only kind that means "we + * have a bug or a broken install". + */ + | "TERMINAL_START_FAILED"; + +export type TerminalSessionError = { + kind: TerminalSessionErrorKind; + error: string; + /** Retained for the attach route and websocket, which branch on it. */ + transient?: boolean; +}; + export function listTerminalSessions( options: { workspaceId?: string; includeExited?: boolean } = {}, ): TerminalSessionSummary[] { @@ -843,16 +886,19 @@ export function writeInputToSession({ terminalId: string; workspaceId: string; data: string; -}): { success: true } | { error: string } { +}): { success: true } | TerminalSessionError { const session = sessions.get(terminalId); if (!session) { - return { error: "Terminal session not found" }; + return { kind: "SESSION_NOT_FOUND", error: "Terminal session not found" }; } if (session.workspaceId !== workspaceId) { - return { error: "Terminal session does not belong to this workspace" }; + return { + kind: "SESSION_WRONG_WORKSPACE", + error: "Terminal session does not belong to this workspace", + }; } if (session.exited) { - return { error: "Terminal session has exited" }; + return { kind: "SESSION_EXITED", error: "Terminal session has exited" }; } session.pty.write(data); @@ -900,12 +946,15 @@ async function getOrAdoptSession({ workspaceId: string; db: HostDb; eventBus?: EventBus; -}): Promise { +}): Promise { for (;;) { const existing = sessions.get(terminalId); if (existing) { if (existing.workspaceId !== workspaceId) { - return { error: "Terminal session does not belong to this workspace" }; + return { + kind: "SESSION_WRONG_WORKSPACE", + error: "Terminal session does not belong to this workspace", + }; } return existing; } @@ -961,7 +1010,7 @@ export async function writeFramedInputToSession({ submit: boolean; db: HostDb; eventBus?: EventBus; -}): Promise<{ success: true } | { error: string }> { +}): Promise<{ success: true } | TerminalSessionError> { const session = await getOrAdoptSession({ terminalId, workspaceId, @@ -970,7 +1019,7 @@ export async function writeFramedInputToSession({ }); if ("error" in session) return session; if (session.exited) { - return { error: "Terminal session has exited" }; + return { kind: "SESSION_EXITED", error: "Terminal session has exited" }; } // Serialize sends per session: the delayed Enter opens a window where a @@ -978,9 +1027,9 @@ export async function writeFramedInputToSession({ // this text and its Enter and get submitted by it. const previous = session.followUpWriteChain ?? Promise.resolve(); const task = previous.then( - async (): Promise<{ success: true } | { error: string }> => { + async (): Promise<{ success: true } | TerminalSessionError> => { if (session.exited) { - return { error: "Terminal session has exited" }; + return { kind: "SESSION_EXITED", error: "Terminal session has exited" }; } const framed = session.modeTracker.isBracketedPasteActive() ? `\x1b[200~${text}\x1b[201~` @@ -993,7 +1042,10 @@ export async function writeFramedInputToSession({ session.pty.write(framed); await new Promise((r) => setTimeout(r, FOLLOW_UP_ENTER_DELAY_MS)); if (session.exited) { - return { error: "Terminal session has exited" }; + return { + kind: "SESSION_EXITED", + error: "Terminal session has exited", + }; } } session.pty.write("\r"); @@ -1024,7 +1076,7 @@ export async function snapshotSession({ maxLines?: number; db: HostDb; eventBus?: EventBus; -}): Promise<({ success: true } & TerminalSnapshot) | { error: string }> { +}): Promise<({ success: true } & TerminalSnapshot) | TerminalSessionError> { const session = await getOrAdoptSession({ terminalId, workspaceId, @@ -2309,12 +2361,7 @@ function getTerminalWorkspaceMismatchError({ return `Terminal session "${terminalId}" belongs to workspace "${ownerWorkspaceId}", not "${requestedWorkspaceId}".`; } -/** - * `transient: true` = the daemon couldn't be reached (stalled, restarting, - * bootstrap pending) — the session may still come up under the same id; the - * attach path tells the renderer to keep retrying instead of going dead. - */ -type CreateSessionError = { error: string; transient?: boolean }; +type CreateSessionError = TerminalSessionError; export async function createTerminalSessionInternal({ terminalId, @@ -2340,7 +2387,8 @@ export async function createTerminalSessionInternal({ ownerWorkspaceId: existing.workspaceId, requestedWorkspaceId: workspaceId, }); - if (mismatchError) return { error: mismatchError }; + if (mismatchError) + return { kind: "SESSION_WRONG_WORKSPACE", error: mismatchError }; if (listed) existing.listed = true; if (initialCommand) queueInitialCommand(existing, initialCommand); @@ -2355,17 +2403,19 @@ export async function createTerminalSessionInternal({ ownerWorkspaceId: existingRecord?.originWorkspaceId, requestedWorkspaceId: workspaceId, }); - if (recordMismatchError) return { error: recordMismatchError }; + if (recordMismatchError) + return { kind: "SESSION_WRONG_WORKSPACE", error: recordMismatchError }; const workspace = db.query.workspaces .findFirst({ where: eq(workspaces.id, workspaceId) }) .sync(); if (!workspace) { - return { error: "Workspace not found" }; + return { kind: "WORKSPACE_NOT_FOUND", error: "Workspace not found" }; } if (!existsSync(workspace.worktreePath)) { return { + kind: "WORKTREE_GONE", error: `Workspace worktree no longer exists: ${workspace.worktreePath}`, }; } @@ -2431,10 +2481,12 @@ export async function createTerminalSessionInternal({ // daemon binary, no socket path, crash circuit open, handshake // rejection) are permanent — surfacing them beats silently retrying // a bootstrap that will fail the same way forever. + const unreachable = error instanceof DaemonUnavailableError; return { + kind: unreachable ? "DAEMON_UNAVAILABLE" : "TERMINAL_START_FAILED", error: error instanceof Error ? error.message : "Failed to start terminal", - transient: error instanceof DaemonUnavailableError, + transient: unreachable, }; } let openResult: { pid: number }; @@ -2446,6 +2498,7 @@ export async function createTerminalSessionInternal({ ); if (!found) { return { + kind: "SESSION_NOT_ACTIVE", error: `Terminal session "${terminalId}" is not active; create it before connecting.`, }; } @@ -2498,10 +2551,12 @@ export async function createTerminalSessionInternal({ } } } catch (error) { + const unreachable = error instanceof DaemonUnavailableError; return { + kind: unreachable ? "DAEMON_UNAVAILABLE" : "TERMINAL_START_FAILED", error: error instanceof Error ? error.message : "Failed to start terminal", - transient: error instanceof DaemonUnavailableError, + transient: unreachable, }; } const pty: DaemonPty = makeDaemonPty(daemon, terminalId, openResult.pid); diff --git a/packages/host-service/src/trpc/router/agents/agents.ts b/packages/host-service/src/trpc/router/agents/agents.ts index 1d547ea50ce..09f456616eb 100644 --- a/packages/host-service/src/trpc/router/agents/agents.ts +++ b/packages/host-service/src/trpc/router/agents/agents.ts @@ -20,7 +20,7 @@ import { createTerminalSessionInternal } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; import { resolveAttachmentPath } from "../attachments/storage"; -import { toTerminalIoError } from "../terminal/errors"; +import { toTerminalSessionError } from "../terminal/errors"; interface ResolvedHostAgentConfig { id: string; @@ -395,7 +395,7 @@ async function runTerminalAgent( }); if ("error" in result) { - throw toTerminalIoError(result.error); + throw toTerminalSessionError(result); } return { diff --git a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts index 7a8623b9df3..b043615c0b4 100644 --- a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts +++ b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts @@ -18,6 +18,7 @@ import { } from "../../../terminal-agents/persistence"; import { protectedProcedure, router } from "../../index"; import { resolveHostAgentConfig } from "../agents/agents"; +import { toTerminalSessionError } from "../terminal/errors"; type GetOrCreateResult = { binding: TerminalAgentBinding; @@ -212,10 +213,7 @@ export const terminalAgentsRouter = router({ }); if ("error" in created) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: created.error, - }); + throw toTerminalSessionError(created); } try { diff --git a/packages/host-service/src/trpc/router/terminal/errors.ts b/packages/host-service/src/trpc/router/terminal/errors.ts index a78f96bede8..e44cb503787 100644 --- a/packages/host-service/src/trpc/router/terminal/errors.ts +++ b/packages/host-service/src/trpc/router/terminal/errors.ts @@ -1,25 +1,41 @@ import { TRPCError } from "@trpc/server"; +import type { TerminalSessionError } from "../../../terminal/terminal"; -export function toTerminalIoError(message: string): TRPCError { - if (message.includes("belong")) { - return new TRPCError({ code: "FORBIDDEN", message }); +/** + * Single tRPC mapping for every terminal session failure. + * + * The switch is exhaustive: adding a kind to `TerminalSessionErrorKind` + * without deciding its code here is a type error, which is the point. Only + * TERMINAL_START_FAILED stays a 500, so it is the only kind the Sentry + * middleware reports. + */ +export function toTerminalSessionError( + failure: TerminalSessionError, +): TRPCError { + const { kind, error: message } = failure; + switch (kind) { + case "SESSION_WRONG_WORKSPACE": + return new TRPCError({ code: "FORBIDDEN", message, cause: { kind } }); + case "SESSION_NOT_FOUND": + case "SESSION_EXITED": + case "SESSION_NOT_ACTIVE": + case "WORKSPACE_NOT_FOUND": + case "WORKTREE_GONE": + return new TRPCError({ code: "NOT_FOUND", message, cause: { kind } }); + case "DAEMON_UNAVAILABLE": + return new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message, + cause: { kind }, + }); + case "TERMINAL_START_FAILED": + return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); + default: { + const unhandled: never = kind; + return new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Unhandled terminal session error kind: ${String(unhandled)}`, + }); + } } - // A worktree deleted underneath an open terminal is a routine lifecycle - // state, not a bug — the runtime reports it as a plain string, so match - // the stable prefix here to keep it out of Sentry. - if (message.startsWith("Workspace worktree no longer exists")) { - return new TRPCError({ - code: "NOT_FOUND", - message, - cause: { kind: "WORKTREE_GONE" }, - }); - } - if ( - message.includes("not found") || - message.includes("not active") || - message.includes("exited") - ) { - return new TRPCError({ code: "NOT_FOUND", message }); - } - return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); } diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index cc5f9499871..71affaba91f 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -17,7 +17,7 @@ import { } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; -import { toTerminalIoError } from "./errors"; +import { toTerminalSessionError } from "./errors"; const createSessionInputSchema = z.object({ workspaceId: z.string(), @@ -50,7 +50,7 @@ async function createTerminalSessionFromInput({ }); if ("error" in result) { - throw toTerminalIoError(result.error); + throw toTerminalSessionError(result); } return { @@ -154,10 +154,7 @@ export const terminalRouter = router({ .mutation(({ input }) => { const result = writeInputToSession(input); if ("error" in result) { - throw new TRPCError({ - code: "NOT_FOUND", - message: result.error, - }); + throw toTerminalSessionError(result); } return { success: true as const }; }), @@ -181,7 +178,7 @@ export const terminalRouter = router({ eventBus: ctx.eventBus, }); if ("error" in result) { - throw toTerminalIoError(result.error); + throw toTerminalSessionError(result); } return { terminalId: input.terminalId, submitted: input.submit }; }), @@ -203,7 +200,7 @@ export const terminalRouter = router({ eventBus: ctx.eventBus, }); if ("error" in result) { - throw toTerminalIoError(result.error); + throw toTerminalSessionError(result); } const { success: _success, ...snapshot } = result; return { terminalId: input.terminalId, ...snapshot };