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:
1 parent
c92eded7e4
commit
fe2effea1f
10 files changed
+104
-64
No files matched your search
@@ -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>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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>))
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 只在后端捕获的进程树完全停稳后完成。
|
||||
|
||||
### 发送预留
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in new issue
Block a user