Forráskód Böngészése

fix(workflow): close lifecycle and CI integration gaps

Tianyi Cui 2 hete
szülő
commit
34f80b8e6e
34 módosított fájl, 356 hozzáadás és 105 törlés
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-13-workflow-ptc-sandbox-reuse.i18n.yaml
  2. 4 0
      .agents/notes/implemented/architecture/2026-09-13-workflow-ptc-sandbox-reuse.md
  3. 4 0
      .agents/notes/implemented/architecture/2026-09-13-workflow-ptc-sandbox-reuse.zh.md
  4. 3 0
      .github/workflows/ci.yml
  5. 2 2
      packages/experimental/ptc-runtime-python/README.i18n.yaml
  6. 1 0
      packages/experimental/ptc-runtime-python/README.md
  7. 1 0
      packages/experimental/ptc-runtime-python/README.zh.md
  8. 2 2
      packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml
  9. 1 1
      packages/ptc-runtime/ptc-runtime-node/README.md
  10. 1 1
      packages/ptc-runtime/ptc-runtime-node/README.zh.md
  11. 20 6
      packages/ptc-runtime/ptc-runtime-node/src/process.ts
  12. 69 0
      packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts
  13. 1 0
      packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts
  14. 2 2
      packages/workflow/workflow-ptc/README.i18n.yaml
  15. 3 1
      packages/workflow/workflow-ptc/README.md
  16. 3 1
      packages/workflow/workflow-ptc/README.zh.md
  17. 0 0
      packages/workflow/workflow-ptc/src/guest-source.ts
  18. 4 4
      packages/workflow/workflow-ptc/src/guest-types.ts
  19. 30 12
      packages/workflow/workflow-ptc/src/guest.ts
  20. 24 7
      packages/workflow/workflow-ptc/src/host.ts
  21. 1 1
      packages/workflow/workflow-ptc/src/index.ts
  22. 2 2
      packages/workflow/workflow-ptc/src/meta.ts
  23. 84 1
      packages/workflow/workflow-ptc/tests/guest.spec.ts
  24. 41 15
      packages/workflow/workflow-ptc/tests/host.spec.ts
  25. 20 0
      packages/workflow/workflow-ptc/tests/integration.spec.ts
  26. 4 2
      packages/workflow/workflow-ptc/tests/source-runtime.compat.spec.ts
  27. 1 1
      packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts
  28. 11 0
      scripts/ci-workflow.spec.ts
  29. 7 1
      snapshots/session/ptc-python-turn/cordis.snapshot.yml
  30. 7 1
      snapshots/session/ptc-python-turn/cordis.yml
  31. 0 39
      snapshots/session/ptc-python-turn/system-prompt.expected.md
  32. 0 0
      snapshots/session/skill-load/session.v3.jsonl
  33. 0 0
      snapshots/web/skill-tool-row/ui.expected.md
  34. 1 1
      vitest.config.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-13-workflow-ptc-sandbox-reuse.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-13-workflow-ptc-sandbox-reuse.md
-2026-09-13-workflow-ptc-sandbox-reuse.md: 70a6d068518deba85122d3394f4ea50e9aea5572
-2026-09-13-workflow-ptc-sandbox-reuse.zh.md: 0b316e559566b6c4e14b58952e10480473307cda
+2026-09-13-workflow-ptc-sandbox-reuse.md: 9454ffef28e1ab5ba7791a30f4bcd67093a34ba3
+2026-09-13-workflow-ptc-sandbox-reuse.zh.md: c51706065703866520bfcad11beffc02eb1f5551

+ 4 - 0
.agents/notes/implemented/architecture/2026-09-13-workflow-ptc-sandbox-reuse.md

@@ -18,6 +18,10 @@ Workflow execution passes `timeoutMs: null`, which explicitly disables the elaps
 
 Cancellation immediately aborts the PTC process and the signal shared by pending and active child agents. The adapter awaits pending starts and child disposal, including a child that publishes after cancellation. PTC stops the program; its caller remains responsible for host bindings already in flight. There is no additional workflow cleanup timer or guest cancellation acknowledgement.
 
+Progress uses one binding call at a time. The first batch starts synchronously; later events queue in order and drain before child disposal and the final result. This prevents ordinary log bursts from exhausting PTC's pending-call limit. Child-result waits stop on cancellation while child disposal remains awaited.
+
+The Node bootstrap keeps its control pipe open after sending the terminal frame until the host closes it. Unawaited binding replies may still be in flight, so eager child-side close would let an `EPIPE` race an already completed program.
+
 The [dynamic-workflows decision](../feature/2026-07-05-dynamic-workflows.md) retains the script, structured-output, event and tool semantics; this note supersedes only its execution substrate and trust realization. The [sandboxed Node PTC decision](2026-09-11-sandboxed-node-ptc-runtime.md) retains execution and control guarantees; the explicit null deadline extends its service options. The [agent-scope runtime design](2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) retains pending-start and child-cleanup ownership.
 
 ## Alternatives considered

+ 4 - 0
.agents/notes/implemented/architecture/2026-09-13-workflow-ptc-sandbox-reuse.zh.md

@@ -18,6 +18,10 @@ VM 定义辅助 API,以及协作式并发、agent 总数和条目上限。它
 
 取消立即中止 PTC 进程,以及待启动和活跃子 agent 共享的信号。适配器等待待完成启动与子 agent 资源释放,包括取消后才发布的子 agent。PTC 停止程序;已经进行中的 Host 绑定仍由其调用方负责。不增加工作流清理定时器,也不等待 guest 取消确认。
 
