feat(terminal): code PTY session and persistent shell failures

TerminalError gains SESSION_CLOSING, SESSION_EXITED, STARTUP_EXITED,
STARTUP_TIMEOUT, NO_SANDBOX, and INIT_REJECTED, following its existing
unprefixed code style. The terminal service and terminal-bash use them
for a send to a closing or exited session, a shell that exits or misses
readiness during startup, and a confined mode without a sandbox
provider; an empty confined argv is SubprocessError
SUBPROCESS_INVALID_ARGV, and a send on an aborted signal throws an
error named AbortError.

The persistent bash and pwsh tools report a missing owner as
ToolContextUnavailableError, a blank command as ToolArgsError, and a
shell that rejects its setup as TerminalError INIT_REJECTED.

Refs #7869
This commit is contained in:
sjw1231 committed 2026-11-06 16:39:36 +08:00
1 parent dbcab765f9
commit 3538757cbe
19 files changed
+90 -41

No files matched your search

@@ -27,8 +27,8 @@
en: b398f124ea7b77b0
zh: 50855e601e6c1b88
/deepseek-ai-dsh-tool-bash-persistent/use-this-package/what-can-go-wrong:
en: 2713550ebdb02f69
zh: 52ed398dc541d1fc
en: 52e0e1489c6f8071
zh: 27251d61351ee72d
/deepseek-ai-dsh-tool-bash-persistent/understand-the-implementation:
en: 8d94a7381bca3fa9
zh: f4bd2866fff6fa8d
@@ -58,7 +58,7 @@ Commands share one shell per agent, so state persists until an `exit`, a timeout
### What can go wrong
A call without an owning agent session fails with `bash requires an owning agent session`, and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command.
A call without an owning agent session fails with `bash requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command.
-----
@@ -58,7 +58,7 @@ kind: "package-reference"
### 可能出什么问题
没有拥有者 agent 会话的调用会以 `bash requires an owning agent session` 失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。
没有拥有者 agent 会话的调用会以 `bash requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。
-----
@@ -9,9 +9,10 @@ import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention'
import { TerminalError } from '@deepseek-ai/dsh-terminal'
import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools'
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
@@ -278,7 +279,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
})
const result = await setup.done
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
throw new Error('persistent bash shell did not accept initialization')
throw new TerminalError('persistent bash shell did not accept initialization', 'INIT_REJECTED')
}
return spawned.sessionId
} catch (error: unknown) {
@@ -428,9 +429,9 @@ function registerPersistentBash(ctx: Context, config: ResolvedConfig): void {
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
if (args.command.trim().length === 0) throw new ToolArgsError(['command must be a non-empty string'])
const owner = exec.agent
if (owner === undefined) throw new Error('bash requires an owning agent session')
if (owner === undefined) throw new ToolContextUnavailableError('bash requires an owning agent session')
return serialized(owner, async () => {
if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement.
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
@@ -694,7 +694,10 @@ describe('tool-bash-persistent', () => {
'fails initialization and closes the unusable shell for %s',
async (mode) => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(await call(ctx, owner, 'pwd')).toMatchObject({
isError: true,
error: { info: { name: 'TerminalError', code: 'INIT_REJECTED' } },
})
expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed')
},
)
@@ -752,8 +755,13 @@ describe('tool-bash-persistent', () => {
it('rejects invalid config and invalid calls', async () => {
const { ctx, owner, stub } = await setup()
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
expect(await call(ctx, undefined, 'pwd')).toMatchObject({
isError: true,
error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } },
})
const blank = await call(ctx, owner, ' ')
expect(text(blank)).toContain('command must be a non-empty string')
expect(blank).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } })
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
@@ -27,8 +27,8 @@
en: 9f4c22b06f940caf
zh: 61f755cec7ff5b76
/deepseek-ai-dsh-tool-pwsh-persistent/use-this-package/what-can-go-wrong:
en: 70bf2e1e8d8ccbf6
zh: 6c78d8c85113adf7
en: 3abc9cc73411c042
zh: 31c9e3ddf7160414
/deepseek-ai-dsh-tool-pwsh-persistent/understand-the-implementation:
en: 8d94a7381bca3fa9
zh: f4bd2866fff6fa8d
@@ -60,7 +60,7 @@ Commands share one shell per agent, so cwd, `$env:` variables, functions, and ba
### What can go wrong
A call without an owning agent session fails with `pwsh requires an owning agent session`, and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command.
A call without an owning agent session fails with `pwsh requires an owning agent session` (`ToolContextUnavailableError`, `TOOL_CONTEXT_UNAVAILABLE`), a blank command fails with `ToolArgsError` (`INVALID_ARGS`), and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command.
-----
@@ -60,7 +60,7 @@ kind: "package-reference"
### 可能出什么问题
没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session` 失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。
没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session`(`ToolContextUnavailableError`,`TOOL_CONTEXT_UNAVAILABLE`)失败,空白命令以 `ToolArgsError`(`INVALID_ARGS`)失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。
-----
@@ -13,7 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention'
import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineTool, ToolArgsError, ToolContextUnavailableError } from '@deepseek-ai/dsh-tools'
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.</NOTE>'
@@ -439,9 +439,9 @@ function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void {
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
if (args.command.trim().length === 0) throw new ToolArgsError(['command must be a non-empty string'])
const owner = exec.agent
if (owner === undefined) throw new Error('pwsh requires an owning agent session')
if (owner === undefined) throw new ToolContextUnavailableError('pwsh requires an owning agent session')
return serialized(owner, async () => {
if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement.
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
@@ -829,8 +829,13 @@ describe('tool-pwsh-persistent', () => {
it('rejects invalid config and invalid calls', async () => {
const { ctx, owner, stub } = await setup()
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
expect(await call(ctx, undefined, 'pwd')).toMatchObject({
isError: true,
error: { info: { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } },
})
const blank = await call(ctx, owner, ' ')
expect(text(blank)).toContain('command must be a non-empty string')
expect(blank).toMatchObject({ isError: true, error: { info: { name: 'ToolArgsError', code: 'INVALID_ARGS' } } })
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
+12 -6
View File
@@ -8,8 +8,9 @@ import type {} from '@deepseek-ai/dsh-working-directory'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal'
import { TerminalBackendCleanupError, TerminalError } from '@deepseek-ai/dsh-terminal'
import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } from '@deepseek-ai/dsh-terminal'
import { SubprocessError } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
@@ -94,12 +95,17 @@ function childEnvironment(spec: TerminalBackendSpawnSpec, dialect: ShellDialect)
export const PWSH_PROMPT_SETUP =
"function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }"
/** The startup deadline failure shared by both dialects. */
function startupTimeout(): TerminalError {
return new TerminalError('PTY shell did not reach readiness before startup timeout', 'STARTUP_TIMEOUT')
}
async function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy, signal?: AbortSignal): Promise<string[]> {
const argv = [config.shellPath, ...config.shellArgs]
if (policy.mode === 'danger-full-access') return argv
const sandbox = ctx.get('sandbox')
if (sandbox === undefined) {
throw new Error(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
throw new TerminalError(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`, 'NO_SANDBOX')
}
// Re-state the discriminant because object spread does not preserve its narrowed type.
return (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv
@@ -134,8 +140,8 @@ async function startupSession(
...signal !== undefined ? { signal } : {},
})
const result = await startupOperation.done
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
if (result.waitReason === 'session_exit') throw new TerminalError('PTY shell exited during startup', 'STARTUP_EXITED')
if (result.waitReason === 'timeout') throw startupTimeout()
viewport = result.viewport
if (result.waitReason === 'stdin_read') break
}
@@ -154,7 +160,7 @@ async function startupSession(
const deadline = Promise.withResolvers<never>()
deadlineTimer = setTimeout(() => {
startupOperation?.cancel()
deadline.reject(new Error('PTY shell did not reach readiness before startup timeout'))
deadline.reject(startupTimeout())
}, timeoutMs)
races.push(deadline.promise)
}
@@ -201,7 +207,7 @@ export class BashTerminalBackend implements TerminalBackend {
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session })
const argv = await spawnArgv(this.ctx, this.config, policy, spec.signal)
spec.signal?.throwIfAborted()
if (argv[0] === undefined) throw new Error('terminal-bash: sandbox returned empty argv')
if (argv[0] === undefined) throw new SubprocessError('terminal-bash: sandbox returned empty argv', 'SUBPROCESS_INVALID_ARGV')
const terminal = await this.spawnTerminal({
argv,
cwd: spec.cwd ?? await this.ctx.workingDirectory.ensure(spec.owner, spec.signal),
+11 -6
View File
@@ -314,8 +314,8 @@ export class LocalPtySession implements TerminalBackendSession {
try {
const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} })
const result = await operation.done
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
if (result.waitReason === 'session_exit') throw new TerminalError('PTY shell exited during startup', 'STARTUP_EXITED')
if (result.waitReason === 'timeout') throw new TerminalError('PTY shell did not reach readiness before startup timeout', 'STARTUP_TIMEOUT')
this.motd = result.viewport
} catch (error: unknown) {
signal?.throwIfAborted()
@@ -326,8 +326,8 @@ export class LocalPtySession implements TerminalBackendSession {
}
startSend(request: TerminalSendRequest): TerminalSendOperation {
if (this.closing) throw new Error('PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
if (this.closing) throw new TerminalError('PTY session is closing', 'SESSION_CLOSING')
if (this.statusValue.kind === 'exited') throw new TerminalError('PTY session has exited', 'SESSION_EXITED')
if (this.active !== undefined) {
const draining = this.activeWrite !== undefined
? ' or draining provider write'
@@ -336,7 +336,12 @@ export class LocalPtySession implements TerminalBackendSession {
: ''
throw new TerminalError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE')
}
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
if (request.signal?.aborted === true) {
// Named as a cancellation so the tool registry classifies it as the caller's abort.
const aborted = new Error('PTY send aborted before write')
aborted.name = 'AbortError'
throw aborted
}
const operation = new LocalSendOperation(
this.config.maxReadBytes,
@@ -452,7 +457,7 @@ export class LocalPtySession implements TerminalBackendSession {
}
async signal(signal: TerminalSignal): Promise<TerminalSignalResult> {
if (this.closing) throw new Error('PTY session is closing')
if (this.closing) throw new TerminalError('PTY session is closing', 'SESSION_CLOSING')
const targetPgid = await this.terminal.signalForeground(signal)
return { delivered: true, targetPgid }
}
@@ -133,6 +133,7 @@ describe('BashTerminalBackend startup rollback', () => {
controller.abort(abortReason)
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
await expect(backend.spawn(spec(agent(ctx)))).rejects.toMatchObject({ name: 'SubprocessError', code: 'SUBPROCESS_INVALID_ARGV' })
})
it('closes failed startup and aggregates cleanup failure', async () => {
@@ -347,6 +348,7 @@ describe('BashTerminalBackend startup rollback', () => {
await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toThrow(
'sandbox mode "workspace-write" requires a ctx.sandbox provider in the execution world',
)
await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'NO_SANDBOX' }))
})
it('forwards terminal allocation cancellation directly', async () => {
@@ -523,8 +525,10 @@ describe('BashTerminalBackend startup rollback', () => {
}) as unknown as LocalPtySession
const exited = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit'))
await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup')
await expect(exited.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_EXITED' }))
const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout'))
await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout')
await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_TIMEOUT' }))
})
it('bounds all pwsh startup retries with one deadline', async () => {
@@ -752,6 +752,7 @@ describe('LocalPtySession readiness and output', () => {
terminal.emitExit(7, 9)
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } })
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
expect(() => session.startSend({ text: '', submit: false })).toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_EXITED' }))
})
it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => {
@@ -776,6 +777,7 @@ describe('LocalPtySession readiness and output', () => {
const aborted = new AbortController()
aborted.abort()
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow(expect.objectContaining({ name: 'AbortError' }))
terminal.throwWrite = true
const failed = session.startSend({ text: 'x', submit: false })
@@ -1166,6 +1168,7 @@ describe('LocalPtySession readiness and output', () => {
const initializing = startup.initialize(new AbortController().signal)
startupTerminal.emitExit(1)
await expect(initializing).rejects.toThrow('exited during startup')
await expect(initializing).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'STARTUP_EXITED' }))
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const terminal = new FakeTerminal()
@@ -1238,7 +1241,9 @@ describe('LocalPtySession readiness and output', () => {
const timeoutTerminal = new FakeTerminal()
const timeout = new LocalPtySession(timeoutTerminal, config())
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
const timedOut = expect(timeout.initialize()).rejects.toEqual(expect.objectContaining({
name: 'TerminalError', code: 'STARTUP_TIMEOUT', message: expect.stringContaining('startup timeout') as string,
}))
await vi.advanceTimersByTimeAsync(100)
await timedOut
})
@@ -1640,8 +1645,8 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
const closing = session.close('test')
expect(session.close('other')).toBe(closing)
await expect(closing).rejects.toThrow('PTY cleanup failed (test)')
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
await expect(session.signal('SIGTERM')).rejects.toThrow('closing')
expect(() => session.startSend({ text: '', submit: false })).toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' }))
await expect(session.signal('SIGTERM')).rejects.toEqual(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' }))
})
it('reports cleanup failure without waiting for top-level exit and permits retry', async () => {
+2 -2
View File
@@ -48,8 +48,8 @@
en: 7af36e58919e41d5
zh: 4fe7b4db1099bcbf
/deepseek-ai-dsh-terminal/understand-the-implementation/send-reservation:
en: 5815723cd0c6089c
zh: 0c612ffa4b8c466c
en: 0b8dc3a382aa7bf2
zh: e6cc6906e01827d2
/deepseek-ai-dsh-terminal/further-exploration:
en: 8459a3b70a9f8bad
zh: 21372aa8939d2182
+1 -1
View File
@@ -88,7 +88,7 @@ Each published session is a record of its id, owner, optional name, backend type
### Send reservation
The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership.
The service reserves a session synchronously for one active send before returning the operation, including before a background job id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation never cross operation ownership. Failures are `TerminalError` instances (a `HarnessError`); besides the registry codes, a send to a closing session fails with `SESSION_CLOSING`, and backends report `SESSION_EXITED`, `STARTUP_EXITED`, `STARTUP_TIMEOUT`, `NO_SANDBOX`, and `INIT_REJECTED` with the same class.
</details>
+1 -1
View File
@@ -88,7 +88,7 @@ kind: "package-reference"
### 发送预留
服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。
服务在返回操作之前同步为一个活跃发送预留会话,包括在后台任务的 job id 可见之前;第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消永远不会跨操作所有权。失败均为 `TerminalError`(一种 `HarnessError`);除注册表 code 外,向正在关闭的会话发送会以 `SESSION_CLOSING` 失败,后端以同一类报告 `SESSION_EXITED`、`STARTUP_EXITED`、`STARTUP_TIMEOUT`、`NO_SANDBOX` 与 `INIT_REJECTED`。
</details>
+15 -2
View File
@@ -52,16 +52,29 @@ declare module '@deepseek-ai/cordis' {
}
}
/** Machine-routable PTY service failures. */
/**
* 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 {
@@ -246,7 +259,7 @@ export class TerminalSessionService extends Service {
*/
startSend(owner: Agent, id: TerminalSessionId, request: TerminalSendRequest): TerminalSendOperation {
const record = this.expectOwned(owner, id)
if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`)
if (record.closing !== undefined) throw new TerminalError(`PTY session ${id} is closing`, 'SESSION_CLOSING')
if (record.active !== undefined) throw new TerminalError(`PTY session ${id} already has an active send`, 'SEND_ACTIVE')
const operation = record.session.startSend(request)
record.active = operation
@@ -525,6 +525,8 @@ describe('TerminalSessionService ownership and lifecycle', () => {
b.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
const first = ctx.terminals.kill(owner, created.sessionId)
expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing')
expect(() => ctx.terminals.startSend(owner, created.sessionId, { text: '', submit: false }))
.toThrow(expect.objectContaining({ name: 'TerminalError', code: 'SESSION_CLOSING' }))
const second = ctx.terminals.kill(owner, created.sessionId)
b.sessions[0]!.closeGate?.resolve(undefined)
expect(await first).toBe(true)