From eaa6ac6e1fa5aa6b9bd5065fbcc01d91092a5bf6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:15:18 -0700 Subject: [PATCH 1/4] Structure terminal PTY operation failures Co-authored-by: codex --- apps/server/src/terminal/Manager.test.ts | 62 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 41 ++++++++++++++-- packages/contracts/src/terminal.ts | 32 ++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c4c73ea7489..d040526b4b4 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -39,6 +39,8 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; readonly pid: number; + writeFailure: unknown | undefined; + resizeFailure: unknown | undefined; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); killed = false; @@ -48,10 +50,16 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } write(data: string): void { + if (this.writeFailure !== undefined) { + throw this.writeFailure; + } this.writes.push(data); } resize(cols: number, rows: number): void { + if (this.resizeFailure !== undefined) { + throw this.resizeFailure; + } this.resizeCalls.push({ cols, rows }); } @@ -498,6 +506,60 @@ it.layer( }), ); + it.effect("preserves structured context and causes for PTY I/O failures", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const writeCause = new Error("PTY input handle is unavailable"); + process.writeFailure = writeCause; + const writeError = yield* Effect.flip( + manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "secret input that must not be attached to the error", + }), + ); + + expect(writeError).toMatchObject({ + _tag: "TerminalWriteError", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + terminalPid: process.pid, + }); + expect(writeError.cause).toBe(writeCause); + expect(writeError).not.toHaveProperty("data"); + + const resizeCause = new Error("PTY resize handle is unavailable"); + process.resizeFailure = resizeCause; + const resizeError = yield* Effect.flip( + manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 132, + rows: 40, + }), + ); + + expect(resizeError).toMatchObject({ + _tag: "TerminalResizeError", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + terminalPid: process.pid, + cols: 132, + rows: 40, + }); + expect(resizeError.cause).toBe(resizeCause); + + process.resizeFailure = undefined; + yield* manager.open(openInput({ cols: 132, rows: 40 })); + expect(process.resizeCalls).toEqual([{ cols: 132, rows: 40 }]); + }), + ); + it.effect("ignores delayed resize requests after a terminal closes", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9fa9d07ebc9..55c64d29566 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -12,7 +12,9 @@ import { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalResizeError, TerminalSessionLookupError, + TerminalWriteError, type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalClearInput, @@ -61,7 +63,9 @@ export { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalResizeError, TerminalSessionLookupError, + TerminalWriteError, }; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; @@ -190,6 +194,25 @@ interface TerminalSubprocessInspector { ): Effect.Effect; } +const resizePtyProcess = ( + session: TerminalSessionState, + process: PtyAdapter.PtyProcess, + cols: number, + rows: number, +) => + Effect.try({ + try: () => process.resize(cols, rows), + catch: (cause) => + new TerminalResizeError({ + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid: process.pid, + cols, + rows, + cause, + }), + }); + export interface ShellCandidate { shell: string; args?: string[]; @@ -2168,10 +2191,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { + yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows); liveSession.cols = targetCols; liveSession.rows = targetRows; liveSession.updatedAt = yield* nowIso; - liveSession.process.resize(targetCols, targetRows); } return snapshot(liveSession); @@ -2219,10 +2242,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.status === "running" && (session.cols !== targetCols || session.rows !== targetRows) ) { + const process = session.process; + yield* resizePtyProcess(session, process, targetCols, targetRows); session.cols = targetCols; session.rows = targetRows; session.updatedAt = yield* nowIso; - yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); } return snapshot(session); @@ -2409,7 +2433,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } - yield* Effect.sync(() => process.write(input.data)); + yield* Effect.try({ + try: () => process.write(input.data), + catch: (cause) => + new TerminalWriteError({ + threadId: input.threadId, + terminalId, + terminalPid: process.pid, + cause, + }), + }); }); const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { @@ -2422,10 +2455,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (!process || session.value.status !== "running") { return; } + yield* resizePtyProcess(session.value, process, input.cols, input.rows); session.value.cols = input.cols; session.value.rows = input.rows; session.value.updatedAt = yield* nowIso; - yield* Effect.sync(() => process.resize(input.cols, input.rows)); }); const resize: TerminalManager["Service"]["resize"] = (input) => diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index a3c8e37e7f9..2bb76f4b1e4 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -298,10 +298,42 @@ export class TerminalNotRunningError extends Schema.TaggedErrorClass()( + "TerminalWriteError", + { + threadId: Schema.String, + terminalId: Schema.String, + terminalPid: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to write to terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid}`; + } +} + +export class TerminalResizeError extends Schema.TaggedErrorClass()( + "TerminalResizeError", + { + threadId: Schema.String, + terminalId: Schema.String, + terminalPid: Schema.Number, + cols: TerminalColsSchema, + rows: TerminalRowsSchema, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to resize terminal for thread: ${this.threadId}, terminal: ${this.terminalId}, PID: ${this.terminalPid} to ${this.cols}x${this.rows}`; + } +} + export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, TerminalNotRunningError, + TerminalWriteError, + TerminalResizeError, ]); export type TerminalError = typeof TerminalError.Type; From 7e5a4d6d16546a2a4cd4c654a8a8b9a3813b5ccb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:33:59 -0700 Subject: [PATCH 2/4] fix: preserve terminal failure causes in logs Co-authored-by: codex --- apps/server/src/terminal/Manager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 55c64d29566..fd571427e8a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1273,7 +1273,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId, terminalId, signal: "SIGTERM", - error: error.message, + cause: error, }).pipe(Effect.as(false)), ), ); @@ -1297,7 +1297,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId, terminalId, signal: "SIGKILL", - error: error.message, + cause: error, }), ), ); @@ -1904,7 +1904,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* Effect.logError("failed to start terminal", { threadId: session.threadId, terminalId: session.terminalId, - error: message, + cause: error, ...(startedShell ? { shell: startedShell } : {}), }); } From 421933132316482cf4f1028e393d63e4cb3f814f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:46:47 -0700 Subject: [PATCH 3/4] fix: distinguish terminal cwd failures Co-authored-by: codex --- apps/server/src/terminal/Manager.test.ts | 39 ++++++++++++++++- apps/server/src/terminal/Manager.ts | 25 +++++------ packages/contracts/src/terminal.ts | 53 +++++++++++++++--------- 3 files changed, 83 insertions(+), 34 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index d040526b4b4..3a1cabc4a27 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -443,6 +443,39 @@ it.layer( fs.writeFileString(filePath, contents), ); + it.effect("reports a missing cwd without an artificial cause", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "missing-cwd"); + const error = yield* Effect.flip(manager.open(openInput({ cwd }))); + + expect(error).toMatchObject({ + _tag: "TerminalCwdNotFoundError", + cwd, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("reports a cwd that is not a directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const { manager, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "cwd-file"); + yield* writeFileString(cwd, "not a directory"); + const error = yield* Effect.flip(manager.open(openInput({ cwd }))); + + expect(error).toMatchObject({ + _tag: "TerminalCwdNotDirectoryError", + cwd, + }); + expect("cause" in error).toBe(false); + }), + ); + it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; @@ -460,9 +493,11 @@ it.layer( ); expect(error).toMatchObject({ - _tag: "TerminalCwdError", + _tag: "TerminalCwdStatError", cwd: blockedCwd, - reason: "statFailed", + cause: { + _tag: "PlatformError", + }, }); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index fd571427e8a..da9d780f82b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -9,6 +9,9 @@ import { DEFAULT_TERMINAL_ID, TerminalCwdError, + TerminalCwdNotDirectoryError, + TerminalCwdNotFoundError, + TerminalCwdStatError, TerminalError, TerminalHistoryError, TerminalNotRunningError, @@ -60,6 +63,9 @@ import * as PtyAdapter from "./PtyAdapter.ts"; export { TerminalCwdError, + TerminalCwdNotDirectoryError, + TerminalCwdNotFoundError, + TerminalCwdStatError, TerminalError, TerminalHistoryError, TerminalNotRunningError, @@ -1495,20 +1501,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const assertValidCwd = Effect.fn("terminal.assertValidCwd")(function* (cwd: string) { const stats = yield* fileSystem.stat(cwd).pipe( - Effect.mapError( - (cause) => - new TerminalCwdError({ - cwd, - reason: cause.reason._tag === "NotFound" ? "notFound" : "statFailed", - cause, - }), - ), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? new TerminalCwdNotFoundError({ cwd }) + : new TerminalCwdStatError({ cwd, cause }), + }), ); if (stats.type !== "Directory") { - return yield* new TerminalCwdError({ - cwd, - reason: "notDirectory", - }); + return yield* new TerminalCwdNotDirectoryError({ cwd }); } }); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 2bb76f4b1e4..fa5f1821169 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -232,34 +232,47 @@ export const TerminalAttachStreamEvent = Schema.Union([ ]); export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type; -export class TerminalCwdError extends Schema.TaggedErrorClass()( - "TerminalCwdError", +export class TerminalCwdNotFoundError extends Schema.TaggedErrorClass()( + "TerminalCwdNotFoundError", { cwd: Schema.String, - reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]), - cause: Schema.optional(Schema.Defect()), }, ) { override get message() { - if (this.reason === "notDirectory") { - return `Terminal cwd is not a directory: ${this.cwd}`; - } - if (this.reason === "notFound") { - return `Terminal cwd does not exist: ${this.cwd}`; - } - const causeMessage = - this.cause !== undefined && - this.cause !== null && - typeof this.cause === "object" && - "message" in this.cause - ? this.cause.message - : undefined; - return typeof causeMessage === "string" && causeMessage.length > 0 - ? `Failed to access terminal cwd: ${this.cwd} (${causeMessage})` - : `Failed to access terminal cwd: ${this.cwd}`; + return `Terminal cwd does not exist: ${this.cwd}`; + } +} + +export class TerminalCwdNotDirectoryError extends Schema.TaggedErrorClass()( + "TerminalCwdNotDirectoryError", + { + cwd: Schema.String, + }, +) { + override get message() { + return `Terminal cwd is not a directory: ${this.cwd}`; + } +} + +export class TerminalCwdStatError extends Schema.TaggedErrorClass()( + "TerminalCwdStatError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to access terminal cwd: ${this.cwd}`; } } +export const TerminalCwdError = Schema.Union([ + TerminalCwdNotFoundError, + TerminalCwdNotDirectoryError, + TerminalCwdStatError, +]); +export type TerminalCwdError = typeof TerminalCwdError.Type; + export class TerminalHistoryError extends Schema.TaggedErrorClass()( "TerminalHistoryError", { From 30a32cfbc2b8e42bc175943f578c67b5db311f52 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:50:31 -0700 Subject: [PATCH 4/4] refactor: inline terminal history errors Co-authored-by: codex --- apps/server/src/terminal/Manager.ts | 50 ++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index da9d780f82b..6347fdfc64d 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1186,16 +1186,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const legacyHistoryPath = (threadId: string) => path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); - const toTerminalHistoryError = - (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => - (cause: unknown) => - new TerminalHistoryError({ - operation, - threadId, - terminalId, - cause, - }); - const readManagerState = SynchronizedRef.get(managerStateRef); const modifyManagerState = ( @@ -1401,16 +1391,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if ( yield* fileSystem .exists(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))) + .pipe( + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ) ) { const raw = yield* fileSystem .readFileString(nextPath) - .pipe(Effect.mapError(toTerminalHistoryError("read", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), + ), + ); const capped = capHistory(raw, historyLineLimit); if (capped !== raw) { yield* fileSystem .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("truncate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "truncate", threadId, terminalId, cause }), + ), + ); } return capped; } @@ -1423,18 +1426,33 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if ( !(yield* fileSystem .exists(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId)))) + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + )) ) { return ""; } const raw = yield* fileSystem .readFileString(legacyPath) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); const capped = capHistory(raw, historyLineLimit); yield* fileSystem .writeFileString(nextPath, capped) - .pipe(Effect.mapError(toTerminalHistoryError("migrate", threadId, terminalId))); + .pipe( + Effect.mapError( + (cause) => + new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), + ), + ); yield* fileSystem.remove(legacyPath, { force: true }).pipe( Effect.catch((cleanupError) => Effect.logWarning("failed to remove legacy terminal history", {