+进度同时只使用一个绑定调用。首批同步发起,后续事件按序排队,并在子 agent 资源释放及最终结果之前完成传递。这避免普通日志突发耗尽 PTC 的待完成调用上限。取消会停止对子 agent 结果的等待,但仍等待子 agent 资源释放。
+
+Node 引导程序发送终态帧后保持控制管道打开,直到 Host 将其关闭。未被等待的绑定回复可能仍在传输,因此子进程提前关闭会让 `EPIPE` 与已经完成的程序结果发生竞争。
+
 [动态工作流决策](../feature/2026-07-05-dynamic-workflows.zh.md)保留脚本、结构化输出、事件与工具语义;本文只取代其执行基底与信任实现。[沙箱化 Node PTC 决策](2026-09-11-sandboxed-node-ptc-runtime.zh.md)保留执行与控制保证;显式 null 截止扩展其服务选项。[agent 作用域运行时设计](2026-07-12-agent-scope-runtime-design.zh.md#workflow-children-are-pending-starts-or-published-records)保留待启动与子 agent 清理的归属规则。
 
 ## 曾考虑的替代方案

+ 3 - 0
.github/workflows/ci.yml

@@ -436,6 +436,9 @@ jobs:
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
 
+      - name: Prepare bubblewrap (unrestrict userns)
+        run: bash scripts/prepare-ci-bubblewrap.sh
+
       - name: Run compatibility smokes
         env:
           DSH_BUILD_CLIENT_PROFILE: official

+ 2 - 2
packages/experimental/ptc-runtime-python/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/ptc-runtime-python/README.md
-README.md: 879fef9cbc9f9bded5aabb2860a13b0d9586367f
-README.zh.md: 4f5a9e602e7264b7f906ce55d10ce763f2cbf8cc
+README.md: 287617991c5da9c13775e40c325877c231dbc312
+README.zh.md: f0c64a535cbac53241a807cc9901074f9ddd843d

+ 1 - 0
packages/experimental/ptc-runtime-python/README.md

@@ -117,6 +117,7 @@ These limits define what the package does and does not cover; they are current p
 - **A `log` frame that arrives after settlement is dropped** — once the run has settled, host-side capture is closed; a late fd-3 `log` frame (from a thread that outlived the done frame) is discarded rather than appended to `logs`.
 - **A binding REPLY value has no seam-level byte or depth cap** — `maxValueBytes` meters only the done frame's completion value; a wide binding reply is rebuilt host-side (`snapshotJsonValue` traversal) and encoded whole, bounded on both sides only by process memory (like a binding argument, which has no child-side budget either).
 - **No shipped profile mounts this provider** — the keyless `ptc-python-turn` snapshot replaces the headless PTC runtime through the real Loader; released profiles use the sandboxed Node process backend.
+- **Workflow execution requires Node** — compositions using this Python provider disable `workflow-ptc`, `tool-workflow`, and `tool-ralph`; the workflow provider rejects an incompatible runtime when loaded.
 - **Cross-channel log interleaving is backend-dependent** — Python stdout, stderr, and fd-3 log frames travel independently; each channel preserves its own order, while their total order in `result.logs` may differ.
 - **CPython 3.10 or newer is required** — the configured executable is resolved and version-probed at load; unsupported interpreters fail before `ctx.ptcRuntime` is registered.
 - **Diagnostic and temporary-directory prefixes omit the package's experimental qualifier** — the marker `[dsh-ptc-runtime-python] log capture truncated at <N> bytes` and the `dsh-ptc-runtime-python-` directory prefix identify this provider independently of its npm package name. The protocol mirror verifies identical marker bytes in TypeScript and Python.

+ 1 - 0
packages/experimental/ptc-runtime-python/README.zh.md

@@ -117,6 +117,7 @@ kind: "package-reference"
 - **结算后到达的 `log` 帧被丢弃**——运行一旦结算,宿主侧捕获即关闭;迟到的 fd-3 `log` 帧(来自比 done 帧存活更久的线程)会被丢弃,而不是追加到 `logs`。
 - **binding 回复值没有 seam 级字节或深度上限**——`maxValueBytes` 只计量 done 帧的完成值;宽 binding 回复在宿主侧重建(`snapshotJsonValue` 遍历)并整帧编码,两侧都只受进程内存约束(与没有子进程侧预算的 binding 实参一样)。
 - **已发布 profile 均不挂载本提供方**——keyless `ptc-python-turn` 快照通过真实 Loader 替换 headless PTC 运行时;已发布 profile 使用沙箱 Node 进程后端。
+- **工作流执行需要 Node**——使用本 Python 提供方的组合禁用 `workflow-ptc`、`tool-workflow` 和 `tool-ralph`;工作流提供方在加载时拒绝不兼容的运行时。
 - **跨通道日志交错由后端决定**——Python stdout、stderr 与 fd-3 日志帧彼此独立传输;每个通道保留自身顺序,但它们在 `result.logs` 中的总顺序可能不同。
 - **需要 CPython 3.10 或更高版本**——配置的可执行文件会在加载期完成解析与版本探测;不受支持的解释器会在 `ctx.ptcRuntime` 注册前失败。
 - **诊断与临时目录前缀省略包名中的 experimental 限定词**——标记 `[dsh-ptc-runtime-python] log capture truncated at <N> bytes` 与 `dsh-ptc-runtime-python-` 目录前缀独立于 npm 包名来标识本提供方。协议镜像检查 TypeScript 与 Python 的标记字节完全相同。

+ 2 - 2
packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/ptc-runtime/ptc-runtime-node/README.md
-README.md: 4ba23d3fcb7d12d728abb4f438f62d5409524568
-README.zh.md: 77605e4621da7eae4ab48626abd1665e392a737d
+README.md: f8f7b5535ba8ed1594b898d410df04e24f5dc8ef
+README.zh.md: dd4e33e70f05ae3917f128e33bf26af5d1a915c4

+ 1 - 1
packages/ptc-runtime/ptc-runtime-node/README.md

@@ -87,7 +87,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o
 
 The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded.
 
-Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
+Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
 
 ### Source and built bootstraps
 

+ 1 - 1
packages/ptc-runtime/ptc-runtime-node/README.zh.md

@@ -87,7 +87,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责
 
 Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。
 
-带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
+带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
 
 ### 源代码与构建后 bootstrap
 

+ 20 - 6
packages/ptc-runtime/ptc-runtime-node/src/process.ts

@@ -19,7 +19,7 @@ export interface ProgramProcess {
  * @param stream - Inherited, already-adopted control endpoint.
  * @param maxMessageBytes - Host-validated maximum frame and queued-write bytes.
  * @param processState - Environment, output streams and exit status of this Node child.
- * @returns After the program has settled and its control output has flushed.
+ * @returns After control output flushes and host shutdown is observed, or transport failure closes the channel.
  */
 export async function runNodeMain(stream: Duplex, maxMessageBytes: number, processState: ProgramProcess): Promise<void> {
   if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit')
@@ -32,6 +32,10 @@ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, proce
   const listeners: Array<(message: ReplyMessage) => void> = []
   let started = false
   let failed = false
+  let terminalSent = false
+  const hostClosed = Promise.withResolvers<void>()
+  const onClose = (): void => { hostClosed.resolve() }
+  stream.once('close', onClose)
   const channel = new JsonChannel(stream, maxMessageBytes, (raw) => {
     if (!started) {
       started = true
@@ -42,19 +46,26 @@ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, proce
       boot.resolve((raw as { data: ProgramBootData }).data)
       return
     }
+    if (terminalSent) return
     for (const listener of listeners) listener(raw as ReplyMessage)
-  }, (error) => {
-    failed = true
-    boot.reject(error)
-    channel.close()
-    processState.exitCode = 1
+  }, (error, kind) => {
+    if (!terminalSent || kind === 'protocol') {
+      failed = true
+      boot.reject(error)
+      channel.close()
+      processState.exitCode = 1
+    }
+    hostClosed.resolve()
   })
   const pending = new Set<Promise<void>>()
   const send = (message: ProgramToHost): void => {
+    if (terminalSent) return
+    if (message.type === 'done') terminalSent = true
     const task = channel.send(message).catch(() => {
       failed = true
       channel.close()
       processState.exitCode = 1
+      hostClosed.resolve()
     }).finally(() => { pending.delete(task) })
     pending.add(task)
   }
@@ -67,7 +78,10 @@ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, proce
     }, data, { stdout: processState.stdout, stderr: processState.stderr })
     while (pending.size > 0) await Promise.all(pending)
     await channel.drain()
+    // Host binding replies can race the terminal frame; the host owns channel shutdown.
+    await hostClosed.promise
   } finally {
+    stream.off('close', onClose)
     channel.close()
     // Transport callbacks can set failed while the awaited program executes.
     // oxlint-disable-next-line typescript/no-unnecessary-condition

+ 69 - 0
packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts

@@ -1,4 +1,5 @@
 import { Duplex, PassThrough } from 'node:stream'
+import { setImmediate as nextTurn } from 'node:timers/promises'
 import { expect, it, onTestFinished } from 'vitest'
 import { JsonChannel } from '../src/channel.ts'
 import { runNodeMain } from '../src/process.ts'
@@ -31,6 +32,7 @@ it('clears process environment, dispatches a binding reply and flushes the termi
     messages.push(message)
     if (message.type === 'ready') void peer.send({ type: 'boot', data: { code: 'console.log("ready"); return await tools.echo({});', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 1024 } })
     if (message.type === 'call') void peer.send({ type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(42) })
+    if (message.type === 'done') host.end()
   }, () => {})
   onTestFinished(() => { peer.close() })
   await runNodeMain(child, 4096, state)
