From fe2effea1f77b37e89f1d7a99286fdedbab75e38 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 5 Nov 2026 20:51:38 +0800 Subject: [PATCH] feat(terminal): give TerminalBackendCleanupError the CLEANUP_FAILED code TerminalBackendCleanupError extended AggregateError, so a backend that failed startup and then failed to clean its partial resources reached tool/result with a name but no error.name/error.code. It now extends TerminalError with code CLEANUP_FAILED, keeps spawnError, cleanupError, and the errors list, and sets the setup failure as cause. TerminalError and the cleanup error move to src/error.ts so src/types.ts stays type-only; TerminalError's constructor accepts ErrorOptions. Refs #7869 --- docs/subsystems/terminal.md | 2 +- docs/subsystems/terminal.zh.md | 2 +- .../terminal-bash/tests/index.spec.ts | 3 + packages/terminal/terminal/README.i18n.yaml | 8 +-- packages/terminal/terminal/README.md | 5 +- packages/terminal/terminal/README.zh.md | 5 +- packages/terminal/terminal/src/error.ts | 70 +++++++++++++++++++ packages/terminal/terminal/src/index.ts | 40 +---------- packages/terminal/terminal/src/types.ts | 17 +---- .../terminal/terminal/tests/service.spec.ts | 16 +++++ 10 files changed, 104 insertions(+), 64 deletions(-) create mode 100644 packages/terminal/terminal/src/error.ts diff --git a/docs/subsystems/terminal.md b/docs/subsystems/terminal.md index c1c78963e4..0d917219d5 100644 --- a/docs/subsystems/terminal.md +++ b/docs/subsystems/terminal.md @@ -31,7 +31,7 @@ A backend owns how one registered type starts and detects readiness. `TerminalSe interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } ``` diff --git a/docs/subsystems/terminal.zh.md b/docs/subsystems/terminal.zh.md index e97e5771f5..a9d2b36d27 100644 --- a/docs/subsystems/terminal.zh.md +++ b/docs/subsystems/terminal.zh.md @@ -31,7 +31,7 @@ type TerminalSessionStatus = interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } ``` diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index d992f8ee7f..2859644622 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -158,8 +158,11 @@ describe('BashTerminalBackend startup rollback', () => { const aggregate = new BashTerminalBackend(ctx, config(), spawnTerminal, () => doublyFailed) await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalBackendCleanupError', + code: 'CLEANUP_FAILED', spawnError: startupFailure, cleanupError: cleanupFailure, + errors: [startupFailure, cleanupFailure], + cause: startupFailure, } satisfies Partial)) }) diff --git a/packages/terminal/terminal/README.i18n.yaml b/packages/terminal/terminal/README.i18n.yaml index 6b91667f72..e734b14bf2 100644 --- a/packages/terminal/terminal/README.i18n.yaml +++ b/packages/terminal/terminal/README.i18n.yaml @@ -39,14 +39,14 @@ en: 6bae42fb7275816f zh: 5f75e1b26074108c /deepseek-ai-dsh-terminal/understand-the-implementation/source-map: - en: 48dab6fe2f770ced - zh: 9bbfd23bcc5ac2b3 + en: 3ab5d082aa13d500 + zh: e97b962d02e474b7 /deepseek-ai-dsh-terminal/understand-the-implementation/data-model-and-lifecycle: en: a2adfa2708882e19 zh: 002f257fd8c4addb /deepseek-ai-dsh-terminal/understand-the-implementation/ownership-and-cleanup-rules: - en: 7af36e58919e41d5 - zh: 4fe7b4db1099bcbf + en: b669a68c739b062a + zh: cc641f850905095f /deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation: en: 0b8dc3a382aa7bf2 zh: e6cc6906e01827d2 diff --git a/packages/terminal/terminal/README.md b/packages/terminal/terminal/README.md index 40cb6866f8..ff34d9a3cc 100644 --- a/packages/terminal/terminal/README.md +++ b/packages/terminal/terminal/README.md @@ -74,7 +74,8 @@ The service owns everything except terminal mechanics: session identity, publica | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `TerminalSessionService`: backend registry, spawn/send/read/signal/kill/list, owner cleanups, disposal | -| [`src/types.ts`](src/types.ts) | Shared contracts: backend interface, session types, wait reasons, signal set, error codes | +| [`src/types.ts`](src/types.ts) | Shared contracts: backend interface, session types, wait reasons, signal set | +| [`src/error.ts`](src/error.ts) | `TerminalError`, its codes, and `TerminalBackendCleanupError` | ### Data model and lifecycle @@ -83,7 +84,7 @@ Each published session is a record of its id, owner, optional name, backend type ### Ownership and cleanup rules - Fencing uses the exact `Agent` object: `hasOwnerActivity(owner)` spans unpublished setup through final close with no publication gap, so lifecycle policy can fence the owner precisely. -- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. +- A backend that cannot clean partial startup resources rejects with `TerminalBackendCleanupError`, a `TerminalError` with code `CLEANUP_FAILED` that lists both failures in `errors`; the service retains that failure as tracked owner activity until owner or service disposal consumes and reports it. - Caller cancellation keeps its exact `AbortSignal.reason`; `kill()` and disposal resolve only after the backend's captured process tree is quiescent. ### Send reservation diff --git a/packages/terminal/terminal/README.zh.md b/packages/terminal/terminal/README.zh.md index bb21c9e502..cef5a62110 100644 --- a/packages/terminal/terminal/README.zh.md +++ b/packages/terminal/terminal/README.zh.md @@ -74,7 +74,8 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `TerminalSessionService`:后端注册表、spawn/send/read/signal/kill/list、所有者清理与 dispose | -| [`src/types.ts`](src/types.ts) | 共享约定:后端接口、会话类型、等待原因、信号集合、错误码 | +| [`src/types.ts`](src/types.ts) | 共享约定:后端接口、会话类型、等待原因、信号集合 | +| [`src/error.ts`](src/error.ts) | `TerminalError`、其 code 与 `TerminalBackendCleanupError` | ### 数据模型与生命周期 @@ -83,7 +84,7 @@ kind: "package-reference" ### 所有权与清理规则 - 限制基于确切的 `Agent` 对象:`hasOwnerActivity(owner)` 覆盖从尚未发布的设置到最终关闭的全过程,没有发布竞态,因此生命周期策略可以精确限制所有者。 -- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 +- 无法清理部分启动资源的后端会以 `TerminalBackendCleanupError` 拒绝,它是 code 为 `CLEANUP_FAILED` 的 `TerminalError`,并在 `errors` 中列出两个失败;服务会将该失败保留为受跟踪的所有者活动,直到所有者或服务 dispose 消费并报告它。 - 调用方取消保留其确切的 `AbortSignal.reason`;`kill()` 与 dispose 只在后端捕获的进程树完全停稳后完成。 ### 发送预留 diff --git a/packages/terminal/terminal/src/error.ts b/packages/terminal/terminal/src/error.ts new file mode 100644 index 0000000000..b487ac9bc4 --- /dev/null +++ b/packages/terminal/terminal/src/error.ts @@ -0,0 +1,70 @@ +/** Machine-routable PTY service and backend failures. @module */ + +import { HarnessError } from '@deepseek-ai/dsh-error' + +/** + * Machine-routable PTY service and backend failures. Backends use + * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a + * confined mode has no sandbox provider, `SESSION_CLOSING` and + * `SESSION_EXITED` for an operation on a closing or exited session, + * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses + * readiness during startup, and `CLEANUP_FAILED` (always as + * {@link TerminalBackendCleanupError}) when startup failed and the partial + * resources it left could not be cleaned. + */ +export type TerminalErrorCode = + | 'CLEANUP_FAILED' + | 'DUPLICATE_BACKEND' + | 'DUPLICATE_NAME' + | 'FOREIGN_SESSION' + | 'INIT_REJECTED' + | 'NO_BACKEND' + | 'NO_SANDBOX' + | 'NO_SESSION' + | 'OWNER_NOT_LIVE' + | 'SEND_ACTIVE' + | 'SERVICE_DISPOSING' + | 'SESSION_CLOSING' + | 'SESSION_EXITED' + | 'STARTUP_EXITED' + | 'STARTUP_TIMEOUT' + +/** Harness error carrying a stable {@link TerminalErrorCode}. */ +export class TerminalError extends HarnessError { + override readonly code: TerminalErrorCode + + /** + * @param message - names the PTY operation and the safe next step. + * @param code - the failure class consumers route on. + * @param options - standard `cause` chaining. + */ + constructor(message: string, code: TerminalErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.name = 'TerminalError' + this.code = code + } +} + +/** + * Backend-reported failure to clean partial resources after unpublished setup + * failed; code `CLEANUP_FAILED`. Backend-owned resources may still be alive. + * `errors` lists both failures in the order `AggregateError` would, and + * `cause` is the setup failure. + */ +export class TerminalBackendCleanupError extends TerminalError { + /** The setup failure followed by the cleanup failure. */ + readonly errors: readonly unknown[] + + /** + * @param spawnError - original setup or cancellation failure. + * @param cleanupError - failure that may leave backend-owned resources alive. + */ + constructor( + readonly spawnError: unknown, + readonly cleanupError: unknown, + ) { + super('PTY backend startup and cleanup both failed', 'CLEANUP_FAILED', { cause: spawnError }) + this.name = 'TerminalBackendCleanupError' + this.errors = [spawnError, cleanupError] + } +} diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index df09ff94aa..2e6e78b6ea 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -6,8 +6,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { HarnessError } from '@deepseek-ai/dsh-error' -import { TerminalBackendCleanupError } from './types.ts' +import { TerminalBackendCleanupError, TerminalError } from './error.ts' import type { TerminalBackend, TerminalBackendSession, @@ -41,7 +40,7 @@ export type { TerminalSpawnResult, TerminalWaitReason, } from './types.ts' -export { TerminalBackendCleanupError } from './types.ts' +export { TerminalBackendCleanupError, TerminalError, type TerminalErrorCode } from './error.ts' /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */ export type TerminalSessionId = TerminalSessionIdValue @@ -52,41 +51,6 @@ declare module '@deepseek-ai/cordis' { } } -/** - * Machine-routable PTY service and backend failures. Backends use - * `INIT_REJECTED` when the shell rejects its setup input, `NO_SANDBOX` when a - * confined mode has no sandbox provider, `SESSION_CLOSING` and - * `SESSION_EXITED` for an operation on a closing or exited session, and - * `STARTUP_EXITED` or `STARTUP_TIMEOUT` when the shell exits or misses - * readiness during startup. - */ -export type TerminalErrorCode = - | 'DUPLICATE_BACKEND' - | 'DUPLICATE_NAME' - | 'FOREIGN_SESSION' - | 'INIT_REJECTED' - | 'NO_BACKEND' - | 'NO_SANDBOX' - | 'NO_SESSION' - | 'OWNER_NOT_LIVE' - | 'SEND_ACTIVE' - | 'SERVICE_DISPOSING' - | 'SESSION_CLOSING' - | 'SESSION_EXITED' - | 'STARTUP_EXITED' - | 'STARTUP_TIMEOUT' - -/** Harness error carrying a stable {@link TerminalErrorCode}. */ -export class TerminalError extends HarnessError { - override readonly code: TerminalErrorCode - - constructor(message: string, code: TerminalErrorCode) { - super(message, code) - this.name = 'TerminalError' - this.code = code - } -} - /** * Brand one registry-minted string as a {@link TerminalSessionId}. * @param value - raw registry-issued id. diff --git a/packages/terminal/terminal/src/types.ts b/packages/terminal/terminal/src/types.ts index cbdd08d8a8..c3a191ba51 100644 --- a/packages/terminal/terminal/src/types.ts +++ b/packages/terminal/terminal/src/types.ts @@ -10,21 +10,6 @@ import type { Agent } from '@deepseek-ai/dsh-agent' /** Internal exported basis for the public `TerminalSessionId` type/value pair. */ export type TerminalSessionIdValue = Branded<'TerminalSessionId'> -/** - * Backend-reported failure to clean partial resources after unpublished setup failed. - * @param spawnError - original setup or cancellation failure. - * @param cleanupError - failure that may leave backend-owned resources alive. - */ -export class TerminalBackendCleanupError extends AggregateError { - constructor( - readonly spawnError: unknown, - readonly cleanupError: unknown, - ) { - super([spawnError, cleanupError], 'PTY backend startup and cleanup both failed') - this.name = 'TerminalBackendCleanupError' - } -} - /** Why one interactive send returned control to its caller. */ export type TerminalWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit' @@ -166,7 +151,7 @@ export interface TerminalBackendSession { export interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 0237f2970a..3199deb1a0 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -203,6 +203,9 @@ describe('TerminalSessionService ownership and lifecycle', () => { expect(concurrent).toBeInstanceOf(TerminalError) expect(concurrent).toBeInstanceOf(HarnessError) expect(concurrent).toMatchObject({ name: 'TerminalError', code: 'SEND_ACTIVE' }) + const cleanup = new TerminalBackendCleanupError(new Error('setup failed'), new Error('cleanup failed')) + expect(cleanup).toBeInstanceOf(TerminalError) + expect(cleanup).toMatchObject({ name: 'TerminalBackendCleanupError', code: 'CLEANUP_FAILED' }) expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false }) expect(operation.cancel()).toBe(true) await operation.done @@ -297,6 +300,19 @@ describe('TerminalSessionService ownership and lifecycle', () => { await expect(pending).rejects.toBe(reason) }) + it('rejects an uncancelled spawn with the backend cleanup failure and its code', async () => { + const ctx = await harness() + const failure = new TerminalBackendCleanupError(new Error('startup failed'), new Error('backend cleanup failed')) + ctx.terminals.registerBackend({ type: 'cleanup-failing', spawn: () => Promise.reject(failure) }) + const owner = stubAgent(ctx, 'owner') + await ctx.agents.register(owner) + + await expect(ctx.terminals.spawn(owner, { type: 'cleanup-failing' })).rejects.toBe(failure) + expect(failure).toMatchObject({ name: 'TerminalBackendCleanupError', code: 'CLEANUP_FAILED' }) + const internal = ctx.terminals as unknown as { disposeOwned(owner: Agent): Promise } + await expect(internal.disposeOwned(owner)).rejects.toThrow('failed to clean up PTY lifecycle') + }) + it.each(['owner', 'service'] as const)('retains caller-triggered backend cleanup failure until %s disposal', async (scope) => { const ctx = await harness() const started = Promise.withResolvers()