From b7849a5eea43bcdf0e8dbab97727bae25c7272f0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 26 Oct 2026 14:40:33 +0800 Subject: [PATCH] fix(ptc): render structured cancellation reasons readably --- .../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 | 4 +++- .../tests/host-failures.spec.ts | 15 +++++++++++++ .../ptc-runtime-node/tests/runtime.spec.ts | 11 +++++++--- snapshots/sdk/ptc-cancel/cancel.mjs | 10 +++++++++ snapshots/sdk/ptc-cancel/runtime.cordis.yml | 5 +++++ snapshots/sdk/ptc-cancel/session.v4.jsonl | 21 +++++++++++++++++++ snapshots/sdk/ptc-cancel/snapshot.yml | 7 +++++++ snapshots/sdk/sdk.snapshot.ts | 14 +++++++++++++ 11 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 snapshots/sdk/ptc-cancel/cancel.mjs create mode 100644 snapshots/sdk/ptc-cancel/runtime.cordis.yml create mode 100644 snapshots/sdk/ptc-cancel/session.v4.jsonl create mode 100644 snapshots/sdk/ptc-cancel/snapshot.yml diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 1ecdc1933e..a0ee6f5e6f 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: 34a221d0f26b8e47 - zh: 2fd20ac9c9953c7f + en: 66fbe8baf4dc4506 + zh: b8adee47561365da /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 a7fe249acf..7c032381da 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -109,6 +109,8 @@ 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`. + ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 7a2503ce1f..e20f6f7a7c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -109,6 +109,8 @@ 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`。 + ----- diff --git a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts index ff5307c320..20814d19d2 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts @@ -107,7 +107,9 @@ export class ProgramWorker { } private abortMessage(signal: AbortSignal | undefined): string { - return this.retainsState ? 'execution canceled; retained variables were cleared' : messageOf(signal?.reason) + 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' } 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 5a5aad761e..da669c3a45 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 @@ -129,6 +129,21 @@ describe('Node runtime host failures', () => { expect(h.resolveExecutable).not.toHaveBeenCalled() }) + 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'], + [new Error('stop requested'), 'stop requested'], + ['stop requested', 'stop requested'], + ])('renders pre-start cancellation %j as %s', async (reason, message) => { + const h = await setup() + const result = await h.start({ ...request, signal: AbortSignal.abort(reason) }, NO_INITIAL_FRAME) + expect(result.error).toEqual({ kind: 'abort', message }) + 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/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index 03688e1ae4..ec7bae7fe4 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -186,9 +186,14 @@ describe('Node program process', () => { const entered = Promise.withResolvers() const controller = new AbortController() const active = run({ ...timing, program: 'void tools.enter({}); for (;;) {}', signal: controller.signal, bindings: bindings({ enter: async () => { entered.resolve(undefined); return null } }) }) - await entered.promise - controller.abort('stop') - expect((await active).error).toEqual({ kind: 'abort', message: 'stop' }) + try { + await entered.promise + controller.abort({ kind: 'user' }) + expect((await active).error).toEqual({ kind: 'abort', message: 'execution canceled' }) + } finally { + controller.abort('test cleanup') + await active + } }) it('closes a still-running descendant before returning the program result', async () => { diff --git a/snapshots/sdk/ptc-cancel/cancel.mjs b/snapshots/sdk/ptc-cancel/cancel.mjs new file mode 100644 index 0000000000..0f7e8ebb9d --- /dev/null +++ b/snapshots/sdk/ptc-cancel/cancel.mjs @@ -0,0 +1,10 @@ +/** Cancel only after a real PTC process has dispatched its readiness tool. */ +export const name = 'ptc-cancel-fixture' +export const inject = ['tools'] +export function apply(ctx) { + ctx.on('tools/result', (exec) => { + if (exec.name === 'working_directory' && exec.parent !== undefined) { + exec.agent.cancel({ kind: 'user' }) + } + }) +} diff --git a/snapshots/sdk/ptc-cancel/runtime.cordis.yml b/snapshots/sdk/ptc-cancel/runtime.cordis.yml new file mode 100644 index 0000000000..4b3df2b8c7 --- /dev/null +++ b/snapshots/sdk/ptc-cancel/runtime.cordis.yml @@ -0,0 +1,5 @@ +- insert: + - id: ptc-runtime + name: '@deepseek-ai/dsh-ptc-runtime-node' + - id: ptc-cancel-fixture + name: './cancel.mjs' diff --git a/snapshots/sdk/ptc-cancel/session.v4.jsonl b/snapshots/sdk/ptc-cancel/session.v4.jsonl new file mode 100644 index 0000000000..cf5deccb0a --- /dev/null +++ b/snapshots/sdk/ptc-cancel/session.v4.jsonl @@ -0,0 +1,21 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1785014439563,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start a run_code program, call working_directory, then keep running until I stop it."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Start a run_code program, call working_directory, then keep running until I stop it."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Start a run_code program, call","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"stream":[{"type":"chunk","time":1792996619936,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792996619936,"index":0,"dt":[],"id":"ptc-cancel","name":"run_code","args":["{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"]},{"type":"chunk","time":1792996619936,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"ptc-cancel","name":"run_code","arguments":"{\"code\":\"await tools.working_directory({}); for (;;) {}\",\"description\":\"Run until stopped\"}"}}},{"type":"chunk","time":1792996619936,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"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":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/snapshots/sdk/ptc-cancel/snapshot.yml b/snapshots/sdk/ptc-cancel/snapshot.yml new file mode 100644 index 0000000000..50f022e261 --- /dev/null +++ b/snapshots/sdk/ptc-cancel/snapshot.yml @@ -0,0 +1,7 @@ +version: 1 +scenario: ptc-cancel +profile: sdk +composition: ptc +recording: authored +header: + class: ptc diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 2f5bbb00b5..587996d653 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -159,6 +159,9 @@ const SDK_ASSERTIONS: Readonly> = { patches: [fileURLToPath(new URL('./tool-error-details/runtime.cordis.yml', import.meta.url))], expectedFinalResponse: 'ERROR_DETAILS_OK', }, + 'ptc-cancel': { + patches: [fileURLToPath(new URL('./ptc-cancel/runtime.cordis.yml', import.meta.url))], + }, 'ptc-turn': { patches: [fileURLToPath(new URL('./ptc-turn/runtime.cordis.yml', import.meta.url))], expectedFinalResponse: 'CODE_ONE+CODE_TWO', @@ -1010,6 +1013,17 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { ) reconcileCatalogCreationTimes(ordered.map(log => log.content), 'validate') const actualContext = contextOf(ordered, cwd) + if (scenario.name === 'ptc-cancel') { + const events = results.flatMap(result => result.events) + 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.' }], + } } }) + expect(events.find(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, + }) + } if (scenario.name === 'minimal-image-compaction') { const events = results.flatMap(result => result.events) const failures = events.filter(event => event.type === 'turn/end' && event.data.reason.kind === 'error')