@@ -42,6 +44,39 @@ it('clears process environment, dispatches a binding reply and flushes the termi
   expect(decodePtcJsonWire(messages.find(message => message.type === 'done')?.value)).toBe(42)
 })
 
+it('keeps the control pipe open for a late binding reply until the host closes it', async () => {
+  const { child, host } = endpoints()
+  const state = processState()
+  const terminal = Promise.withResolvers<unknown>()
+  const messages: string[] = []
+  let callId: unknown
+  const peer = new JsonChannel(host, 4096, (raw) => {
+    const message = raw as Record<string, unknown>
+    messages.push(String(message.type))
+    if (message.type === 'ready') void peer.send({ type: 'boot', data: {
+      code: 'void tools.echo({}).then(() => { throw new Error("late reply resumed the program") }); setImmediate(() => console.log("late timer")); return 42;',
+      namespaces: [{ global: 'tools', names: ['echo'] }],
+      maxOutputBytes: 1024,
+    } })
+    if (message.type === 'call') callId = message.id
+    if (message.type === 'done') terminal.resolve(decodePtcJsonWire(message.value))
+  }, (error) => { terminal.reject(error) })
+  const main = runNodeMain(child, 4096, state)
+  try {
+    expect(await terminal.promise).toBe(42)
+    // Settle the terminal write callbacks while the host still owns the open pipe.
+    await nextTurn()
+    expect(child.destroyed).toBe(false)
+    await peer.send({ type: 'reply', id: callId, ok: true, value: encodePtcJsonWire(42) })
+    await nextTurn()
+    expect(messages).toEqual(['ready', 'call', 'done'])
+  } finally {
+    peer.close()
+    await main
+  }
+  expect(state.exitCode).toBeUndefined()
+})
+
 it.each([0, -1, 1.5, 4294967296])('rejects an invalid bootstrap frame limit %i', async (limit) => {
   const { child } = endpoints()
   await expect(runNodeMain(child, limit, processState())).rejects.toThrow('invalid control message limit')
@@ -72,3 +107,37 @@ it('contains program writes that exceed queued control output', async () => {
   await runNodeMain(child, 1024, state)
   expect(state.exitCode).toBe(1)
 })
+
+it('records a terminal frame that cannot fit the control write budget as failure', async () => {
+  const { child, host } = endpoints()
+  const state = processState()
+  const peer = new JsonChannel(host, 1024, (raw) => {
+    if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: {
+      code: 'return "x".repeat(2000)', namespaces: [], maxOutputBytes: 8000,
+    } })
+  }, () => {})
+  onTestFinished(() => { peer.close() })
+  await runNodeMain(child, 1024, state)
+  expect(state.exitCode).toBe(1)
+})
+
+it('retains a malformed host frame failure after the terminal frame', async () => {
+  const { child, host } = endpoints()
+  const state = processState()
+  const terminal = Promise.withResolvers<undefined>()
+  const peer = new JsonChannel(host, 1024, (raw) => {
+    const message = raw as { type: string }
+    if (message.type === 'ready') void peer.send({ type: 'boot', data: {
+      code: 'return 42', namespaces: [], maxOutputBytes: 1000,
+    } })
+    if (message.type === 'done') terminal.resolve(undefined)
+  }, () => {})
+  onTestFinished(() => { peer.close() })
+  const main = runNodeMain(child, 1024, state)
+  try {
+    await terminal.promise
+    host.write(Buffer.from([0, 0, 0, 1, 0xff]))
+    await main
+    expect(state.exitCode).toBe(1)
+  } finally { peer.close(); await main }
+})

