# 设计评估:后台任务在飞时的「推迟门」 > 状态:**已实现**。§7.2 已由源码确认,§7.4 与「结算唤醒衔接」仍待真实会话验证。 > > 落地位置:`lib/pending.js`(在飞任务探测)、`lib/index.js`(`onTurnEnd` 推迟门与超时升级)、 > `lib/overlay.js`(deferral 记录)、`lib/client.js`(面板提示)、`lib/prompt.js`(协议播报补充)。 > 验证结果见 §10。 ## 1. 现象 在 DSH 上跑长程目标时会出现这种情形: 1. 主代理把任务分派给子代理或后台命令; 2. 主代理本轮结束,`final` 一条「已派出,等待结果」; 3. goal 循环随即开启新一轮。 第 3 步的结果分两种: - **有用**:模型手上真有独立工作,顺手开工,任务推进更快; - **空转**:没有新信息到达,模型只能复述「正在等待 X」。 用户观察到两种都会出现,且空转相当常见。 ## 2. 根因 `@deepseek-ai/dsh-goal-round-driver` 的触发条件是 `agent/status` 事件中 `status === 'idle'`: ```js ctx.on("agent/status", ({ agent, status }) => { if (status === "idle") { state.competingQueued = false; requestDrive(state) } }) // drive() 内: if (goal === undefined || goal.phase !== "active" || goal.activation !== "armed") return const round = goal.roundsStarted + 1 agent.followup(createUserMessage({ content: renderGoalRoundPrompt(goal, round), source: { kind: "goal", ... } })) ``` **`idle` 表示「这一轮结束了」,而不是「有活可干」。** 而一轮恰恰可能因为把活派出去、正在等结果而结束。循环把两个语义不同的状态当成了同一个,于是必然在「交棒」的时刻抢跑。 轮次提示本身也不携带任何会话特异信息——`renderGoalRoundPrompt` 只渲染目标文本与 `Round: n/m`,加一句通用的「继续推进」。所以模型进入空轮时拿不到任何「你为什么被叫醒」的信息,只能从上下文里推断自己在等。 ## 3. 为什么这不只是浪费 token 按严重程度: **3.1 空轮次会把模型推向虚假完成。** 一轮无事可做时,模型有动机宣称目标达成——那是唯一能让循环停下来的动作。这直接冲撞本插件的核心不变量:证据门能拦下「无实据的 `achieved`」(`EVIDENCE_REQUIRED`),也能拦下「清单未完成」(`OPEN_ITEMS_REMAIN`),但拦不住模型去**制造**一份看起来像实据的东西。门越严,这个压力反而越大。 **3.2 空轮次消耗 `maxGoalRounds` 配额。** 空转与有效轮次共用同一个上限。 **3.3 现有卡顿守卫挡不住它。** `stallSignature` 比对的是 `next_step` 的归一化签名(去空白、转小写、去尾部标点、截断 160 字符),只有**逐字相同**才累加。模型每轮把「仍在等待子代理」换个说法,`stallCount` 就被重置为 0。这个守卫是为「反复提出同一个下一步」设计的,对「反复申报同一件无法推进的事」无效。 ## 4. DSH 已经提供了正确信号 查 `jobs` 服务契约后确认三件事: **4.1 有同步、按 owner 隔离的查询。** ``` abstract list(caller?: Agent): JobSnapshot[] // "List caller-owned and unowned jobs in registration order without exposing // another session's labels." interface JobSnapshot { id; kind; label; ownerSession?; status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'; startedAt; finishedAt?; reported: boolean; } ``` `ctx.jobs.list(agent)` 同步返回「这个代理手上还有哪些任务」,无需轮询、无 async 依赖。 **4.2 后台命令与子代理都归这个注册表管。** ```ts interface JobKindMap { bash: 'bash'; subagent: 'subagent' } ``` **4.3 最关键的一句:结算本身就会唤醒父代理。** > "Completion is announced last, after the record is committed and every other observer of the settlement has seen it, **because a reporter may open a model turn synchronously**." 以及 `onJobDone(listener)` 的存在,说明任务完成会触发一轮模型轮次来投递结果。 **这条推论是整份方案的支点**:既然结算会自己叫醒主代理,那么「推迟开启下一轮」不会丢失任何东西——不需要 goal 循环去轮询等待,唤醒由 jobs 层负责。 ## 5. 方案:把「推进」换成「推迟」 不新增循环,复用现有的 disarm/resume 机制,在 `onTurnEnd` 里插一道门。 ### 5.1 判定顺序(顺序本身就是语义) 在 `lib/index.js` 的 `onTurnEnd` 中,现有顺序是: ``` 1. 目标存在且 phase === 'active' 2. round = goal.roundsStarted >= 1 3. overlay.bind 4. 本轮已有判定 → return(driver 接管后续) ← 显式放行 5. 该轮被取消(aborted)→ return 6. withdraw(disarm) 7. 未排过校验轮 → 排校验轮;排过 → markUnverified ``` 推迟门要插在 **5 与 6 之间**: ``` ... 5. 该轮被取消(aborted)→ return 5.5 仍有在飞任务 → 记账并 return(不排任何消息) ← 新增 6. withdraw(disarm) 7. ... ``` 放在第 4 步**之后**是关键:模型若在轮内主动调用了 `goal_verify`,第 4 步就短路返回,门根本不生效,driver 照常开下一轮。**这就是「我要并行推进」的显式出口**——闭合本轮、给出 `next_step`,即表示「这一轮我做完了,继续」。 ### 5.2 判定内容 ```js function pendingWork(agent) { const jobs = ctx.get('jobs') if (jobs === undefined) return [] // 未挂载 jobs 服务时门自动失效 return jobs.list(agent) .filter((job) => job.status === 'running' || job.status === 'stopping') .map((job) => ({ id: job.id, kind: job.kind, label: job.label, startedAt: job.startedAt })) } ``` 只把 `running` / `stopping` 视为阻塞。已结算但 `reported: false` 的任务不阻塞:它的投递通知已经在路上,会自己叫醒代理。 `jobs.list(caller)` 还会返回**无主任务**(`owner === undefined`),它们不属于任何会话;`ownerSession` 是唯一能把它们排除掉的字段,否则一个进程级任务会阻塞所有目标。实现见 `lib/pending.js`:`pendingJobs` 走上面的同步查询,`pendingChildren` 补上可延续子代理(原因见 §7.2)。 ### 5.3 记账与恢复 - 进入推迟时记 `deferral = { handles, since }` 到 overlay 的 goal 记录里。 - 推迟**不排任何消息**,也**不触发 `markUnverified`**——等待不是「没做校验」,不该被计入未校验轮次。 - 恢复路径不需要新代码:结算唤醒一轮 → 该轮 `turn/end` → `pendingWork` 为空 → 走原来的第 6、7 步,正常排校验轮。 - 台账快照新增 `deferral` 字段,面板显示「⏸ 推迟中,等待 `bash-3`(已 4m)」。 ### 5.4 兜底:推迟不能是静默的 纯 `turn/end` 驱动的逻辑有个盲区:如果任务永远不结算(挂住),就没有任何轮次发生,也就没有事件来触发兜底。因此需要一个**独立定时器**: - 进入推迟时挂一个 `deferralTimeoutMs`(默认建议 10 分钟)的 `unref()` 定时器; - 超时 → `ctx.goals.pause(agent, ref)`,并在 reason 里点名具体句柄:`Waiting on bash-3 (subagent "audit") for 12m without settlement.` - 面板立刻显示暂停原因;用户回来后自行决定 resume 还是 kill 任务。 **要把「静默卡死」当成比「空转」更坏的结果**:空转至少还留下痕迹,静默卡死会让目标在无人察觉时停摆。所以这一条是 fail-loud,且不可关闭(超时时长可配)。 **实现时的调整(pause → block)**:一开始选了 `pause`——语义贴近「需要人看一眼」,且与手动暂停表现一致——把解释放在 overlay 的 `deferral.timedOut` 上,并承认代价是原生卡片看不到句柄。 后来发现这个取舍建立在**错误前提**上:`dsh-goal-round-driver:237` **只在 `change.operation === "pause"` 时取消正在运行的轮次**。也就是说拿 `pause` 做超时升级,会在轮次恰好正在跑时**误杀它**;`block` 不会。于是改用 **`block`**,一次拿到三个好处:不误杀、原因(句柄 + 等待时长)写进持久目标记录、连原生 goal 卡片都能显示。顺带解释了一个本文档没预料到的现象,见 §13。 ### 5.5 配置 ```yaml deferWhileBusy: true # 关闭即回到当前行为 deferralTimeoutMs: 600000 # 超时暂停并点名句柄 ``` ## 6. 主要代价(必须承认) **推迟会把本可并行的工作串行化。** 如果模型派了一个 30 分钟的后台构建,手上又有独立工作,推迟门会让目标干等 30 分钟。 三点缓解,但都不是完全解决: 1. 模型本可以在**同一轮内**把独立工作也派出去——它选择结束轮次,本身就是「我在等」的信号; 2. 显式调用 `goal_verify` 可以随时放行(第 4 步短路); 3. 面板会显示推迟状态与句柄,人类可以立刻介入。 ## 7. 未决问题 **7.1 出口是否可发现?** 模型怎么知道「我可以调 `goal_verify` 来继续」?现在的协议播报(目标创建时的 ``)解释了 `goal_verify` 闭合本轮,但没说「即使还有任务在飞,你也可以用它表示继续」。可以: - (a) 在协议播报里加一句; - (b) 推迟时排一条 notice 告诉模型——但这等于把我们想消灭的空轮又请回来,**不推荐**; - (c) 不特意宣传,依赖模型「做完就应提交判定」的自然倾向。 倾向 (a),代价是每个目标多一句提示文本。 **7.2 后台 `subagent` 是否真的入 `ctx.jobs`?——已确认,答案是「两条路都有」。** - **后台一次性子代理入 jobs。** `dsh-tool-subagent/lib/index.js:538` 在 `runInBackground && !continuable` 时调用 `jobs.start({ kind: 'subagent', label, owner: parent, run })`。后台 `bash` / `pwsh` 同理(`dsh-tool-bash:414`、`dsh-tool-pwsh:387`)。 - **可延续子代理不入 jobs。** 同一文件 `:525-533` 在 `continuable` 分支直接返回 `ctx.subagents.startContinuable(...)` 的 `childId`,**不注册 job**。 所以**必须两条信号都用**:只查 `jobs.list` 会漏掉 `subagent_fork` 这类可延续子代理。§5.2 原本「只查 jobs」的方案因此被实现为 `lib/pending.js` 的双信号探测,第二条走: ``` ctx.subagents.listChildren(parentSessionId) → SubagentListEntry.activity === 'running' // 'inactive' 的闲置子代理不阻塞 ``` 代价是 async、要走 session 查询,且 `listChildren` 只覆盖直接子级(另有 `listDescendants`)。 旁证:`dsh-jobs-local` 的 `activeTaskCount(owner)` 用的正是 `status === "running" || status === "stopping"`,与本方案的过滤条件一致。 **7.3 `jobs.list(caller)` 的作用域是否正确?** 契约里提到注册与投递是 owner-relative 的,而插件若从宿主 composition 的无作用域上下文挂载,会看到每一个 owner。传 `agent` 是正确用法,但需要确认返回集不会混入其他会话的任务。 **7.4 `disarm` 是否真的阻止 driver 排队?** 本插件**当前已依赖**这个假设(在轮次被认领时 disarm),但它是从 driver 源码推断的(`drive()` 对 `activation !== "armed"` 提前返回),**尚未在真实会话中验证**。推迟门建立在同一假设上,风险叠加而非新增。 ## 8. 归属:这其实是 driver 层的通病 任何 goal 都受影响,与本插件无关。三个位置: | 位置 | 覆盖面 | 代价 | |:---|:---|:---| | `dsh-goal-round-driver`(原生) | 进程内所有 goal,最根本 | 需改宿主 composition;上游升级会覆盖 | | **本插件(推荐先做)** | 所有走本插件的 goal——因为校验轮本来就由本插件插入 | 改动小;若用户不装本插件则无效 | | 不修 | — | 3.1 的虚假完成压力持续存在 | ## 9. 第二道防线(与本门互补,可独立做) 推迟门依赖「有在飞的任务」这个信号。但空轮也可能因为别的原因发生(例如模型把自己绕进去了)。一个**不依赖 jobs** 的补充守卫:判定「这一轮没有产生任何新信息」,例如同时满足 - 本轮校验没有引入新的 evidence, - 未完成清单项的集合与状态与上一轮完全相同, - 连续发生 N 次, → 走 `ctx.goals.block`,reason 说明「连续 3 轮没有任何清单项或证据变化」。 这比 `stallSignature` 强,因为它是**状态比对**而非**文本比对**,换个说法无法绕过。代价是需要在台账里记录每轮的 open-item 指纹。建议与本门分开评估,互不阻塞。 ## 10. 验收标准与结果 | # | 标准 | 结果 | |:--|:---|:---| | 1 | 轮次结束且有在飞任务 → 不排任何消息,`activation` 保持 `disarmed` | ✅ 冒烟 `deferral:` | | 2 | 在飞任务结算后 → 针对**原来那一轮**排校验轮,轮次编号不前进 | ⚠️ 部分(见下) | | 3 | 轮内显式 `goal_verify` → 门不拦截,下一轮照常开启 | ✅ 冒烟 `opt-in:` | | 4 | 推迟超过 `deferralTimeoutMs` → 目标 paused,解释保留 | ✅ 冒烟 `timeout:` | | 5 | 真实会话确认 `disarm` 让 driver 停止排队 | ❌ 未做(§7.4) | **第 2 条的诚实说明**:冒烟验证了「推迟期间 `roundsStarted` 不变」与「在飞任务清空后再结束轮次会排出一轮校验」,但**结算唤醒那一轮本身是 harness 行为,桩里没有模拟**——冒烟是手工再发一次 `turn/end` 代替的。真实链路是 jobs 结算 → 通知同步开启模型轮次 → 该轮 `turn/end` → 门放行。**这个衔接是整份方案里唯一没有跑通的环节**,需要真实会话确认。 计数:`npm test` 39 项单测(verify 10 / overlay 17 / resolve 5 / pending 7)全通过;`node scripts/smoke.mjs` 14 项断言全通过。 ## 11. 与 ZCode 的关系(诚实说明) ZCode 目标模式的文档里没有子代理/后台任务这一层,它的校验轮只处理「一轮做完 → 判定是否达成」。**本方案是针对 DSH 特有的 `jobs`/`subagents` 结构做的适配,不是照搬 ZCode。** 把它算作 ZCode 目标模式的延伸,而不能说成是它的原有行为。 ## 12. 推进状态 1. ✅ §7.2 已确认,并因此把信号从「只查 jobs」改为双信号(详见该节); 2. ✅ §7.1 出口可发现性选 (a):`` 播报里加了一句「有独立工作时直接提交判定即可恢复」; 3. ✅ 推迟门 + 兜底暂停已实现; 4. ⏳ §9 第二道防线(状态指纹)尚未做,与本门互不阻塞; 5. ❌ 真实会话验证 §10 第 2 条(结算唤醒衔接)与第 5 条(`disarm` 是否真让 driver 停手)——**这是唯一仍未闭合的环节**。 ## 13. 附带的发现:goal 卡片暂停会取消当前回答 同一个 driver 监听器还造成一个用户体验问题——**人点暂停会连带取消正在生成的回答**: ```js // dsh-goal-round-driver/lib/index.js:237 if (change.operation === "pause" && agent.status === "running" && ctx.agents.currentInitiator() !== agent) agent.cancel({ kind: "user" }, { keepInbox: true }); ``` `currentInitiator()` 是 AsyncLocalStorage 中的「发起者」,由 `withInitiator` / `withoutInitiator` 建立与清除。于是出现一个非对称: - **人类**点暂停 → 无 initiator,`undefined !== agent` 成立 → **取消回答**; - **模型**在轮内自己调 `update_goal action=pause` → initiator 是该 agent → 守卫拦住 → **不取消**。 (第二条是按守卫语义推断,未实测。)守卫作者显然想区分这两种情况,却恰好把人类暂停判成了该中断。 Codex 的语义是分开的:goal 卡片暂停只停**后续自动轮次**,当前回答说完;输入框停止才中止当前回答。 **插件侧修法(已实现)**:面板暂停改两段式——先 `disarm()`(撤销续跑权;`disarm` 是进程内 activation 变更、**不产生持久 `goal/changed`**,因此上面这段取消根本不会被触发),等 agent 空闲后再提交持久 `pause()`(此时 `agent.status === "running"` 不成立,同样不取消)。用户中途点 resume 会取消这个待提交的暂停。 **插件侧修不了的部分**:原生 goal 卡片与 `/goal pause` 直接调 `ctx.goals.pause`,那段取消是 driver 自己的监听器,无法拦截、也无法事后补救(轮次已经没了)。要修只能改宿主 `dsh-goal-round-driver`,代价是升级会被覆盖——本次按用户选择**不动宿主**,改为在 README 里把两个控件的语义写清楚。