DeepSeek Harness 后台任务运行时:ctx.jobs 怎么托住一个跑很久的活
先把问题摆具体一点:模型调了一次 bash,命令是个要跑二十分钟的构建。工具调用这一步肯定不能干等,它必须马上返回,好让模型继续干别的。那么问题来了——这二十分钟里,是谁在记着这个进程?谁有权杀它?它跑完之后,那句「完成了」是怎么回到模型的上下文里的?如果这中间用户把这个 agent 关了,进程会不会变成孤儿?
这一套在 DeepSeek Harness 里由 packages/jobs/ 这一家包负责。开头要先把限定说清楚:该仓库 README 有一节标题就叫 Developer preview,原文写明项目处于开发者预览阶段并且用加粗大写强调「会有破坏兼容性的变更」。下面提到的配置项名字、默认值、错误文案,都是当前快照里的样子,随时会变,别照着写死。
三个包各管一段
packages/jobs/ 下面我们数出三个包,不是三个目录级别的 glob 陷阱,是实打实的三个 package.json:jobs/(合约)、jobs-local/(进程内实现)、tool-jobs/(面向模型的那三个工具)。分法很干脆:jobs/ 只定义抽象类和类型,jobs-local/ 是唯一的实现,tool-jobs/ 负责把 job 变成模型能看见的东西。三者的 README 各自都写了自己那段合约。
真正干活的生产方并不在这一家里。全仓调用 jobs.start( 的地方我们数出四处:packages/shell/tool-bash/src/index.ts、packages/shell/tool-pwsh/src/index.ts、packages/subagent/tool-subagent/src/index.ts、packages/terminal/tool-terminal/src/index.ts。对应的 kind 分别是 bash、pwsh、subagent、pty-send。
这里有个第一次读会愣一下的细节:packages/jobs/jobs/src/types.ts 里的 JobKindMap 只写了 bash 和 subagent 两个,docs/subsystems/jobs.md 引的也是这两个。另外两个 kind 是在生产方自己那边用 declare module '@deepseek-ai/dsh-jobs' 声明合并加进去的——tool-pwsh 加 pwsh,tool-terminal 加 'pty-send'。所以你想知道这个仓库到底有几种 job,光看类型定义那一处会漏,得去 grep JobKindMap。类型注释里自己写了这是可合并扩展的 map,注册表把每个值当作不透明的 id 命名空间。
生产方只交三个钩子
回到那条 bash。tool-bash 在后台分支里调的 jobs.start({ kind: 'bash', label: args.command, owner, run }),run() 里才去 ctx.shell.start(...) 拉起进程,然后同步返回一个对象,里面三样东西:cancel、done、readOutput。
这三样就是运行时能对生产方做的全部动作。类型文档 docs/subsystems/jobs.md 把职责划得很清楚:生产方拥有执行资源,运行时拥有身份、访问权限和生命周期状态。JobHooks.done 的注释还专门强调了一句容易写错的语义——它要在生产方释放完资源之后才 resolve,而不是活干完就 resolve。区别在哪?在于运行时是拿 done 当「这个进程真的没了」的凭据用的,owner 销毁时要 await 它。
readOutput 是可选的:有它就是流式任务(每次读取消费上次以来的增量),没它就是只有最终输出的任务。tool-bash 给了 readOutput,tool-subagent 那边源码注释写的是「不给 readOutput,中间细节归子会话自己管」。
start() 的预检顺序,以及那个默认 10
进程局部实现在 packages/jobs/jobs-local/src/index.ts,我们数出 534 行。start() 从函数体第一行到调 spec.run() 之前的不到二十行全是预检,顺序本身有意义,因为它决定了失败时你手里剩下什么:
servesOwner(spec.owner)——有没有一个已挂载的 job controller 服务这个 owner。没有就抛错,错误文案原文是background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)。kind、label非空校验;outputLimitBytes若给了必须是正的安全整数。- 若给了 owner,走
ensureOwnerCleanup(owner)——服务用自己的 ctx 取agents注册表核对:这个 owner 必须是当前注册在该 agent id 下的那一个实例,不是同名的替身,否则抛agent "<id>" is not the registered agent instance (background job owner must be live);注册表本身没加载时抛的是另一句,要求先加载@deepseek-ai/dsh-agent。 - 并发闸。
第 4 步就是那个最容易撞上的数字。jobs-local 里的常量 DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER 值是 10,配置项叫 maxConcurrentJobsPerOwner,schema 是 .step(1).min(1).max(Number.MAX_SAFE_INTEGER)。它数的是这个 owner 名下 running 加 stopping 的记录数——注意 stopping 也算,已终结的历史不算。所有无 owner 的 job 共用另一个服务级的桶。撞上限时的错误里直接写了给模型看的处置建议:用 job_kill 停掉一个不需要的,等它跑完,再重试。jobs-local 的 README 补了一句:注册表不排队、不抢占,也不另维护一个可变计数器。
docs/subsystems/jobs.md 的「服务行为」一节写的默认值也是 10,和代码对得上。
全部预检过了才调 spec.run()。这个先后顺序是有后果的:预检失败时既不会有 job id,也不会有已经拉起来的进程;而 run() 一旦返回,注册就不可能再失败了。id 在 run() 之后才发,格式 <kind>-N,N 来自一个按 kind 计数的 counters map,每次取旧值加一,代码里没有任何地方把它减回去——所以同一个进程里 bash-7 这个 id 不会被复用。
id 可预测这件事文档是明说的:docs/subsystems/jobs.md 写「访问控制依赖拥有者授权,而非 id 的保密性」。实现里对应的就是 assertAccess,比对 job.owner.id 和 caller?.id,不匹配就抛 job <id> belongs to another session。而 jobs/ 的 README 在「已知限制」里同时写明:无 owner 的 job 没有这道 session 围栏,外部调用方得自己上策略或者干脆别用。
settle 的顺序:完成通知为什么排最后
settle() 是先来先赢的:函数第一行判断已终结就直接 return。之后的动作顺序在源码里带着注释:先写状态和 finishedAt,若有等待者就把 reported 置真,取快照,放掉所有 waiter,markSettled(),notifyChanged,最后才轮到 onJobDone 的完成监听器。
为什么完成通知要排在最末?注释自己给了理由(属文档自述):完成通知的接收方可能会同步开一个模型轮次,所以在它跑起来之前,这次结算的其它观察者必须都已经看到提交后的记录了。
reported 这个布尔位是这套设计里最反直觉的一处。它不表示「job 完成了」,而表示「终态已经被人报过、或者已经承诺要报」。kill()、终态的 read()、带等待者的 wait() 都会把它置真;teardown 时的取消也置真,jobs-local 源码注释解释的是:owner 都要销毁了,没人会读这条通知,而一个被通知唤醒的上报方会为每一层 teardown 花掉一次模型请求。apiproxy 那边的 packages/host/apiproxy/src/api/jobs.ts 干脆在类型注释里写明 reported 是「对用户没有意义的内部通知投递位」,所以推给前端的 JobView 里不带它。
通知怎么落到模型:wakeup 预算是 3
tool-jobs 加载时会做三件事:ctx.jobs.attachController('tool-jobs')(这就是前面 start() 第 1 步要检查的那个 controller)、注册一段 name: 'tool:jobs'、order: 106 的 system prompt、以及挂上 onJobDone 监听器。
那个监听器的分叉逻辑很短:如果快照的 reported 已经是真、或者没有 owner,直接返回;否则看 owner 忙不忙。忙的(owner.status 不是 idle)走 owner.inject(message),通知进下一步的 inbox;闲的走 owner.followup(message),也就是主动给它开一个轮次。
主动唤醒是有预算的。maxConsecutiveWakes 默认 3,用 WeakMap<Agent, number> 按 agent 实例记账,用满之后后续通知降级成注入。回血的唯一途径是 agent/inbox/claimed 事件里 message.source.kind === 'user'——也就是真人输入。tool-jobs 的 README 写明了这个上限存在的原因(文档自述):这条链是自激的,一个被唤醒的轮次可能又去起一个后台 job,那个 job 完成了又来唤醒它;源码里那句校验注释的措辞也是「Infinity 会让这个字段本来要约束的失控链条不再有界」。另外这条回血只在 wakeup 投递下才注册,quiet 下没人花预算也就不用回血。
不想要这种主动唤醒的,配置项是 completionDelivery,可选 'quiet' 和 'wakeup',默认 'wakeup'。tool-jobs 的 README 写的是 quiet 让闲置 owner 也走注入通道,「这是确定性 transcript 需要的」。
三个工具和两个超时
模型侧只有三个工具:job_output(job_id, wait?, timeout_ms?)、job_list()、job_kill(job_id, reason?)。
job_output 默认不阻塞。给了 wait: true 才等,等多久是 Math.min(args.timeout_ms ?? waitDefault, waitCap)——waitTimeoutMs 默认 30_000 毫秒,maxWaitTimeoutMs 默认 600_000 毫秒,模型给的值超了会被夹到上限。等超时不算错误:工具描述里写明超时返回 [status: running] 并且把 job 留着活。源码注释也说了这是它自己管 deadline、不走 ToolDefinition.timeoutMs 的原因。另外配置加载时有个校验:waitTimeoutMs 大于 maxWaitTimeoutMs 会直接抛错。
job_kill 的行为顺序值得单独记:kill() 里是先调生产方的 cancel,再改状态。代码注释写的是这样一来 cancel 抛错时生命周期和通知状态都不变。jobs/ 的 README 对应写着「取消抛出会让 job 保持 running」。返回值只有 'requested' 和 'already-finished' 两种,它不等工作真的停下来。
会咬到人的几处边界
第一处,jobs-local 的 README 在「已知限制」里自己写了:如果生产方的 cancel 返回了却始终不 settle done,注册表分不清它是「静默失效」还是「停得慢」,这个 job 会在剩下的服务生命周期里一直占着一个并发槽位,而且只有显式抛错的 cancel 才能被安全地强制失败掉。也就是说前面那个默认 10 的额度,是可能被一个写坏的生产方慢慢吃光的。
第二处,一切都在进程内。jobs/ 的 README 写明这份合约是 in-process 的——JobStart.run() 传的是回调和确切的 Agent 对象;jobs-local 的 README 写明记录随 harness 进程一起死,要持久化或跨重启得另做一个后端来实现这个 seam。
第三处,流式输出只有一个消费游标,两个包的「已知限制」都写了这条,想让第二个观察者也读同一份输出,目前没有对应 API。
第四处,outputLimitBytes 是生产方给的,注册表既不改写生产方的输出,也不替没给的生产方发明一个默认值。四个生产方里,我们只在 tool-terminal 看到它显式传了 outputLimitBytes: maxResultBytes,该文件里 DEFAULT_MAX_RESULT_BYTES 的值是 256 * 1024;tool-bash 的那次 start 调用里没有这个字段。按 tool-jobs README 的说法,不给这个字段的生产方保持原有的无上限控制器行为。
最后一处,你如果自己写生产方插件,start() 第一步那道 controller 检查是按 owner 相对判断的:servesOwner 先看全局层,再沿 owner 的 scope 链找。README 的原话是,一个自己没加载 tool-jobs 的 composition,不能靠别的 composition 的控制面来起后台任务。这一条不看源码基本猜不到,报错文案倒是把该加载哪个包直接写在里面了。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
本文涉及在本机拉起 shell 与子进程的能力,安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。