+ 1 - 0
packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts

@@ -45,5 +45,6 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep
   }, (error) => { completed.reject(error) })
   onTestFinished(async () => { channel.close(); child.kill(); await finished })
   expect(await completed.promise).toEqual({ answer: 42, env: {} })
+  channel.close()
   await finished
 })

+ 2 - 2
packages/workflow/workflow-ptc/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/workflow/workflow-ptc/README.md
-README.md: f6f91fbe7cbd40aff0c46b5d2f77885b505f0b66
-README.zh.md: e364b8c9e8be5ebd01127702a00af77a98266978
+README.md: ddc160a2725d36cf30afede2d4afed746a6b89d1
+README.zh.md: 3b562bfe12f5dfdc22b55f209d2120a548d60c24

+ 3 - 1
packages/workflow/workflow-ptc/README.md

@@ -25,7 +25,7 @@ Run JavaScript workflows in fresh Node processes under the calling Session's fil
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this engine in a composition that provides subagents, sandbox policy and the [Node PTC runtime](../../ptc-runtime/ptc-runtime-node/README.md). It supplies workflow execution for `dsh-tool-workflow` and for `dsh-tool-ralph` when explicitly enabled. Ralph remains disabled in shipped defaults.
+Mount this engine in a composition that provides subagents, sandbox policy and the [Node PTC runtime](../../ptc-runtime/ptc-runtime-node/README.md). It supplies workflow execution for `dsh-tool-workflow` and for `dsh-tool-ralph` when explicitly enabled. Ralph remains disabled in shipped defaults. The engine rejects non-TypeScript PTC providers when it loads. Python PTC compositions must disable the `workflow-ptc`, `tool-workflow` and any enabled `tool-ralph` rows.
 
 ### Minimal configuration
 
@@ -46,6 +46,8 @@ With those dependencies available, mount the engine and its model-facing consume
 
 An owning consumer may set `WorkflowStartRequest.subagentProvider` and lower `WorkflowStartRequest.maxTotalAgents` for one run. Script hooks cannot change either choice. Process heap, output, control and termination limits belong to the Node PTC provider; the engine adds no overall elapsed timer. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-workflow-ptc) defines accepted engine fields.
 
+The Node PTC provider's `maxPendingCalls` also limits workflow concurrency: child startup, result waits and disposal use those slots. Progress batches use at most one additional slot. Leave headroom when setting `maxConcurrentAgents`.
+
 ### Results and failures
 
 The script runs with top-level `await`; `meta` and `args` arrive as JSON data. Every `agent()` call uses the configured subagent provider and the run's fixed parent. The final lossless-JSON return value becomes the run result; an ordinary child failure resolves `agent()` to `null`.

+ 3 - 1
packages/workflow/workflow-ptc/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-在提供 subagent、沙箱策略和 [Node PTC 运行时](../../ptc-runtime/ptc-runtime-node/README.zh.md)的组合中挂载本引擎。它为 `dsh-tool-workflow` 及显式启用时的 `dsh-tool-ralph` 提供工作流执行。Ralph 在已发布默认组合中保持禁用。
+在提供 subagent、沙箱策略和 [Node PTC 运行时](../../ptc-runtime/ptc-runtime-node/README.zh.md)的组合中挂载本引擎。它为 `dsh-tool-workflow` 及显式启用时的 `dsh-tool-ralph` 提供工作流执行。Ralph 在已发布默认组合中保持禁用。引擎在加载时拒绝非 TypeScript 的 PTC 提供方。Python PTC 组合必须禁用 `workflow-ptc`、`tool-workflow` 以及任何已启用的 `tool-ralph` 条目。
 
 ### 最小配置
 
@@ -46,6 +46,8 @@ kind: "package-reference"
 
 负责运行的消费方可以为一次运行设置 `WorkflowStartRequest.subagentProvider` 并降低 `WorkflowStartRequest.maxTotalAgents`。脚本钩子不能更改这两项选择。进程堆、输出、控制通信和终止限制由 Node PTC 提供方负责;引擎不增加整体经过时间定时器。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-workflow-ptc)定义可接受的引擎字段。
 
+Node PTC 提供方的 `maxPendingCalls` 也限制工作流并发:子 agent 启动、结果等待与资源释放会占用这些名额。进度批次至多再占用一个名额。设置 `maxConcurrentAgents` 时应预留余量。
+
 ### 结果与失败
 
 脚本支持顶层 `await`;`meta` 和 `args` 作为 JSON 数据传入。每次 `agent()` 调用使用配置的 subagent 提供方及运行固定的父级。最终的无损 JSON 返回值成为运行结果;普通子 agent 失败使 `agent()` 以 `null` 兑现。

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
packages/workflow/workflow-ptc/src/guest-source.ts


+ 4 - 4
packages/workflow/workflow-ptc/src/guest-types.ts

@@ -51,9 +51,9 @@ export interface WorkflowGuestHost {
    */
   disposeChild(request: WorkflowChildRequest): Promise<null>
   /**
-   * Publish progress for this workflow run.
-   * @param event - Script narration or child lifecycle data.
-   * @returns Null after the host accepts the event.
+   * Publish an ordered batch of progress for this workflow run.
+   * @param events - Script narration and child lifecycle data in emission order.
+   * @returns Null after the host accepts every event.
    */
-  progress(event: WorkflowProgress): Promise<null>
+  progress(events: WorkflowProgress[]): Promise<null>
 }

+ 30 - 12
packages/workflow/workflow-ptc/src/guest.ts

@@ -8,23 +8,37 @@ import type { ExecutionObserver } from './runtime.ts'
 import type { ChildPort } from './types.ts'
 
 /**
- * Run a workflow and await every progress callback before returning its result.
- * Cancellation belongs to the PTC runtime and the host's child cleanup.
+ * Run a workflow with one progress batch in flight. Drain progress before child
+ * disposal and the terminal result; PTC and the host own cancellation and cleanup.
  * @param host - JSON callbacks owned by this workflow run.
  * @returns The script result after progress delivery; initialization failures reject.
  */
 export async function runWorkflowGuest(host: WorkflowGuestHost): Promise<WorkflowResult> {
   const init = await host.begin({})
-  const progress = new Set<Promise<void>>()
+  let queued: WorkflowProgress[] = []
+  let inFlight: Promise<void> | undefined
   let progressError: string | undefined
-  const send = (event: WorkflowProgress): void => {
-    // Start the callback synchronously so narration reaches the host before a hot loop.
-    const task = host.progress(event).then(
-      () => {},
-      (error: unknown) => { progressError ??= renderThrown(error) },
+  const flush = (): void => {
+    if (inFlight !== undefined || queued.length === 0) return
+    const batch = queued
+    queued = []
+    inFlight = host.progress(batch).then(
+      () => { inFlight = undefined; flush() },
+      (error: unknown) => {
+        progressError = renderThrown(error)
+        queued = []
+        inFlight = undefined
+      },
     )
-    progress.add(task)
-    void task.then(() => { progress.delete(task) })
+  }
+  const send = (event: WorkflowProgress): void => {
+    if (progressError !== undefined) return
+    queued.push(event)
+    // The first batch reaches the host before a synchronous script can seize its loop.
+    flush()
+  }
+  const drain = async (): Promise<void> => {
+    while (inFlight !== undefined) await inFlight
   }
   const observer: ExecutionObserver = {
     phase: (title) => { send({ type: 'phase', title }) },
@@ -41,13 +55,17 @@ export async function runWorkflowGuest(host: WorkflowGuestHost): Promise<Workflo
       return {
         id: childId,
         result,
-        async dispose() { await host.disposeChild({ callId }) },
+        async dispose() {
+          // Start observers must receive the child while its host registration is still live.
+          await drain()
+          await host.disposeChild({ callId })
+        },
       }
     },
   }
   const execution = new WorkflowExecution(init.meta, init.body, init.args, init.limits, observer, children)
   const result = await execution.drive()
-  await Promise.all(progress)
+  await drain()
   return progressError === undefined ? result : {
     value: null,
     stopReason: 'error',

+ 24 - 7
packages/workflow/workflow-ptc/src/host.ts

@@ -86,6 +86,11 @@ function progress(value: unknown): WorkflowProgress {
   }
 }
 
+function progressBatch(value: unknown): WorkflowProgress[] {
+  if (!Array.isArray(value)) throw new Error('workflow progress requires an array of events')
+  return value.map(progress)
+}
+
 function workflowResult(value: unknown): WorkflowResult {
   const result = object(value)
   if (result.stopReason !== 'completed' && result.stopReason !== 'error' && result.stopReason !== 'cancelled') throw new Error('invalid workflow stop reason')
@@ -173,7 +178,10 @@ export class PtcWorkflowRun implements WorkflowRun {
       startChild: value => this.track(this.startChild(childRequest(value))),
       childResult: value => this.track(this.childResult(this.child(value))),
       disposeChild: async (value) => { await this.disposeChild(this.child(value)); return null },
-      progress: (value) => { this.onProgress(progress(value)); return Promise.resolve(null) },
+      progress: (value) => {
+        for (const event of progressBatch(value)) this.onProgress(event)
+        return Promise.resolve(null)
+      },
     }
   }
 
@@ -212,12 +220,21 @@ export class PtcWorkflowRun implements WorkflowRun {
   }
 
   private async childResult(record: ChildRecord): Promise<PtcJsonValue> {
-    const result = await record.run.result
-    return json({
-      output: result.output,
-      stopReason: result.stopReason,
-      ...result.structured === undefined ? {} : { structured: result.structured },
-    })
+    const signal = this.controller.signal
+    signal.throwIfAborted()
+    const aborted = Promise.withResolvers<never>()
+    const onAbort = (): void => { aborted.reject(signal.reason) }
+    signal.addEventListener('abort', onAbort, { once: true })
+    try {
+      const result = await Promise.race([record.run.result, aborted.promise])
+      return json({
+        output: result.output,
+        stopReason: result.stopReason,
+        ...result.structured === undefined ? {} : { structured: result.structured },
+      })
+    } finally {
+      signal.removeEventListener('abort', onAbort)
+    }
   }
 
   private disposeChild(record: ChildRecord): Promise<void> {

+ 1 - 1
packages/workflow/workflow-ptc/src/index.ts

@@ -114,6 +114,7 @@ class PtcWorkflowEngine extends WorkflowEngine {
 
   constructor(ctx: Context, config: Config) {
     super(ctx)
+    if (ctx.ptcRuntime.language !== 'typescript') throw new Error('workflow-ptc requires the Node TypeScript PTC runtime')
     // schemastery (static Config) has already filled the defaulted fields;
     // the assertion records that resolution, not a hidden fallback.
     this.config = config as ResolvedConfig
@@ -130,7 +131,6 @@ class PtcWorkflowEngine extends WorkflowEngine {
    * @returns the live run (its `result` resolves when the script settles).
    */
   start(request: WorkflowStartRequest): WorkflowRun {
-    if (this.ctx.ptcRuntime.language !== 'typescript') throw new Error('workflow-ptc requires the Node TypeScript PTC runtime')
     const meta = validateMeta(request.meta)
     assertBodyParses(request.script, meta.name)
     const subagentProvider = resolveSubagentProvider(this.ctx, this.config.provider, request.subagentProvider)

+ 2 - 2
packages/workflow/workflow-ptc/src/meta.ts

@@ -1,8 +1,8 @@
 /**
  * Meta validation checks caller-provided DATA against the {@link WorkflowMeta}
  * contract and rejects every violation by name. Meta arrives as schema-checked
- * JSON data, never evaluated script text; evaluating it on the host could run getters outside the
- * worker timeout that exists to isolate model-written code.
+ * JSON data, never evaluated script text. Model-written JavaScript executes
+ * inside the confined PTC process.
  * @module @deepseek-ai/dsh-workflow-ptc/meta
  */
 

+ 84 - 1
packages/workflow/workflow-ptc/tests/guest.spec.ts

@@ -1,4 +1,5 @@
 import { execFile } from 'node:child_process'
+import { setImmediate } from 'node:timers/promises'
 import { promisify } from 'node:util'
 import { describe, expect, it, onTestFinished } from 'vitest'
 import { runWorkflowGuest } from '../src/guest.ts'
@@ -24,7 +25,7 @@ function fixture(body: string, overrides: Partial<WorkflowGuestHost> = {}, limit
     },
     childResult: async ({ callId }) => ({ output: [{ type: 'text', text: requests[callId - 1]!.prompt }], stopReason: 'completed' }),
     async disposeChild({ callId }) { disposed.push(callId); return null },
-    async progress(event) { events.push(event); return null },
+    async progress(batch) { events.push(...batch); return null },
     ...overrides,
   }
   return { host, init, requests, events, disposed }
@@ -88,6 +89,88 @@ describe('workflow guest callbacks', () => {
     expect(result.error).toContain('progress delivery failed')
   })
 
+  it('coalesces a synchronous progress burst while one acknowledgement is pending', async () => {
+    const first = Promise.withResolvers<undefined>()
+    const second = Promise.withResolvers<undefined>()
+    const firstAck = Promise.withResolvers<null>()
+    const secondAck = Promise.withResolvers<null>()
+    const batches: WorkflowProgress[][] = []
+    let pending = 0
+    let peak = 0
+    const test = fixture('for (let index = 0; index < 200; index++) log(String(index)); return "done"', {
+      async progress(events) {
+        batches.push(events)
+        peak = Math.max(peak, ++pending)
+        const firstBatch = batches.length === 1
+        if (firstBatch) first.resolve(undefined)
+        else second.resolve(undefined)
+        await (firstBatch ? firstAck.promise : secondAck.promise)
+        pending -= 1
+        return null
+      },
+    })
+    let settled = false
+    const active = runWorkflowGuest(test.host).then((result) => { settled = true; return result })
+    onTestFinished(async () => { firstAck.resolve(null); secondAck.resolve(null); await active })
+    await first.promise
+    expect(batches).toHaveLength(1)
+    expect(batches[0]).toEqual([{ type: 'log', message: '0' }])
+    firstAck.resolve(null)
+    await second.promise
+    expect(batches).toHaveLength(2)
+    expect(batches[1]).toEqual(Array.from({ length: 199 }, (_, index) => ({ type: 'log', message: String(index + 1) })))
+    expect(peak).toBe(1)
+    await setImmediate()
+    expect(settled).toBe(false)
+    secondAck.resolve(null)
+    await expect(active).resolves.toMatchObject({ value: 'done', stopReason: 'completed' })
+  })
+
+  it('reports a rejected batch and stops dispatching its queued progress', async () => {
+    const entered = Promise.withResolvers<undefined>()
+    const acknowledgement = Promise.withResolvers<null>()
+    const batches: WorkflowProgress[][] = []
+    const test = fixture('for (let index = 0; index < 200; index++) log(String(index)); return "done"', {
+      progress(events) {
+        batches.push(events)
+        entered.resolve(undefined)
+        return acknowledgement.promise
+      },
+    })
+    const active = runWorkflowGuest(test.host)
+    onTestFinished(async () => { acknowledgement.resolve(null); await active })
+    await entered.promise
+    acknowledgement.reject(new Error('progress batch rejected'))
+    const result = await active
+    expect(result).toMatchObject({ value: null, stopReason: 'error' })
+    expect(result.error).toContain('progress batch rejected')
+    expect(batches).toEqual([[{ type: 'log', message: '0' }]])
+  })
+
+  it('delivers queued child lifecycle events before disposing the published child', async () => {
+    const firstAck = Promise.withResolvers<null>()
+    const events: WorkflowProgress[] = []
+    let batches = 0
+    const test = fixture('log("hold"); return await agent("work")', {
+      async progress(batch) {
+        events.push(...batch)
+        if (++batches === 1) await firstAck.promise
+        return null
+      },
+    })
+    const active = runWorkflowGuest(test.host)
+    onTestFinished(async () => { firstAck.resolve(null); await active })
+    // All child callbacks are fulfilled promises; the next turn drains their microtasks.
+    await setImmediate()
+    expect(test.requests).toHaveLength(1)
+    expect(test.disposed).toEqual([])
+    expect(events).toEqual([{ type: 'log', message: 'hold' }])
+    firstAck.resolve(null)
+    await expect(active).resolves.toMatchObject({ value: 'work', stopReason: 'completed' })
+    expect(events.map(event => event.type)).toEqual(['log', 'agent-start', 'agent-end'])
+    expect(test.disposed).toEqual([1])
+  })
+
   it('reports initialization failure before any child or progress call', async () => {
     const test = fixture('return 42', { begin: async () => { throw new Error('run already cancelled') } })
     await expect(runWorkflowGuest(test.host)).rejects.toThrow('run already cancelled')

+ 41 - 15
packages/workflow/workflow-ptc/tests/host.spec.ts

@@ -5,6 +5,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import SessionProjections from '@deepseek-ai/dsh-session-projection'
 import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
 import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
 import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import PtcWorkflowEngine from '../src/index.ts'
 import { fakeParent } from './setup.ts'
@@ -24,7 +25,7 @@ class ControlledRuntime extends PtcRuntime {
   run(spec: PtcRunSpec): Promise<PtcRunResult> { return this.execute(spec) }
 }
 
-async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Promise<PtcRunResult>) {
+async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Promise<PtcRunResult>, language = 'typescript') {
   const ctx = new Context()
   onTestFinished(async () => { await ctx.fiber.dispose() })
   await ctx.plugin(SessionStore)
@@ -44,6 +45,7 @@ async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Pro
   })
   await ctx.plugin(ControlledRuntime)
   const runtime = ctx.ptcRuntime as ControlledRuntime
+  runtime.language = language
   if (execute !== undefined) runtime.execute = spec => execute(spec.bindings[0]!.functions, spec)
   await ctx.plugin(PtcWorkflowEngine, { provider: 'stub' })
   const parent = fakeParent(ctx)
@@ -60,10 +62,11 @@ describe('workflow host callback validation', () => {
     ['startChild', { prompt: 7 }, 'prompt must be a string'],
     ['startChild', { prompt: 'p', provider: 7 }, 'provider must be a string'],
     ['startChild', { prompt: 'p', model: false }, 'model must be a string'],
-    ['progress', { type: 'phase', title: null }, 'phase must be a string'],
-    ['progress', { type: 'agent-start', info: { seq: 0 } }, 'sequence must be a positive integer'],
-    ['progress', { type: 'agent-end', info: { outcome: 'unknown' } }, 'invalid workflow agent outcome'],
-    ['progress', { type: 'unknown' }, 'invalid workflow progress event'],
+    ['progress', {}, 'requires an array of events'],
+    ['progress', [{ type: 'phase', title: null }], 'phase must be a string'],
+    ['progress', [{ type: 'agent-start', info: { seq: 0 } }], 'sequence must be a positive integer'],
+    ['progress', [{ type: 'agent-end', info: { outcome: 'unknown' } }], 'invalid workflow agent outcome'],
+    ['progress', [{ type: 'unknown' }], 'invalid workflow progress event'],
     ['childResult', { callId: '1' }, 'call id must be an integer'],
     ['disposeChild', { callId: 99 }, 'child call is not active'],
   ] as const)('rejects malformed %s callback data %j', async (name, input, message) => {
@@ -79,9 +82,9 @@ describe('workflow host callback validation', () => {
   it('does not emit duplicate agent-end notifications', async () => {
     const info = { seq: 1, label: 'child', childId: 'host-child' }
     const { ctx, start } = await setup(async (bindings) => {
-      await bindings.progress!({ type: 'agent-start', info })
-      await bindings.progress!({ type: 'agent-end', info: { ...info, outcome: 'failed' } })
-      await bindings.progress!({ type: 'agent-end', info: { ...info, outcome: 'cancelled' } })
+      await bindings.progress!([{ type: 'agent-start', info }])
+      await bindings.progress!([{ type: 'agent-end', info: { ...info, outcome: 'failed' } }])
+      await bindings.progress!([{ type: 'agent-end', info: { ...info, outcome: 'cancelled' } }])
       return completed
     })
     const ended = vi.fn()
@@ -142,12 +145,35 @@ describe('workflow runtime outcomes', () => {
     }
   })
 
-  it('rejects a non-TypeScript runtime before publishing workflow/start', async () => {
-    const { ctx, runtime, start } = await setup()
-    runtime.language = 'python'
-    const started = vi.fn()
-    ctx.on('workflow/start', started)
-    expect(start).toThrow('requires the Node TypeScript PTC runtime')
-    expect(started).not.toHaveBeenCalled()
+  it('rejects a non-TypeScript runtime while loading the workflow provider', async () => {
+    await expect(setup(undefined, 'python')).rejects.toThrow('requires the Node TypeScript PTC runtime')
+  })
+
+  it('stops waiting for child output after disposal releases the child resources', async () => {
+    const childResult = Promise.withResolvers<SubagentResult>()
+    const disposed = vi.fn(() => Promise.resolve())
+    let outputWait: Promise<unknown> | undefined
+    const { ctx, start } = await setup(async (bindings) => {
+      const child = await bindings.startChild!({ prompt: 'child' })
+      outputWait = bindings.childResult!(child)
+      void outputWait.catch(() => {})
+      return completed
+    })
+    vi.spyOn(ctx.subagents.getProvider('stub')!, 'start').mockResolvedValue({
+      id: SessionId('output-pending'), localAgent: undefined, result: childResult.promise, dispose: disposed,
+    })
+    const handle = start()
+    let settled = false
+    void handle.result.then(() => { settled = true })
+    try {
+      await new Promise(resolve => setImmediate(resolve))
+      expect(settled).toBe(true)
+      expect(disposed).toHaveBeenCalledOnce()
+      await expect(outputWait).rejects.toBe('workflow settled')
+      expect((await handle.result).stopReason).toBe('completed')
+    } finally {
+      childResult.resolve({ output: [], stopReason: 'aborted' })
+      await handle.dispose()
+    }
   })
 })

+ 20 - 0
packages/workflow/workflow-ptc/tests/integration.spec.ts

@@ -90,4 +90,24 @@ return { got: judged === null ? 'null' : 'value' }`,
     expect(result.value).toEqual({ got: 'null' })
     await run.dispose()
   })
+
+  it('keeps real children visible to start observers after a progress burst', async () => {
+    const { ctx, parent } = await setup([textResponse('child complete')])
+    const logs: string[] = []
+    const visible: boolean[] = []
+    ctx.on('workflow/log', (_info, message) => { logs.push(message) })
+    ctx.on('workflow/agent-start', (_info, child) => { visible.push(ctx.agents.get(child.childId) !== undefined) })
+    const run = ctx.workflowEngine.start({
+      meta: { name: 'progress-burst', description: 'ordered progress and child visibility' },
+      script: 'for (let index = 0; index < 200; index++) log(String(index)); return await agent("finish")',
+      parent,
+    })
+    try {
+      await expect(run.result).resolves.toMatchObject({ value: 'child complete', stopReason: 'completed', agentsStarted: 1 })
+      expect(logs).toEqual(Array.from({ length: 200 }, (_, index) => String(index)))
+      expect(visible).toEqual([true])
+    } finally {
+      await run.dispose()
+    }
+  })
 })

+ 4 - 2
packages/workflow/workflow-ptc/tests/source-runtime.compat.spec.ts

@@ -31,7 +31,8 @@ it('runs the default workflow config through the source PTC runtime', async () =
     parent,
   })
   try {
-    await expect(run.result).resolves.toMatchObject({ value: 42, stopReason: 'completed', agentsStarted: 0 })
+    const result = await run.result
+    expect(result, result.error).toMatchObject({ value: 42, stopReason: 'completed', agentsStarted: 0 })
   } finally { await run.dispose() }
 })
 
@@ -51,7 +52,8 @@ return 42`,
     parent,
   })
   try {
-    expect((await run.result).stopReason).toBe('completed')
+    const result = await run.result
+    expect(result.stopReason, result.error).toBe('completed')
     expect(await readFile(outside, 'utf8')).toBe('unchanged')
     if (mode === 'workspace-write') expect(await readFile(inside, 'utf8')).toBe('inside')
     else await expect(readFile(inside, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })

+ 1 - 1
packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts

@@ -791,7 +791,7 @@ describe('dsh-workflow-ptc', { timeout: 120_000 }, () => {
         parent: fakeParent(ctx),
       })
       const result = await handle.result
-      expect(result.stopReason).toBe('completed')
+      expect(result.stopReason, result.error).toBe('completed')
       // BEFORE dispose(): the settlement itself must have aborted the signal —
       // without it this child would stay live until dispose's terminate. This
       // is a HOST-PROMPTNESS claim, not a cold-start race — a tight explicit

+ 11 - 0
scripts/ci-workflow.spec.ts

@@ -14,6 +14,17 @@ const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{
 const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
 
 describe('CI workflow', () => {
+  it('prepares confinement before Node compatibility smokes', () => {
+    const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-compat')
+    if (!Array.isArray(job.steps)) throw new TypeError('Node compatibility job must define steps')
+    const steps = job.steps.filter(isRecord)
+    const preparation = steps.findIndex(step => step.run === 'bash scripts/prepare-ci-bubblewrap.sh')
+    const smoke = steps.findIndex(step => step.run === 'pnpm run check:node-compat')
+    expect(preparation).toBeGreaterThanOrEqual(0)
+    expect(smoke).toBeGreaterThan(preparation)
+    expect(steps[preparation]).not.toHaveProperty('continue-on-error', true)
+  })
+
   it.each(['ci.yml', 'ci-master.yml', 'e2e.yml', 'release.yml', 'release-vendor.yml'])(
     '%s cancels superseded validation runs without crossing workflow or ref boundaries', (name) => {
       const workflow = loadWorkflow('.github/workflows/' + name)

+ 7 - 1
snapshots/session/ptc-python-turn/cordis.snapshot.yml

@@ -1,4 +1,4 @@
-# Keyless private Python PTC composition through the real headless Loader.
+# Keyless Python PTC composition through the real headless Loader.
 - id: llm-deepseek
   name: '@deepseek-ai/dsh-llm-deepseek'
   disabled: true
@@ -31,6 +31,12 @@
 - id: ptc-runtime
   disabled: true
 
+- id: workflow-ptc
+  disabled: true
+
+- id: tool-workflow
+  disabled: true
+
 - insert:
     - id: ptc-runtime-python
       name: '@deepseek-ai/dsh-experimental-ptc-runtime-python'

+ 7 - 1
snapshots/session/ptc-python-turn/cordis.yml

@@ -1,4 +1,4 @@
-# Private Python PTC composition: replace the headless worker provider through
+# Python PTC composition: replace the Node provider through
 # the real Loader and render the generated Python SDK prompt.
 - id: agent-default-model
   name: '@deepseek-ai/dsh-agent-default-model'
@@ -25,6 +25,12 @@
 - id: ptc-runtime
   disabled: true
 
+- id: workflow-ptc
+  disabled: true
+
+- id: tool-workflow
+  disabled: true
+
 - insert:
     - id: ptc-runtime-python
       name: '@deepseek-ai/dsh-experimental-ptc-runtime-python'

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 39
snapshots/session/ptc-python-turn/system-prompt.expected.md


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
snapshots/session/skill-load/session.v3.jsonl


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
snapshots/web/skill-tool-row/ui.expected.md


+ 1 - 1
vitest.config.ts

@@ -154,7 +154,7 @@ const processBoundTests = [
   'packages/context/time-context/tests/time-context.spec.ts',
   'packages/llm/llm-pi-ai/tests/adapter.spec.ts',
   'packages/boot/app-boot/tests/app-boot.spec.ts',
-  'packages/workflow/workflow-ptc/tests/session.spec.ts',
+  'packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts',
 ]
 
 export default defineConfig({

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott