From aa455f5a40d45bf6cce0d89cdc4b4c6b77a244da Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 26 Oct 2026 15:03:19 +0800 Subject: [PATCH] fix(ptc): preserve structured cancellation details --- .../ptc-runtime-node/README.i18n.yaml | 4 +-- .../ptc-runtime/ptc-runtime-node/README.md | 2 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 2 +- .../ptc-runtime-node/src/worker.ts | 8 ++--- .../tests/host-failures.spec.ts | 29 +++++++++++++++---- .../tests/persistent-host.spec.ts | 4 +-- .../ptc-runtime-node/tests/persistent.spec.ts | 16 +++++++--- .../ptc-runtime-node/tests/runtime.spec.ts | 2 +- snapshots/sdk/ptc-cancel/session.v4.jsonl | 2 +- snapshots/sdk/sdk.snapshot.ts | 2 +- 10 files changed, 49 insertions(+), 22 deletions(-) diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index a0ee6f5e6f..d9d40d1475 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -30,8 +30,8 @@ en: 3e7ab293fac35688 zh: 617ed4930bbef6c7 /deepseek-ai-dsh-ptc-runtime-node/use-this-package/failures: - en: 66fbe8baf4dc4506 - zh: b8adee47561365da + en: d4d3294f357b4afc + zh: 69992be84c94cd4a /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation: en: 50e6cd38e298da31 zh: c8b252b3acf59147 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 7c032381da..012e1d3f75 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -109,7 +109,7 @@ Omitting `timeoutMs` uses the configured elapsed default; numeric requests are v Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; unparsable or oversized control traffic and invalid program messages are `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit, lost control transport or failed managed cleanup is `worker-exit`. Control I/O failures retain the transport diagnostic alongside the observed process exit status. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse. -Temporary environments keep a string or `Error` message supplied by the caller; other cancellation reasons, including structured Agent causes, report `execution canceled`. Retained environments report `execution canceled; retained variables were cleared`. +Host failure diagnostics keep strings and `Error` messages unchanged and use Node inspection for other values, including objects with circular references. Cancellation diagnostics retain structured cause fields such as `kind` and a hook's `reason`; retained environments append `; retained variables were cleared`. ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index e20f6f7a7c..d936d9d33f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -109,7 +109,7 @@ PTC 消费方按 [dsh-tool-executor-ptc](../../core/tool-executor-ptc/README.zh. 程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;无法解析或超量的控制通信以及无效的程序消息为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出、控制通道丢失或受管清理失败为 `worker-exit`。控制通道 I/O 失败时,在观测到的进程退出状态之外保留通道诊断原因。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。 -临时环境保留调用方提供的字符串或 `Error` 消息;其他取消原因(包括结构化 Agent 原因)显示为 `execution canceled`。保留状态的环境显示为 `execution canceled; retained variables were cleared`。 +Host 失败诊断保留原始字符串与 `Error` 消息,并用 Node 检查格式化其他值,包括含有循环引用的对象。取消诊断保留结构化原因的字段,如 `kind` 与 hook 的 `reason`;保留状态的环境会追加 `; retained variables were cleared`。 ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts index 20814d19d2..2806de8b24 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts @@ -1,5 +1,6 @@ /** Managed Node process shared by temporary and owner-retained evaluations. */ import { AsyncLocalStorage } from 'node:async_hooks' +import { inspect } from 'node:util' import { Context } from '@deepseek-ai/cordis' import type { PtcRunSpec, PtcRunResult, PtcRunSandbox, PtcRunFailure, PtcBindingNamespace, PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox' @@ -18,7 +19,8 @@ import { OutputLedger } from './output-ledger.ts' import { drainOutput, terminateProcess } from './output-stream.ts' import { readProgramResult, type ProgramBootData, type ProgramMethods, type HostMethods } from './protocol.ts' -const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error) +const messageOf = (error: unknown): string => error instanceof Error ? error.message + : typeof error === 'string' ? error : inspect(error, { customInspect: false }) interface Evaluation { settled: boolean @@ -107,9 +109,7 @@ export class ProgramWorker { } private abortMessage(signal: AbortSignal | undefined): string { - if (this.retainsState) return 'execution canceled; retained variables were cleared' - const reason: unknown = signal?.reason - return typeof reason === 'string' || reason instanceof Error ? messageOf(reason) : 'execution canceled' + return this.message(messageOf(signal?.reason)) } private async evaluate(spec: PtcRunSpec, bindings: Map): Promise { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index da669c3a45..929ff480b9 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -130,11 +130,11 @@ describe('Node runtime host failures', () => { }) it.each<[unknown, string]>([ - [{ kind: 'user' }, 'execution canceled'], - [{ kind: 'parent' }, 'execution canceled'], - [{ kind: 'hook', reason: 'policy stopped the turn' }, 'execution canceled'], - [{ kind: 'disposed' }, 'execution canceled'], - [null, 'execution canceled'], + [{ kind: 'user' }, "{ kind: 'user' }"], + [{ kind: 'parent' }, "{ kind: 'parent' }"], + [{ kind: 'hook', reason: 'policy stopped the turn' }, "{ kind: 'hook', reason: 'policy stopped the turn' }"], + [{ kind: 'disposed' }, "{ kind: 'disposed' }"], + [null, 'null'], [new Error('stop requested'), 'stop requested'], ['stop requested', 'stop requested'], ])('renders pre-start cancellation %j as %s', async (reason, message) => { @@ -144,6 +144,25 @@ describe('Node runtime host failures', () => { expect(h.resolveExecutable).not.toHaveBeenCalled() }) + it('keeps structured startup failure details', async () => { + const h = await setup() + h.resolveExecutable.mockRejectedValue({ code: 'EXECUTABLE_UNAVAILABLE', executable: 'node' }) + const result = await h.start() + expect(result.error).toEqual({ + kind: 'worker-exit', message: "{ code: 'EXECUTABLE_UNAVAILABLE', executable: 'node' }", + }) + expect(h.spawn).not.toHaveBeenCalled() + }) + + it('renders a circular cancellation reason without interrupting settlement', async () => { + const h = await setup() + const reason: { kind: string; cause?: object } = { kind: 'hook' } + reason.cause = reason + const result = await h.start({ ...request, signal: AbortSignal.abort(reason) }, NO_INITIAL_FRAME) + expect(result.error).toEqual({ kind: 'abort', message: " { kind: 'hook', cause: [Circular *1] }" }) + expect(h.resolveExecutable).not.toHaveBeenCalled() + }) + it('keeps a null-deadline run active past the numeric ceiling until cancellation', async () => { const h = await setup({ timeoutMs: 20, maxTimeoutMs: 40 }) const booted = Promise.withResolvers() diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index 26298852a6..39b6508d4d 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -316,11 +316,11 @@ describe('persistent PTC host protocol', () => { h.direct.resolve({ exitCode: 9, signal: null }) await Promise.resolve() const controller = new AbortController() - if (outcome === 'abort') controller.abort() + if (outcome === 'abort') controller.abort({ kind: 'parent' }) if (outcome === 'cleanup') h.waitForExit.mockRejectedValue(new Error('exit cleanup failed')) const result = await h.runtime.run(h.runtime.resolve({ ...request, program: '2', signal: controller.signal })) expect(result.error?.kind).toBe(outcome === 'abort' ? 'abort' : 'worker-exit') - expect(result.error?.message).toContain(outcome === 'abort' ? 'execution canceled' : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') + expect(result.error?.message).toContain(outcome === 'abort' ? "{ kind: 'parent' }; retained variables were cleared" : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') expect(h.messages).toHaveLength(1) expect(h.messages[0]).toMatchObject({ t: 'q', m: 'run' }) expect(h.spawn).toHaveBeenCalledTimes(1) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts index 67cdcc7c8a..428b162a61 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts @@ -207,10 +207,18 @@ describe.each([ signal: controller.signal, bindings: bindings(async () => { entered.resolve(true); return null }), }) - await entered.promise - controller.abort() - expect((await pending).error?.kind).toBe('abort') - expect((await run('typeof app')).value).toBe('undefined') + try { + await entered.promise + controller.abort({ kind: 'hook', reason: 'policy stopped the turn' }) + expect((await pending).error).toEqual({ + kind: 'abort', + message: "{ kind: 'hook', reason: 'policy stopped the turn' }; retained variables were cleared", + }) + expect((await run('typeof app')).value).toBe('undefined') + } finally { + controller.abort('test cleanup') + await pending + } }) it('resets a timed-out environment and does not reuse an exited process', async () => { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index ec7bae7fe4..6c70755fea 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -189,7 +189,7 @@ describe('Node program process', () => { try { await entered.promise controller.abort({ kind: 'user' }) - expect((await active).error).toEqual({ kind: 'abort', message: 'execution canceled' }) + expect((await active).error).toEqual({ kind: 'abort', message: "{ kind: 'user' }" }) } finally { controller.abort('test cleanup') await active diff --git a/snapshots/sdk/ptc-cancel/session.v4.jsonl b/snapshots/sdk/ptc-cancel/session.v4.jsonl index cf5deccb0a..e143054de1 100644 --- a/snapshots/sdk/ptc-cancel/session.v4.jsonl +++ b/snapshots/sdk/ptc-cancel/session.v4.jsonl @@ -16,6 +16,6 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}} {"type":"tool/ptc-dispatch-start","data":{"rootCallId":"ptc-cancel","parentCallId":"ptc-cancel","subCallId":"ptc-cancel:ptc:1","name":"working_directory","arguments":{}}} {"type":"tool/ptc-dispatch","data":{"rootCallId":"ptc-cancel","parentCallId":"ptc-cancel","subCallId":"ptc-cancel:ptc:1","name":"working_directory","arguments":{},"isError":false,"content":[{"type":"text","text":"{{cwd}}"}]}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"ptc-cancel"},"toolCallId":"ptc-cancel","content":[{"type":"text","text":"Error: code run failed (abort): execution canceled\nFile sandbox: danger-full-access."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"ptc-cancel"},"toolCallId":"ptc-cancel","content":[{"type":"text","text":"Error: code run failed (abort): { kind: 'user' }\nFile sandbox: danger-full-access."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 587996d653..3bd8b0965b 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -1018,7 +1018,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { expect(events.find(event => event.type === 'tool/result')).toMatchObject({ data: { message: { toolCallId: 'ptc-cancel', isError: true, - content: [{ type: 'text', text: 'Error: code run failed (abort): execution canceled\nFile sandbox: danger-full-access.' }], + content: [{ type: 'text', text: "Error: code run failed (abort): { kind: 'user' }\nFile sandbox: danger-full-access." }], } } }) expect(events.find(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },