Background job completion wakes are unbounded by default
Background job completion wakes are unbounded by default
The [idle-owner wake decision](2026-08-11-background-job-completion-wakes-an-idle-owner.md) shipped `maxConsecutiveWakes` with a default of 3: an idle owner could be woken three times by job completions before further notices degraded to injection, and only a claimed user message refilled the budget. The counter was per owner and shared by every job kind — background `bash`/`pwsh`/PTY commands and one-shot `subagent_
English
Problem
The idle-owner wake decision shipped maxConsecutiveWakes with a default of 3: an idle owner could be woken three times by job completions before further notices degraded to injection, and only a claimed user message refilled the budget. The counter was per owner and shared by every job kind — background bash/pwsh/PTY commands and one-shot subagent_* children.
A session that chained more than three of them without a user message hit the cap (issue #4944, reported as dsh-external/issues#641). The fourth notice was injected into the next-step inbox of an owner nothing would wake; the client renders no pending context message, so the session sat with a notice it could not see. The tool-jobs prompt promises the model "you are notified in-session when a job finishes — do not busy-poll", which encourages exactly the start-and-end-turn pattern that exhausts the cap. The stall arrived when the user was away and looked like a hang until they typed something.
Decision
maxConsecutiveWakes has no default. Unset, every completion reaching an idle owner under wakeup delivery opens a turn. The agent/inbox/claimed refill listener and the per-owner ledger exist only when a cap is configured; a configured value must still be a whole number of turns, and Infinity is rejected because omitting the field already means unbounded.
The self-exciting chain the cap was built for — a woken turn starting the job whose completion wakes it again — is still real, and deployments that want the bound set the field. The trade-off is that the chain spends model requests the user can watch and stop, while a notice parked past a cap is invisible: nothing in the client shows it, nothing re-arms the budget, and the model's promise becomes false. An observable cost beats an unobservable stall.
dsh run is unaffected: headless exits once the task turn goes idle, so there is no idle owner left to wake.
Alternatives considered
Keep the default cap and make the parked notice visible — a session-level pending indicator or an unread mark on the jobs list. This keeps the runaway bound but still breaks the prompt's promise: the model ended its turn expecting a notice and receives nothing until the user acts. Visibility is worth adding for a configured cap; it does not justify a default one.
Count only self-excited wakes — a wake counts against the budget only when the woken turn starts a new background job. This bounds the runaway chain without cutting off a chain of independent jobs, but the distinction needs the plugin to correlate job starts with the turn a wake opened, state no current consumer needs. It remains the natural refinement if a configured cap proves too coarse.
A cooldown or rate limit instead of a counter. Time does not distinguish a wanted chain from a runaway one; the wanted case is often the slow one, and a rate limit still parks a notice invisibly when it triggers.
Re-check the next-step inbox on every turn-close path (abort, LLM error, rejected pre-step). This closes the other parking spots the issue lists, but they are reached only by an already-parked notice; with no default cap the reported scenario never parks one. It stays a separate agent-loop change.
Consequences
- An unattended session chaining background jobs is woken for every completion; the model request per wake is the cost, and the user can stop the session.
- The cap is opt-in configuration. Its README, config-catalog, and JSDoc state the silent-stall cost of setting it.
- Unit coverage pins four unattended completions each opening a turn under the default config; the cap, refill, and quiet-delivery tests keep explicit
maxConsecutiveWakesvalues. - No recorded-session snapshot changes: no shipped profile sets the field, and no existing scenario chains more than three idle completions.
中文
问题
空闲所有者唤醒决策发布时 maxConsecutiveWakes 默认为 3:空闲所有者最多被任务完成唤醒三次,此后的通知降级为注入,只有被领取的用户消息才补充预算。计数器按所有者记录,并由所有任务类型共用——后台 bash/pwsh/PTY 命令与一次性 subagent_* 子级。
一个会话在没有用户消息的情况下串起超过三个这样的任务就会触顶(issue #4944,来源为 dsh-external/issues#641)。第四条通知被注入到一个无人会唤醒的所有者的 next-step inbox;客户端不渲染待领的 context 消息,会话就带着一条自己看不见的通知停在那里。tool-jobs 的提示词向模型承诺「任务完成会在会话内通知你,不要轮询」,恰恰鼓励了会耗尽上限的「启动后结束本轮」模式。停摆发生在用户离开时,看起来像挂死,直到用户输入点什么。
决策
maxConsecutiveWakes 没有默认值。未设置时,wakeup 投递下抵达空闲所有者的每次完成都会开启一轮。agent/inbox/claimed 补充监听与按所有者的台账只在配置了上限时存在;配置的值仍必须是整数轮数,Infinity 被拒绝,因为省略该字段已经表示无上限。
上限所针对的自激链——被唤醒的一轮启动某个任务,其完成又唤醒它——依然真实存在,想要该界的部署自行设置字段。取舍在于:这条链花掉的是用户看得见、能停下的模型请求,而停在上限之外的通知是不可见的:客户端不显示,没有机制重新充能预算,模型得到的承诺也成了假话。可观察的代价胜过不可观察的停摆。
dsh run 不受影响:headless 在任务轮次空闲后即退出,没有可唤醒的空闲所有者。
被否决的替代方案
保留默认上限,让停驻的通知可见——会话级的待处理指示或任务列表上的未读标记。这保住了失控约束,但仍然违背提示词的承诺:模型结束本轮等着通知,却要等到用户动手才收到。可见性值得为已配置的上限补上;它不能为默认上限辩护。
只统计自激的唤醒——只有被唤醒的一轮又启动了新的后台任务时才计入预算。这约束了失控链而不切断一串独立任务,但这种区分需要插件把任务启动与唤醒所开启的轮次关联起来,是当前没有消费者需要的状态。若配置的上限被证明过粗,它仍是自然的细化方向。
用冷却或速率限制代替计数器。 时间无法区分想要的链与失控的链;想要的情形往往正是慢的那种,而速率限制触发时同样会把通知不可见地停驻。
在每条轮次结束路径上复查 next-step inbox(abort、LLM 错误、被拒绝的 pre-step)。这关闭了 issue 列出的其他停驻点,但它们只会被一条已停驻的通知触及;没有默认上限时,报告的场景根本不会停驻通知。它仍是独立的 agent-loop 改动。
影响
- 串起后台任务的无人值守会话会为每次完成被唤醒;每次唤醒一次模型请求是其代价,用户可以停止会话。
- 上限是可选配置。其 README、config-catalog 与 JSDoc 说明了设置它带来的静默停摆代价。
- 单元覆盖钉住默认配置下连续四次无人值守完成各开启一轮;上限、补充与 quiet 投递的测试保留显式的
maxConsecutiveWakes值。 - 录制会话快照无变化:没有任何内置 profile 设置该字段,也没有既有场景串起超过三次空闲完成。