← 回到 infra 主页

DeepSeek Harness · Upstream · Issue #13 草案(原 #13 + #14 合并 · 简明版)

长时高扇出 workflow 作业不可靠:run 进程重启即丢  +  单进程单主线程封顶吞吐

背景:我们用 workflow 跑长时高扇出作业(363 项、maxConcurrentAgents=256、一项一个 subagent、每项多次读图)。同一个 run 暴露两个面:面一——run 没有可恢复的持久化身份,进程一重启(部署 / OOM / 看门狗)父会话只拿到「outcome is unknown」,没有 runId、没有进度、没有续跑入口;面二——所有 agent 共用一条 JS 主线程,加并发只排队不提吞吐,还会把每用户网关压到看门狗探针超时而触发自愈重启。实测:2026-09-16 两轮被自愈重启腰斩(10:14:45 / 10:29:51),第二轮活约 10 分钟、仅净完成 12 项。请求:面一按 P0 落耐久 run 记录 + 按 runId 重附着;面二按 P0 把随包已带的 out-of-process seam 接上、让扇出吃满多核。

一句话判据(只读,约 10 秒):grep -n "background collection remains deferred" …/dsh-tool-workflow/lib/index.js 命中 :10、grep -n "durably recorded" …/dsh-session/lib/index.js 命中 :600/:667 ⇒ 面一成立;grep -nE "subagent-(spawn|fork)-in-process" …/dsh-base/cordis.patch.yml 只有两行、且 ls -d …/dsh-subprocess* 有货 ⇒ 面二成立(能力已随包、装配未接上)。
边界(诚实声明):多进程/多线程不降低单次上游时延(本机同一上游实测:并发 71 ⇒ p50 ≈101 s;并发 325 ⇒ p50 ≈246 s),不修每用户网关 maxSockets: 64,也不消 fsync/压缩屏障;它治的是 CPU 串行、跨会话耦合与崩溃半径。本地那条「把网关忙判成网关死」的自愈是我们自己的脚本(ops/dsh/gateway-health.sh),本页给出 P0 缓解,但上游两个面不因它修好而消失。

问题背景(两个面)

面是什么后果(实测)
面一
run 不可恢复
run 只活在进程内注册表(new Map());结果只经同步 await run.result 交付,finally 里 dispose;写进会话流的收尾记录只有 {runId, stopReason}、不含终值;模块自述 background collection remains deferred,跨进程恢复只给一句「outcome is unknown」。两轮腰斩后拿不到任何 run 级信息:只能靠外部副作用(逐项落盘的文件计数)反推进度、幂等补跑;若作业不是「一项一文件」,就只能整轮重跑。
面二
吞吐被一条线程封顶
随包只有 in-process subagent driver(spawn / fork);而进程外接缝与机器已随包(dsh-subagent/lib/types/out-of-process.js、dsh-subprocess/-local、dsh-acp/-app),随包已有三处 new Worker() 先例(workflow 编排脚本、run_code、代际校验)。256 在飞时 10 分钟只完成 12 项(加并发只排队);同刻无关会话被拖慢(控制面 RPC 被拉长到 34 s–≥45 s);每用户网关被压到探针 6 s 超时 ⇒ 看门狗重启整个单元,把在飞 run 连锅端。

实测数据

读数都在 host 上现采,命令见「判据行」;#10 / #12 的读数引自本专栏对应页(各自有原始输出)。

项读数出处
扇出规模363 项 / maxConcurrentAgents=256 / 一项一个 agent本批 remaining_chunks.json
两轮存活与产出第 1 轮起飞约 1 分钟即被杀(产出 0);第 2 轮约 10 分钟被杀,净完成 12 项(verdict 文件 316→328)journal + 落盘计数
看门狗扳机(两轮同形)BAD … timeout=6s 连续失败=2/2 → HEAL user=<user> cmd=systemctl restart dsh-<user>.service;重启后 Consumed 1h 24min 21.988s CPU timedsh-gateway-health 日志
主线程封顶(加并发不提吞吐)K=4 与 K=8 都停在 1.00 rpc/s;主线程 96.7% → 98.3%;每操作时延 2.6 → 4 → 8 s本专栏 #10
控制面被饿死约 0.5 s CPU 的 session/list 被拉到 34 s – ≥45 s(客户端超时右截尾)本专栏 #12(权威组 A0)
饱和时 CPU 花在哪zod_validate 16.35% / util_values_snap 11.14% / session_io_parse 9.27% / gc 7.81%#12 第五轮进程内归因
重启后子会话不可寻址(新增实测)会话恢复后 GUI 加载子会话历史报 session/agent-busy: subagent Sessions require their durable parent address —— 子会话本身还在盘上,但只能经「subagent delivery + durable parent address」寻址;父地址在进程重启后不可解析,客户端退回普通会话 API 就被拒。子会话不是死了,是取不到。dsh-api-session-controller/lib/index.js:1578 / lib/types/history.js:346;本机 GUI 实测
上游单次时延地板并发 71 → 单次 p50 ≈101 s;并发 325 → p50 ≈246 s(多进程也压不动这一项)本机 OCR 服务口径(非 DSH 进程内读数)

代码锚点(指到行)

随包 0.1.5-rc.1;指纹:dsh-tool-workflow/lib/index.js 11,563 B md5 dad9cb1ef0cf;dsh-session/lib/index.js 74,086 B md5 eac953e21eb7;dsh-workflow-worker-thread/lib/index.js 38,562 B md5 eeee556dd5919。

# 面一:run 没有持久化身份、结果没有收尾记录
dsh-tool-workflow/lib/index.js:10     "… the run; non-completed reasons become tool errors, and background collection remains deferred."
dsh-tool-workflow/lib/index.js:38     const active = /* @__PURE__ */ new Map();          # run 注册表只在内存
dsh-tool-workflow/lib/index.js:231-262  start(...) → result = await run.result → return {runId, agentsStarted, result}; finally { await run.dispose(); recorder.finish(run.id, result.stopReason) }
dsh-tool-workflow/lib/index.js:76-82    finish(runId, stopReason) → append("tool-workflow/run-end", {runId, stopReason})   # 不含终值
dsh-workflow-worker-thread/lib/index.js # grep -nE "persist|storage|writeFile|appendFile" → 0 命中(引擎不落盘 run)
dsh-session/lib/index.js:600 / :667
# 面一(续):重启后子会话连"看历史"都取不到
dsh-api-session-controller/lib/index.js:1578   if (header.origin === "subagent") throw new RemoteError("session/agent-busy", "subagent Sessions require their durable parent address", …)
dsh-api-session-controller/lib/types/history.js:346   同上(历史读取路径)     "Recovery code for a recorded tool call whose completed outcome was not durably recorded." / 恢复文案原文

# 面二:能力已随包、装配未接上
dsh-base/cordis.patch.yml:331-339      只挂 dsh-subagent-spawn-in-process(providerName: spawn) 与 dsh-subagent-fork-in-process(providerName: fork)
dsh-subagent-spawn-in-process/lib/index.js:42 / dsh-subagent-fork-in-process/lib/index.js:58   # 全树仅有的两处 subagent registerProvider
dsh-subagent/lib/types/out-of-process.js # "Provider-side vocabulary for OUT-OF-PROCESS subagent backends … belongs to the `dsh-subprocess` seam."
dsh-workflow-worker-thread:311 / dsh-code-runtime-worker-thread:737 / dsh-session-persistence-jsonl:1491   # 随包已有的三处 new Worker()

改进方向(分级)

P0 = 不改就会继续丢/继续慢;P1 = 让下次能续上或更省。

面一 · run 耐久性(上游)

面二 · 原生多进程 / worker-thread(上游)

本地(本仓,P0 缓解;不属上游)

判据行(可复跑)

全部只读;不重启任何单元。

# 1) 面一:run 无持久化身份 + 恢复只给 unknown
grep -n "background collection remains deferred" [app-dir]/app-[version]/node_modules/@deepseek-ai/dsh-tool-workflow/lib/index.js   # :10
grep -n "durably recorded"                       [app-dir]/app-[version]/node_modules/@deepseek-ai/dsh-session/lib/index.js          # :600 / :667

# 2) 面二:能力已随包、装配未接上
grep -nE "subagent-(spawn|fork)-in-process" [app-dir]/app-[version]/node_modules/@deepseek-ai/dsh-base/cordis.patch.yml            # 只有 :331 / :336 两行
ls -d [app-dir]/app-[version]/node_modules/@deepseek-ai/dsh-subprocess* [app-dir]/app-[version]/node_modules/@deepseek-ai/dsh-acp*
sudo grep -rn "subagents.registerProvider" --include=index.js [app-dir]/app-[version]/node_modules/@deepseek-ai                     # 只有 spawn:42 / fork:58

# 3) 本地扳机(两轮同形)
sudo journalctl -u dsh-gateway-health.service --since today --no-pager | grep -E "BAD|HEAL"

可提交的 issue 正文(英文)

整段可直接粘贴,不依赖我们此前任何报告。

Title: Long-running fan-out `workflow` jobs are unreliable: runs are lost on process
       restart, and agent throughput is capped by a single JS main thread

Background
----------
`workflow` is the documented way to "orchestrate subagents at scale". We use it for a
long, wide job: 363 items, maxConcurrentAgents=256, one subagent per item. The same run
exposed two independent problems.

(1) Runs are not recoverable across a process restart.
    * The run registry is in-memory only: `const active = new Map()` (dsh-tool-workflow/
      lib/index.js:38), and the engine persists nothing (dsh-workflow-worker-thread: no
      persist/storage/writeFile/appendFile).
    * The tool awaits `run.result` and then disposes the run; the durable end-of-run
      record carries only `{runId, stopReason}` — not the final value (:231-262, :76-82).
    * The module itself documents "background collection remains deferred" (:10).
    * Cross-process recovery can only report "outcome is unknown"
      (dsh-session/lib/index.js:600/:667); there is no way to re-attach by run id.
    Measured: the run was killed twice by an external restart (10:14:45, 10:29:51);
    the second one survived ~10 minutes and completed only 12 of 363 items. Progress
    could only be inferred from each item's on-disk side effects.

(2) All agents share one JS main thread, so concurrency queues instead of scaling.
    * The shipped host mounts only in-process subagent drivers (dsh-base/cordis.patch.yml:
      331-339 -> spawn/fork; tree-wide registrations: dsh-subagent-spawn-in-process:42,
      dsh-subagent-fork-in-process:58).
    * The out-of-process vocabulary and machinery already ship
      (dsh-subagent/lib/types/out-of-process.js, dsh-subprocess, dsh-subprocess-local,
      dsh-acp, dsh-acp-app), and `new Worker()` is already used for workflow scripts,
      run_code and generation verification — it is just not used for agents.
    Measured elsewhere on the same host: K=4 and K=8 both plateau at 1.00 rpc/s with the
    main thread at 96.7%/98.3%; an unrelated control-plane call (about 0.5 s of CPU) was
    stretched to 34 s - >=45 s; and a 256-way fan-out made the per-user gateway miss a 6 s
    watchdog probe twice, triggering a restart of the whole unit (which killed the run).

Requested
---------
Face 1 (run durability), P0:
  a. Persist a durable run record (runId, meta, per-item/agent status, terminal state and
     final value) and surface it through session recovery, so a restart reports run id,
     progress and completed results instead of "outcome is unknown".
  b. Provide a query/re-attach entry point by run id, including the list of unfinished
     items so only those need re-running.
  P1: c. On shutdown/dispose, write a terminal record for in-flight tool calls (ABORTED).
      d. Implement the deferred background collection so a long fan-out can be detached
         and collected later.

Face 2 (native multi-process / worker-thread agents), P0:
  a. Ship an out-of-process subagent provider built on the existing out-of-process
     vocabulary + dsh-subprocess seam, mounted alongside the in-process drivers and
     selectable per profile.
  P1: b. A worker_threads variant for CPU-bound agents that do not need isolation.
      c. Per-worker concurrency quotas with queue state exposed to the UI.
      d. A cross-process contract for persistence, cancellation and session/follow.

Acceptance criteria (measurable): during an N-way fan-out the host main thread must stay
well below saturation; wall time should scale as ceil(N/workers) x per-agent latency
rather than N x queueing latency; and canary latency for an unrelated session on the same
host must stay flat.

What multi-process does NOT fix (stated to avoid over-claiming): single-call upstream
latency (measured on the same upstream: concurrency 71 -> p50 ~101 s; concurrency 325 ->
p50 ~246 s), a proxy/connection-pool limit, or per-step fsync/compaction barriers.

Keywords: workflow run durability, TOOL_OUTCOME_UNKNOWN, background collection deferred,
multi-process agents, worker_threads, out-of-process subagent driver, event-loop stall.

最后修改:2026-10-01 19:51:07 +08:00