Merge pull request #4930 from deepseek-harness/fix/641-unbounded-completion-wakes
fix(tool-jobs): wake an idle owner for every completion by default
This commit is contained in:
commit
a294fc55d0
14 files changed
+162
-58
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.md: b8a10a2150ead02036cb97506a25c3a51202adcf
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: adc0770cd00adeb3b13c95dcc386464493dcd943
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.md: f13f09d326dc7b4e7b2998f3124f70c185c50922
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: f9b563ebbf90e2ca19d1fc5ca9fa5b028e4429f1
|
||||
+7
-7
@@ -28,11 +28,11 @@ For a driver that is genuinely running, `steer()` and `inject()` are the same de
|
||||
|
||||
Injection is correct there. A cancelled turn is a user pressing stop, and reopening one on their behalf launders an interrupt into a model request they did not ask for. The turn loop already covers the ordinary case: it cannot close while the next-step inbox holds anything, so a notice arriving before that check extends the current turn, and several tasks settling together cost one step rather than one turn each.
|
||||
|
||||
### Waking is bounded, and the bound is not time
|
||||
### The cap on consecutive wakes is opt-in, and it is not time
|
||||
|
||||
`maxConsecutiveWakes` (default 3) caps the turns one owner may open this way; beyond it a notice degrades to injection and waits for the next turn. Claiming any user-authored message restores the budget — claiming, not arrival, because that is the point human input actually enters a step. Notices this plugin queued never refill it.
|
||||
Waking is unbounded unless `maxConsecutiveWakes` is set. With it set, the value caps the turns one owner may open this way; beyond it a notice degrades to injection and waits for the next turn. Claiming any user-authored message restores the budget — claiming, not arrival, because that is the point human input actually enters a step. Notices this plugin queued never refill it.
|
||||
|
||||
The bound exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. `dsh run` needs no separate policy: its one user message is claimed in the first turn and never repeats, so the budget is spent monotonically and the process terminates.
|
||||
The cap exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. It has no default because a notice past the cap stalls the session invisibly; the [unbounded-by-default decision](2026-09-22-unbounded-completion-wakes-by-default.md) records that trade-off. `dsh run` needs no separate policy either way: headless exits once the task turn goes idle, so a completion after that point has no owner to wake.
|
||||
|
||||
`completionDelivery: quiet` restores the old lane for idle owners. It exists for deterministic transcripts; job completion independently retains `quiet | wakeup` because its bounded owner-turn policy differs from next-step subagent reports.
|
||||
|
||||
@@ -52,15 +52,15 @@ The bound exists because this chain is self-exciting in a way subagent settlemen
|
||||
|
||||
**A general unsolicited-input queue** with priority lanes, as Claude Code uses to merge background jobs, cron, MCP push, and hooks into one drain. DSH's inbox already is that queue — durable `agent/inbox/spliced` splices over `next-turn`/`next-step` — so this would add a layer above an existing one to decide a single bit.
|
||||
|
||||
**Refusing to reopen a turn that already produced a visible answer,** Codex's `MailboxDeliveryPhase` latch. That latch is the default this decision deliberately inverts: waking after the model has spoken is the entire point, and the wake budget is the bound instead.
|
||||
**Refusing to reopen a turn that already produced a visible answer,** Codex's `MailboxDeliveryPhase` latch. That latch is the default this decision deliberately inverts: waking after the model has spoken is the entire point, and the optional wake budget is the bound instead.
|
||||
|
||||
**A wall-clock window** on top of the counter. For an interactive agent the slow case is the wanted one — an hour-long build finishing and the agent resuming is the feature — and `dsh run` is already bounded by the counter it cannot refill. Worth revisiting only if an unattended long-lived deployment appears.
|
||||
**A wall-clock window** on top of the counter. For an interactive agent the slow case is the wanted one — an hour-long build finishing and the agent resuming is the feature — and `dsh run` exits once its task turn goes idle regardless. Worth revisiting only if an unattended long-lived deployment appears.
|
||||
|
||||
**Suppressing `onJobDone` entirely during owner drain,** symmetric with the service-wide `listenersClosed`. It reads cleaner and removes a signal that is not only for notices: the force-fail record and the runtime invariant both observe teardown settlements. The `reported` bit denies exactly the reporters and nothing else.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Default behavior changes: an idle owner now spends a model request per completion, capped at `maxConsecutiveWakes` per owner between user messages. Deployments that want the old behavior set `completionDelivery: quiet`.
|
||||
- Default behavior changes: an idle owner now spends a model request per completion, capped per owner between user messages only where `maxConsecutiveWakes` is set. Deployments that want the old behavior set `completionDelivery: quiet`.
|
||||
- The `tool-jobs` prompt section needs no edit; "You are notified in-session when a task finishes" became true rather than aspirational.
|
||||
- `JobSnapshot.reported` gains teardown as a fourth setter, documented at the Service Definition and in [the subsystem reference](../../../../docs/subsystems/jobs.md).
|
||||
- `settle()` announces completion after committing the record and publishing the visible-set change. Any listener relying on running before waiters were released or before `onJobsChanged` now runs after both.
|
||||
@@ -69,7 +69,7 @@ The bound exists because this chain is self-exciting in a way subagent settlemen
|
||||
|
||||
### Accepted risks
|
||||
|
||||
A spent budget is restored only by user input. An unattended agent that exhausts it collects its remaining notices whenever something else opens a turn, and nothing re-arms it in the meantime.
|
||||
Under a configured `maxConsecutiveWakes`, a spent budget is restored only by user input. An unattended agent that exhausts it collects its remaining notices whenever something else opens a turn, nothing re-arms it in the meantime, and the client shows nothing while the notice waits. This is why the cap has no default.
|
||||
|
||||
A notice pending on an idle owner under `quiet` still dies with that owner's disposal, unchanged from before: the disposal cancel clears the unclaimed inbox and the log keeps the insert/cancel pair as the record. The [settlement delivery note](2026-08-06-manager-owned-subagent-settlement-delivery.md) owns the offline-mailbox discussion this would need.
|
||||
|
||||
|
||||
+7
-7
@@ -28,11 +28,11 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
在那里注入才是对的。轮次被取消意味着用户按了停止,替他们重新开一轮等于把一次中断洗成了他们没有要求的模型请求。普通情形已由轮次循环覆盖:只要 next-step inbox 还有内容,轮次就无法结束,因此在该检查之前抵达的通知会延长当前轮次,同时结算的多个任务只花掉一步而不是各占一轮。
|
||||
|
||||
### 唤醒有界,且该界不是时间
|
||||
### 连续唤醒上限是可选的,且该界不是时间
|
||||
|
||||
`maxConsecutiveWakes`(默认 3)限制一个所有者由此开启的轮数;超出后通知降级为注入,等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达,因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
|
||||
除非设置了 `maxConsecutiveWakes`,唤醒没有上限。设置后,该值限制一个所有者由此开启的轮数;超出后通知降级为注入,等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达,因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
|
||||
|
||||
设界是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。`dsh run` 不需要单独策略:它唯一的用户消息在第一轮就被领取且不会重复,因此预算单调消耗,进程必然终止。
|
||||
设上限是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。它没有默认值,因为超出上限的通知会让会话不可见地停摆;[默认无上限的决策](2026-09-22-unbounded-completion-wakes-by-default.zh.md)记录了这项取舍。`dsh run` 无论如何都不需要单独策略:headless 在任务轮次空闲后即退出,此后的完成没有可唤醒的所有者。
|
||||
|
||||
`completionDelivery: quiet` 为空闲所有者恢复旧通道。它的存在是为了确定性 transcript;后台任务完成会独立保留 `quiet | wakeup`,因为其有界的所有者轮次策略不同于 next-step subagent 报告。
|
||||
|
||||
@@ -52,15 +52,15 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
**一个通用的非请求输入队列**并带优先级通道,正如 Claude Code 用来把后台任务、cron、MCP 推送与 hook 合并进同一次排空。DSH 的 inbox 本身就是那个队列——`next-turn`/`next-step` 之上的持久 `agent/inbox/spliced` splice——因此这等于在既有层之上再加一层,只为决定一个 bit。
|
||||
|
||||
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由唤醒预算来承担。
|
||||
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由可选的唤醒预算来承担。
|
||||
|
||||
**在计数之上再加墙钟窗口**。对交互式 agent 而言,慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 已被它无法补充的计数封顶。只有当出现无人值守的长生命周期部署时才值得重新考虑。
|
||||
**在计数之上再加墙钟窗口**。对交互式 agent 而言,慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 无论如何都在任务轮次空闲后退出。只有当出现无人值守的长生命周期部署时才值得重新考虑。
|
||||
|
||||
**在 owner 排空期间整体压制 `onJobDone`**,与服务级的 `listenersClosed` 对称。它读起来更干净,但会移走一个不只服务于通知的信号:强制失败记录与运行时不变量都会观察 teardown 结算。`reported` 位恰好只否决报告方,别的什么也不否决。
|
||||
|
||||
## 影响
|
||||
|
||||
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,按所有者、在两次用户消息之间由 `maxConsecutiveWakes` 封顶。想要旧行为的部署设置 `completionDelivery: quiet`。
|
||||
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,只有设置了 `maxConsecutiveWakes` 时才按所有者、在两次用户消息之间封顶。想要旧行为的部署设置 `completionDelivery: quiet`。
|
||||
- `tool-jobs` 的提示词段落无需改动;「任务完成时你会在会话内收到通知」从愿景变成了事实。
|
||||
- `JobSnapshot.reported` 新增 teardown 作为第四个置位方,记录在 Service Definition 与[子系统参考](../../../../docs/subsystems/jobs.zh.md)中。
|
||||
- `settle()` 在提交记录并发布可见集变更之后才宣布完成。任何依赖「在释放等待方之前或在 `onJobsChanged` 之前运行」的监听器现在都排在两者之后。
|
||||
@@ -69,7 +69,7 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
### 已接受的风险
|
||||
|
||||
已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能。
|
||||
在设置了 `maxConsecutiveWakes` 的情况下,已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能,通知等待时客户端也毫无显示。这正是上限没有默认值的原因。
|
||||
|
||||
在 `quiet` 下待领于空闲所有者的通知仍会随该所有者释放而消亡,与此前一致:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。[结算交付 note](2026-08-06-manager-owned-subagent-settlement-delivery.zh.md) 承载这需要的离线信箱讨论。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-22-unbounded-completion-wakes-by-default.md
|
||||
2026-09-22-unbounded-completion-wakes-by-default.md: 8760154054de423240e16d93e3db5ae01521820a
|
||||
2026-09-22-unbounded-completion-wakes-by-default.zh.md: e5560bc7ced5a8ca9ab55ea56ba309676d78b568
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Background job completion wakes are unbounded by default
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-unbounded-completion-wakes-by-default.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
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_*` 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](https://github.com/dsh-external/issues/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 `maxConsecutiveWakes` values.
|
||||
- No recorded-session snapshot changes: no shipped profile sets the field, and no existing scenario chains more than three idle completions.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Background job completion wakes are unbounded by default
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-unbounded-completion-wakes-by-default.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[空闲所有者唤醒决策](2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)发布时 `maxConsecutiveWakes` 默认为 3:空闲所有者最多被任务完成唤醒三次,此后的通知降级为注入,只有被领取的用户消息才补充预算。计数器按所有者记录,并由所有任务类型共用——后台 `bash`/`pwsh`/PTY 命令与一次性 `subagent_*` 子级。
|
||||
|
||||
一个会话在没有用户消息的情况下串起超过三个这样的任务就会触顶(issue #4944,来源为 [dsh-external/issues#641](https://github.com/dsh-external/issues/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 设置该字段,也没有既有场景串起超过三次空闲完成。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: d65619dfdb2f1f8406f93ba32cdcca5b27dace75
|
||||
config-catalog.zh.md: 1dc8a03f22c98b1e388032bd51f0a5e5b2f65c6e
|
||||
config-catalog.md: fc5a8e12cf94715934d484121dfbbccc7ffc4252
|
||||
config-catalog.zh.md: 26c7807c997c41a208e047965eec59eeec925a9c
|
||||
@@ -3405,9 +3405,11 @@ export interface Config {
|
||||
completionDelivery?: CompletionDelivery
|
||||
/**
|
||||
* Turns one owner may have opened by completion wakes before the next
|
||||
* notice degrades to injection, reset by any user-authored input (default 3).
|
||||
* Bounds the self-exciting chain where a woken turn starts the job whose
|
||||
* completion wakes it again.
|
||||
* notice degrades to injection, reset by any user-authored input. Absent by
|
||||
* default: every idle completion wakes its owner. Set it to bound the
|
||||
* self-exciting chain where a woken turn starts the job whose completion
|
||||
* wakes it again, at the cost of notices past the cap waiting silently for
|
||||
* the next user input.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
}
|
||||
|
||||
@@ -3407,9 +3407,11 @@ export interface Config {
|
||||
completionDelivery?: CompletionDelivery
|
||||
/**
|
||||
* Turns one owner may have opened by completion wakes before the next
|
||||
* notice degrades to injection, reset by any user-authored input (default 3).
|
||||
* Bounds the self-exciting chain where a woken turn starts the job whose
|
||||
* completion wakes it again.
|
||||
* notice degrades to injection, reset by any user-authored input. Absent by
|
||||
* default: every idle completion wakes its owner. Set it to bound the
|
||||
* self-exciting chain where a woken turn starts the job whose completion
|
||||
* wakes it again, at the cost of notices past the cap waiting silently for
|
||||
* the next user input.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/jobs/tool-jobs/README.md
|
||||
README.md: 38cf89961d3bbf13d803bef206cbe843726f31ab
|
||||
README.zh.md: 9a6c1c01103f03c5e9915094714bcecadf473042
|
||||
README.md: 2836f10e160ae3ecfe9a2df7f6b82a88b4ddf33f
|
||||
README.zh.md: 1311f5656ded91fd4cc520e1114a41bb87ebf4d3
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use `dsh-tool-jobs` to inspect and control background commands, PTY work, and subagents through `job_output`, `job_list`, and `job_kill`. Reads can wait within a configured timeout, list results identify each job's kind and status, and cancellation settles only after the work stops. When owned work finishes, the agent receives an in-session notice: busy agents receive it in their next step, while idle agents may be woken by a bounded follow-up turn. Configuration controls wait limits, completion delivery, and consecutive wakeups. Stream output is consumed by one reader, and pending notices do not survive owner disposal.
|
||||
Use `dsh-tool-jobs` to inspect and control background commands, PTY work, and subagents through `job_output`, `job_list`, and `job_kill`. Reads can wait within a configured timeout, list results identify each job's kind and status, and cancellation settles only after the work stops. When owned work finishes, the agent receives an in-session notice: busy agents receive it in their next step, while idle agents are woken by a follow-up turn. Configuration controls wait limits, completion delivery, and an optional cap on consecutive wakeups. Stream output is consumed by one reader, and pending notices do not survive owner disposal.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -39,7 +39,7 @@ The three tools return `{ text, job }`, `PublicJobSnapshot[]`, and `{ outcome: '
|
||||
|
||||
When a job finishes, the owning agent receives `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` as an in-session message. A busy agent has the notice injected into its next step — the turn cannot close while the inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle agent is instead woken with a follow-up turn, because an unclaimed notice is a completion the model never learns about. Completions the model already collected get no notice: the registry reports a settlement that released a live `wait` as `awaited` — whether a `job_output` wait or a shell tool waiting on its own foreground command — and the plugin remembers the kills the model requested through `job_kill`; a settlement caused by owner or service teardown is skipped because nobody is left to read it.
|
||||
|
||||
Waking is bounded: each owner may be woken `maxConsecutiveWakes` times before further notices degrade to injection, and claiming any user-authored message restores the budget. The bound exists because the chain is self-exciting — a woken turn may start the background job whose completion wakes it again. `completionDelivery: quiet` keeps even idle owners on the injection lane, which deterministic transcripts need.
|
||||
Waking is unbounded by default: an unattended agent that chains background commands and one-shot subagents is woken for every completion. `maxConsecutiveWakes` caps that: each owner may be woken that many times before further notices degrade to injection, and claiming any user-authored message restores the budget. The cap bounds the self-exciting chain — a woken turn may start the background job whose completion wakes it again — but a notice past it waits silently until the next user input, so a session that relies on wakes to finish its work stalls there. `completionDelivery: quiet` keeps even idle owners on the injection lane, which deterministic transcripts need.
|
||||
|
||||
### Minimal configuration
|
||||
|
||||
@@ -54,7 +54,7 @@ Loading the plugin with no config is the common path; a `waitTimeoutMs` above `m
|
||||
| `waitTimeoutMs` | `30,000` | Wait used when `wait: true` omits `timeout_ms` |
|
||||
| `maxWaitTimeoutMs` | `600,000` | Cap for model-supplied waits; larger values clamp down to it |
|
||||
| `completionDelivery` | `wakeup` | `wakeup` opens a turn on an idle owner; `quiet` leaves the notice pending |
|
||||
| `maxConsecutiveWakes` | `3` | Turns one owner may open by wake before notices degrade to injection |
|
||||
| `maxConsecutiveWakes` | unset | Turns one owner may open by wake before notices degrade to injection; unset means no cap |
|
||||
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-jobs) is the exhaustive source for every accepted field and its JSDoc.
|
||||
|
||||
@@ -92,7 +92,7 @@ This section explains the design decisions behind the tools and points at the co
|
||||
|
||||
### Notice delivery lanes
|
||||
|
||||
The settlement subscription (`{ owners: 'scope' }`) skips settlements the registry reports as `awaited`, jobs the model killed through `job_kill`, unowned jobs, and teardown settlements, then resolves the agent registered for the owner session. A `wakeup` delivery opens a turn on an idle owner while the budget lasts, tracked per exact `Agent` in a `WeakMap`; claiming a user-authored message (`agent/inbox/claimed`) resets that owner's budget. A busy owner — or any notice past the budget, or `quiet` delivery — is injected into the next-step inbox instead. The registry counts only a wait still owed the projection at settlement, so a wait that timed out or was aborted leaves a later settlement to notify as usual; a removal drops the job from the model-kill set, which only ever holds live jobs the model killed.
|
||||
The settlement subscription (`{ owners: 'scope' }`) skips settlements the registry reports as `awaited`, jobs the model killed through `job_kill`, unowned jobs, and teardown settlements, then resolves the agent registered for the owner session. A `wakeup` delivery opens a turn on an idle owner; with `maxConsecutiveWakes` set, only while the budget lasts, tracked per exact `Agent` in a `WeakMap`, and claiming a user-authored message (`agent/inbox/claimed`) resets that owner's budget. A busy owner — or any notice past a configured budget, or `quiet` delivery — is injected into the next-step inbox instead. The registry counts only a wait still owed the projection at settlement, so a wait that timed out or was aborted leaves a later settlement to notify as usual; a removal drops the job from the model-kill set, which only ever holds live jobs the model killed.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -158,7 +158,7 @@ Reads return output or `(no new output)` followed by `[status: <status>]` and op
|
||||
|
||||
#### Token effect
|
||||
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner by `maxConsecutiveWakes`; a notice reaching a busy owner adds a step to the turn it is already paying for.
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner only when `maxConsecutiveWakes` is set; a notice reaching a busy owner adds a step to the turn it is already paying for.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -172,7 +172,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
These limits define when the tools are a poor fit. They are current package constraints, not a task backlog.
|
||||
|
||||
- **A settlement inside the driver's retirement window still strands its notice** — between the turn loop's last inbox check and the driver committing its idle phase the owner still reads as busy, so the notice is injected and nothing wakes. Steering has the same hole; closing it belongs to `agent-loop`.
|
||||
- **A spent wake budget is not restored by time** — only user-authored input refills it, so an unattended agent whose budget ran out collects its remaining notices on the next turn something else opens.
|
||||
- **A spent wake budget is not restored by time** — with `maxConsecutiveWakes` set, only user-authored input refills it, so an unattended agent whose budget ran out collects its remaining notices on the next turn something else opens, and nothing in the client shows that a notice is waiting.
|
||||
- **A notice pending on an idle owner does not survive that owner's disposal** — the disposal cancel clears the unclaimed inbox, and the log keeps the insert/cancel pair as the record.
|
||||
- **Model reads are single-consumer** — independent observers use the registry's non-consuming `readAt` (the Web client's `job.follow`), not these tools.
|
||||
- **Unowned jobs have no session fence** — external callers must supply policy or avoid them.
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
使用 `dsh-tool-jobs`,可通过 `job_output`、`job_list` 与 `job_kill` 检查和控制后台命令、PTY 工作与 subagent。读取可在配置的超时内等待,列表结果标识各任务的 kind 与状态,而取消只有在工作停止后才结算。归属明确的工作完成时,agent(智能体)会收到会话内通知:繁忙的 agent 在下一步收到通知,空闲的 agent 则可能由有界的 follow-up 轮次唤醒。配置控制等待上限、完成投递与连续唤醒次数。流输出仅供单一读取方消费,待领通知无法在所有者释放后存活。
|
||||
使用 `dsh-tool-jobs`,可通过 `job_output`、`job_list` 与 `job_kill` 检查和控制后台命令、PTY 工作与 subagent。读取可在配置的超时内等待,列表结果标识各任务的 kind 与状态,而取消只有在工作停止后才结算。归属明确的工作完成时,agent(智能体)会收到会话内通知:繁忙的 agent 在下一步收到通知,空闲的 agent 则由 follow-up 轮次唤醒。配置控制等待上限、完成投递与可选的连续唤醒上限。流输出仅供单一读取方消费,待领通知无法在所有者释放后存活。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -39,7 +39,7 @@ kind: "package-reference"
|
||||
|
||||
任务完成时,拥有它的 agent 会收到会话内消息 `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.`。繁忙的 agent 会在下一步收到注入的通知——inbox 尚有内容时轮次无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的 agent 则被一个 follow-up 轮次唤醒,因为无人领取的通知就是模型永远不会知道的完成。模型已经收走的完成不再通知:注册表把释放了存活 `wait` 的结算报告为 `awaited`——无论是 `job_output` 的等待,还是 shell 工具在等待自己的前台命令——插件则记着模型经 `job_kill` 请求的杀停;所有者或服务 teardown 导致的结算会被跳过,因为没有人能读它。
|
||||
|
||||
唤醒是有界的:每个所有者最多可被唤醒 `maxConsecutiveWakes` 次,此后的通知降级为注入;领取任何用户撰写的消息都会恢复预算。设界是因为这条链会自激——被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者。`completionDelivery: quiet` 让空闲所有者也在注入通道上,确定性 transcript(文本记录)需要的正是这一点。
|
||||
唤醒默认不设上限:无人值守的 agent 连续串起后台命令与一次性 subagent 时,每次完成都会唤醒它。`maxConsecutiveWakes` 可以封顶:每个所有者最多被唤醒这么多次,此后的通知降级为注入;领取任何用户撰写的消息都会恢复预算。上限用来约束自激链——被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者——但超出上限的通知会静默等到用户下一次输入,依赖唤醒完成工作的会话会停在那里。`completionDelivery: quiet` 让空闲所有者也在注入通道上,确定性 transcript(文本记录)需要的正是这一点。
|
||||
|
||||
### 最小配置
|
||||
|
||||
@@ -54,7 +54,7 @@ kind: "package-reference"
|
||||
| `waitTimeoutMs` | `30,000` | `wait: true` 省略 `timeout_ms` 时使用的等待时间 |
|
||||
| `maxWaitTimeoutMs` | `600,000` | 模型所给等待时间的上限;更大的值向下收敛到它 |
|
||||
| `completionDelivery` | `wakeup` | `wakeup` 为空闲所有者开启一轮;`quiet` 让通知继续待领 |
|
||||
| `maxConsecutiveWakes` | `3` | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入 |
|
||||
| `maxConsecutiveWakes` | 未设置 | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入;未设置即不封顶 |
|
||||
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-jobs)是每个受支持字段及其 JSDoc 的穷尽式真源。
|
||||
|
||||
@@ -92,7 +92,7 @@ kind: "package-reference"
|
||||
|
||||
### 通知投递通道
|
||||
|
||||
结算订阅(`{ owners: 'scope' }`)跳过注册表报告为 `awaited` 的结算、模型经 `job_kill` 杀停的任务、无所有者的任务与 teardown 结算,然后解析登记在拥有者会话下的 agent。`wakeup` 投递在预算内为空闲所有者开启一轮,按确切 `Agent` 记录在 `WeakMap` 中;领取用户撰写的消息(`agent/inbox/claimed`)会重置该所有者的预算。繁忙的所有者——或超出预算的任何通知,以及 `quiet` 投递——改为注入 next-step inbox。注册表只计入结算时仍被欠着投影的等待,因此超时或中止的等待会让之后的结算照常通知;移除会把任务从模型杀停集合里删掉,该集合只保有模型杀停过的存活任务。
|
||||
结算订阅(`{ owners: 'scope' }`)跳过注册表报告为 `awaited` 的结算、模型经 `job_kill` 杀停的任务、无所有者的任务与 teardown 结算,然后解析登记在拥有者会话下的 agent。`wakeup` 投递为空闲所有者开启一轮;设置了 `maxConsecutiveWakes` 时只在预算内开启,按确切 `Agent` 记录在 `WeakMap` 中,领取用户撰写的消息(`agent/inbox/claimed`)会重置该所有者的预算。繁忙的所有者——或超出所配预算的任何通知,以及 `quiet` 投递——改为注入 next-step inbox。注册表只计入结算时仍被欠着投影的等待,因此超时或中止的等待会让之后的结算照常通知;移除会把任务从模型杀停集合里删掉,该集合只保有模型杀停过的存活任务。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -158,7 +158,7 @@ Track every background job id you start. You are notified in-session when a job
|
||||
|
||||
#### Token 影响
|
||||
|
||||
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量按所有者由 `maxConsecutiveWakes` 封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
|
||||
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量仅在设置了 `maxConsecutiveWakes` 时按所有者封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -172,7 +172,7 @@ Track every background job id you start. You are notified in-session when a job
|
||||
这些限制说明工具何时不合适。它们是当前包约束,不是任务积压。
|
||||
|
||||
- **落在 driver 退休窗口内的结算仍会让通知搁浅**——在轮次循环最后一次检查 inbox 与 driver 提交 idle 相位之间,所有者读起来仍是繁忙,因此通知走注入且无人唤醒。steering(中途引导)存在同样的问题;修复它属于 `agent-loop`。
|
||||
- **已花掉的唤醒预算不会随时间恢复**——只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知。
|
||||
- **已花掉的唤醒预算不会随时间恢复**——设置了 `maxConsecutiveWakes` 时,只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知,且客户端不会显示有通知在等待。
|
||||
- **待领于空闲所有者的通知无法在该所有者释放后存活**——释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。
|
||||
- **模型读取只有单一消费方**——独立观察者使用注册表非消耗的 `readAt`(Web 客户端的 `job.follow`),而不是这些工具。
|
||||
- **无 owner 的任务没有会话隔离**——外部调用方必须提供策略或避开这些任务。
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* `ctx.jobs`. Loading the plugin attaches the controller required by
|
||||
* producers. It also delivers completions the model has not already
|
||||
* collected to the owning agent: injected into a busy owner's next step, or
|
||||
* opening a turn on an idle one under the default `wakeup` delivery, bounded
|
||||
* per owner.
|
||||
* opening a turn on an idle one under the default `wakeup` delivery, unbounded
|
||||
* unless `maxConsecutiveWakes` caps it per owner.
|
||||
* @module @deepseek-ai/dsh-tool-jobs
|
||||
*/
|
||||
|
||||
@@ -47,9 +47,11 @@ export interface Config {
|
||||
completionDelivery?: CompletionDelivery
|
||||
/**
|
||||
* Turns one owner may have opened by completion wakes before the next
|
||||
* notice degrades to injection, reset by any user-authored input (default 3).
|
||||
* Bounds the self-exciting chain where a woken turn starts the job whose
|
||||
* completion wakes it again.
|
||||
* notice degrades to injection, reset by any user-authored input. Absent by
|
||||
* default: every idle completion wakes its owner. Set it to bound the
|
||||
* self-exciting chain where a woken turn starts the job whose completion
|
||||
* wakes it again, at the cost of notices past the cap waiting silently for
|
||||
* the next user input.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export const Config: z<Config> = z.object({
|
||||
waitTimeoutMs: z.number().min(1).default(30_000),
|
||||
maxWaitTimeoutMs: z.number().min(1).default(600_000),
|
||||
completionDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
|
||||
maxConsecutiveWakes: z.number().min(1).default(3),
|
||||
maxConsecutiveWakes: z.number().min(1),
|
||||
})
|
||||
|
||||
/** Shared schema for job-control outputs. */
|
||||
@@ -190,7 +192,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const waitDefault = config.waitTimeoutMs ?? 30_000
|
||||
const waitCap = config.maxWaitTimeoutMs ?? 600_000
|
||||
const delivery = config.completionDelivery ?? 'wakeup'
|
||||
const wakeBudget = config.maxConsecutiveWakes ?? 3
|
||||
const wakeBudget = config.maxConsecutiveWakes
|
||||
|
||||
// Turns this plugin opened on each owner since that owner last consumed
|
||||
// human input. Keyed by the exact Agent, so a same-session replacement
|
||||
@@ -199,13 +201,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (waitDefault > waitCap) {
|
||||
throw new Error(`tool-jobs: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
|
||||
}
|
||||
// A budget is a count of turns. `Infinity` would leave the runaway chain this
|
||||
// field exists to bound unbounded, and a fraction never names a turn at all.
|
||||
if (!Number.isSafeInteger(wakeBudget)) {
|
||||
// A budget is a count of turns: a fraction never names a turn, and
|
||||
// `Infinity` would spell an "unbounded" that omitting the field already means.
|
||||
if (wakeBudget !== undefined && !Number.isSafeInteger(wakeBudget)) {
|
||||
throw new Error(`tool-jobs: maxConsecutiveWakes (${wakeBudget}) must be a whole number of turns`)
|
||||
}
|
||||
// Nothing spends the budget under quiet delivery, so nothing needs to refill it.
|
||||
if (delivery === 'wakeup') {
|
||||
// Nothing spends the budget under quiet delivery or without a cap, so
|
||||
// nothing needs to refill it.
|
||||
if (delivery === 'wakeup' && wakeBudget !== undefined) {
|
||||
ctx.on('agent/inbox/claimed', ({ agent, message }) => {
|
||||
// Claiming is the point the human's input actually enters a step; a notice
|
||||
// this plugin itself queued must not refill the budget it just spent.
|
||||
@@ -289,11 +292,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
summary: completionSummary(event.job),
|
||||
},
|
||||
})
|
||||
const spent = spentWakes.get(owner) ?? 0
|
||||
if (delivery === 'wakeup' && owner.status === 'idle' && spent < wakeBudget) {
|
||||
spentWakes.set(owner, spent + 1)
|
||||
owner.followup(message)
|
||||
return
|
||||
if (delivery === 'wakeup' && owner.status === 'idle') {
|
||||
if (wakeBudget === undefined) {
|
||||
owner.followup(message)
|
||||
return
|
||||
}
|
||||
const spent = spentWakes.get(owner) ?? 0
|
||||
if (spent < wakeBudget) {
|
||||
spentWakes.set(owner, spent + 1)
|
||||
owner.followup(message)
|
||||
return
|
||||
}
|
||||
}
|
||||
owner.inject(message)
|
||||
})
|
||||
|
||||
@@ -163,9 +163,9 @@ describe('tool-jobs setup', () => {
|
||||
.rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
|
||||
})
|
||||
|
||||
it('defaults delivery to wakeup and rejects an unknown lane', () => {
|
||||
it('defaults delivery to unbounded wakeup and rejects an unknown lane', () => {
|
||||
expect(ToolJobs.Config({}).completionDelivery).toBe('wakeup')
|
||||
expect(ToolJobs.Config({}).maxConsecutiveWakes).toBe(3)
|
||||
expect(ToolJobs.Config({}).maxConsecutiveWakes).toBeUndefined()
|
||||
expect(() => ToolJobs.Config({ completionDelivery: 'loud' as never })).toThrow()
|
||||
expect(() => ToolJobs.Config({ maxConsecutiveWakes: 0 })).toThrow()
|
||||
})
|
||||
@@ -187,7 +187,7 @@ describe('tool-jobs setup', () => {
|
||||
}
|
||||
|
||||
// The field exists to bound runaway waking; a fractional budget counts
|
||||
// nothing and an infinite one removes the bound it was configured for.
|
||||
// nothing and an infinite one is spelled by omitting the field.
|
||||
expect(await loadWith(Number.POSITIVE_INFINITY)).toContain('maxConsecutiveWakes')
|
||||
expect(await loadWith(2.5)).toContain('maxConsecutiveWakes')
|
||||
expect(await loadWith(1)).toBe('loaded')
|
||||
@@ -763,6 +763,19 @@ describe('completion notice delivery', () => {
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wakes an idle owner for every completion when no wake budget is set', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
|
||||
// Four unattended completions in a row must each open a turn; no
|
||||
// user input arrives in between to refill anything.
|
||||
await settleTasks(ctx, owner, 4)
|
||||
expect(followup).toHaveBeenCalledTimes(4)
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('degrades to injection once the consecutive wake budget is spent', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 2 })
|
||||
const inject = vi.fn()
|
||||
|
||||
Reference in new issue
Block a user