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 接上、让扇出吃满多核。
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* 有货 ⇒ 面二成立(能力已随包、装配未接上)。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 time | dsh-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 = 让下次能续上或更省。
runId、meta、逐 item/agent 状态、终态与终值;并折进会话恢复路径——进程重启后父会话应看到「起了多少项 / 完成多少项 / 已完成项的结果」,而不是一句 unknown(恢复文案至少带 runId 与进度)。runId 查询 / 重附着入口:读回耐久记录、导出未完成项,允许只补跑未完成项。tool/call 写终态(ABORTED),并与耐久记录保持一致。runId),事后收结果。dsh-subprocess 机器,与两个 in-process driver 并存、按 profile 可选(如 provider: process)。new Worker() 先例)。session/follow / 工具调用耐久记录仍由宿主统一收口(否则与面一叠加成更难查的半途状态)。ceil(N/workers) × 单 agent 时延,而不是 N × 排队时延;③ 同宿主无关会话 canary 延迟保持平稳。gateway-health.sh 的探针超时(6 s)与失败阈值(2 轮)应放宽并观察「是否在推进」;探针优先走不经网关的直连。systemctl restart 整个单元。workflow 时把「自动重启」降级为告警 + 人工。全部只读;不重启任何单元。
# 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"
整段可直接粘贴,不依赖我们此前任何报告。
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