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
This commit is contained in:
sjw1231 committed 2026-11-06 16:39:37 +08:00
1 parent c92eded7e4
commit fe2effea1f
10 files changed
+104 -64

No files matched your search

+1 -1
View File
@@ -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<TerminalBackendSession>
}
```
+1 -1
View File
@@ -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<TerminalBackendSession>
}
```
@@ -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<TerminalBackendCleanupError>))
})
+4 -4
View File
@@ -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
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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 只在后端捕获的进程树完全停稳后完成。
### 发送预留
+70
View File
@@ -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]
}
}
+2 -38
View File
@@ -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.
+1 -16
View File
@@ -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<TerminalBackendSession>
}
@@ -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<void> }
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<undefined>()