Merge pull request #2275 from deepseek-harness/feat/wake-idle-owner-on-task-completion

feat(tasks): wake an idle owner when a background task completes
This commit is contained in:
Yichen Jiang
2026-08-11 21:29:55 +08:00
committed by GitHub
28 changed files with 641 additions and 110 deletions

View File

@@ -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/architecture/2026-06-20-generic-long-running-tool-runtime.md
2026-06-20-generic-long-running-tool-runtime.md: 457eaac7de92ea37287803349672271148d59185
2026-06-20-generic-long-running-tool-runtime.zh.md: 9a86c4cf2b49973212adfd4b9acdb54150af01d1
2026-06-20-generic-long-running-tool-runtime.md: c34d0708ee88b94fcf9b9294fe002a9c3e081cfd
2026-06-20-generic-long-running-tool-runtime.zh.md: 311fd271ccbd139f7d82270eb56e2471a34a4bb3

View File

@@ -77,7 +77,7 @@ A producer loaded without any controller would let callers start work they canno
Stream reads share one task-scoped consuming cursor because the owning model is the intended reader. A UI or multiple independent readers need a separate non-consuming observation API; sharing this cursor would let readers consume one another's output.
The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent.
The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion delivers a logged message to the exact owner's session. A busy owner is injected; an idle owner is woken, under the bounded policy the [idle-owner wake decision](../feature/2026-08-11-background-task-completion-wakes-an-idle-owner.md) owns.
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task <id>` prefix and `task_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The task controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size.
@@ -131,4 +131,4 @@ Unit coverage pins preflight atomicity, per-kind ids, output-limit validation an
Bash commands and subagents share one id vocabulary, listing, notice format, prompt habit, and set of control tools. New long-running producers implement execution hooks instead of another registry and tool family. The [tool cookbook](../../../../docs/cookbook/adding-a-tool.md) points producers to this contract.
Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, completion notices do not wake idle agents, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs.
Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs.

View File

@@ -77,7 +77,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访
流式读取共享一个任务作用域内的消费游标因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API共享该游标会让读取方彼此消费对方的输出。
系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务并终止不再重要的工作。完成时系统会向确切所有者的会话注入一条已记录的 `context/message`;它会成为下一个请求的持久上下文,但不会唤醒空闲的 agent
系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务并终止不再重要的工作。完成时系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-task-completion-wakes-an-idle-owner.md)负责
当读取或等待交付终止任务、实时等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-tasks` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background task <id>` 前缀与 `task_output` 指令预留空间,再截断可变的 kind、label、status、detail乃至截断标记本身因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
@@ -131,4 +131,4 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas
bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。
有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方,完成通知不会唤醒空闲 agent;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。
有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。

View File

@@ -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-08-11-background-task-completion-wakes-an-idle-owner.md
2026-08-11-background-task-completion-wakes-an-idle-owner.md: cbebf1fbe82d47db4ba6e39b318d2e8ed8d89e17
2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md: b5f3cdd594b04370dcfe99d83775f34ab8b8115c

View File

@@ -0,0 +1,76 @@
# Agent Note: Background task completion wakes an idle owner
Status: implemented
English | [中文](2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md)
## Problem
`tool-tasks` promised the model "You are notified in-session when a task finishes — do not busy-poll or sleep on one." The promise held only while the model was still working. Completion delivered through `agent.inject()`, which appends to the next-step inbox without reserving a driver, so a task settling after its turn closed left the notice parked until something unrelated woke the agent. The common shape is exactly the one that breaks: the model starts a long command, tells the user it started it, ends its turn, and the command finishes into an inbox nobody will claim. The prompt told the model not to poll, and then nothing arrived.
The gap was recorded as a limitation rather than reasoned about, so the fallback was `task_output(wait: true)` — the blocking wait the same prompt discourages.
This supersedes one fact of the [background-task runtime decision](../architecture/2026-06-20-generic-long-running-tool-runtime.md) — that completion never wakes an idle owner — and adds teardown as a `reported` setter. That note keeps every other task-runtime decision and is updated in place rather than replaced.
The delivery machinery was never the obstacle. `Agent.send(message, target, wakeup)` has covered the `target` × `wakeup` matrix since the [unified send decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md), and `wakeDriver()` already handles idle, maintenance, and cancelled-converging phases. The missing piece was the policy choice of which lane a completion takes, plus the bound that choice needs.
## Decision
An unreported completion picks its lane from what the owner is doing. A busy owner is injected, unchanged. An idle owner is woken with `followup()`.
This adopts the delivery rule the [continuation manager](2026-08-06-manager-owned-subagent-settlement-delivery.md) already ships for subagent settlement, where "steering rather than injecting is deliberate … This is a correctness rule, not a deployment preference." The two paths do not overlap: `tool-subagent` registers a Task only for a one-shot background child and returns `continuable` before reaching that code, so a child is delivered by exactly one of the two mechanisms.
### The busy owner keeps injection
For a driver that is genuinely running, `steer()` and `inject()` are the same delivery: `wakeDriver()` returns early without latching for a running, unaborted phase. They differ only for an owner whose turn is cancelled but has not yet converged, where steering redirects to the next turn and replays the wake at convergence.
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
`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.
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 task 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.
`completionDelivery: quiet` restores the old lane for idle owners. It exists for deterministic transcripts, and mirrors the `reportDelivery` switch on `tool-subagent-report` in name, values, and default.
### Teardown claims the report
`cancelForTeardown` now marks the record `reported`, exactly as `kill()` does after cancelling. The asymmetry was invisible while the notice was a harmless inject; a waking reporter turns it into one model request per teardown layer, on agents the host is destroying.
`reported` was already the right bit — "a kill, read, or wait has reported or committed to report the terminal state" — and teardown is a kill without a caller. Using it keeps every observer of the settlement intact: `onTaskDone` still fires, so runtime invariants and the force-fail path stay covered, and only notice reporters go quiet.
### Completion is announced last
`settle()` released waiters, marked the record settled, and published the visible-set change *after* running completion listeners. A reporter that opens a turn does so synchronously, so that order let a woken turn's `turn/start` land before the settlement it was reacting to was committed, and before any `onTasksChanged` observer had seen it. Announcing completion last makes the reporter the final observer of a settlement every other observer has already seen.
## Alternatives considered
**A producer-declared wake bit on `TaskStart`,** matching Codex's `trigger_turn` and Kimi's `admission` enum. It is the better long-run shape — a `tail -f` stream and a two-hour build want different answers — but no current producer distinguishes them, and the repository requires a current owner and need for public surface. The natural trigger to add it is the first producer that wants one task to wake and another not to.
**A general unsolicited-input queue** with priority lanes, as Claude Code uses to merge background tasks, 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.
**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.
**Suppressing `onTaskDone` 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`.
- The `tool-tasks` prompt section needs no edit; "You are notified in-session when a task finishes" became true rather than aspirational.
- `TaskSnapshot.reported` gains teardown as a fourth setter, documented at the Service Definition and in [the subsystem reference](../../../../docs/subsystems/tasks.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 `onTasksChanged` now runs after both.
- The `tool-bash` real-composition test dropped its second user message: settlement alone carries the notice into a turn that collects the output. It asserts the durable outcome rather than a turn boundary, because whether the command outlives its turn is a race; the lane choice is pinned in `tool-tasks` unit tests instead.
- Unit coverage pins idle wake, busy injection, quiet delivery, budget exhaustion, budget restore on user input, non-restore on plugin notices, and teardown silence.
### 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.
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.
Whether a completion extends the running turn or opens a new one is a genuine race for short-lived tasks, so no authored transcript can hold both orders. Assembled coverage asserts the outcome; the lane choice is pinned in unit tests.
One microtask window survives: a settlement landing after the turn loop's last inbox check but before the driver commits its idle phase still reads `status === 'running'`, so it injects and nothing wakes. Steering would not close it either — `wakeDriver()` latches only for maintenance and post-cancel phases, not for a driver between its final check and its own retirement. Closing it needs an `agent-loop` boundary that publishes retirement before the last claim, which is a core-agent decision rather than a delivery-policy one.

View File

@@ -0,0 +1,76 @@
# Agent Note: Background task completion wakes an idle owner
Status: implemented
[English](2026-08-11-background-task-completion-wakes-an-idle-owner.md) | 中文
## 问题
`tool-tasks` 对模型承诺「任务完成时你会在会话内收到通知——不要忙轮询,也不要 sleep 等待」。这个承诺只在模型仍在工作时成立。完成经由 `agent.inject()` 交付,它只向 next-step inbox 追加而不预留 driver因此在轮次结束之后才结算的任务会把通知搁在那里直到某件无关的事情唤醒 agent。最常见的形态恰恰就是会失效的那一种模型启动一条长命令告诉用户已经启动结束轮次而命令完成后进入了一个无人领取的 inbox。提示词让模型不要轮询然后什么也没到。
这个缺口被记为一条限制,而不是被推敲过,于是退路成了 `task_output(wait: true)`——同一段提示词并不鼓励的阻塞等待。
本决策取代[后台任务运行时决策](../architecture/2026-06-20-generic-long-running-tool-runtime.md)中的一条事实——完成永不唤醒空闲所有者——并把 teardown 加为 `reported` 的置位方。那份 note 仍拥有其余全部任务运行时决策,因此就地更新而非替换。
交付机制从来不是障碍。自[统一 send 决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)起,`Agent.send(message, target, wakeup)` 就覆盖了 `target` × `wakeup` 矩阵,`wakeDriver()` 也已经处理 idle、maintenance 和已取消未收敛三种相位。缺的是「一次完成走哪条通道」这一策略选择,以及该选择所需的界。
## 决策
尚未报告的完成按所有者当时在做什么来选择通道。繁忙的所有者走注入,保持原样。空闲的所有者用 `followup()` 唤醒。
这采纳了[延续管理器](2026-08-06-manager-owned-subagent-settlement-delivery.md)已经为 subagent 结算所采用的交付规则,那里写着「用 steer 而非 inject 是刻意的……这是一条正确性规则,不是部署偏好」。两条路径不重叠:`tool-subagent` 只为一次性后台子 agent 注册 Task而 continuable 分支在抵达那段代码之前就已返回,因此一个子 agent 恰好由两种机制中的一种交付。
### 繁忙的所有者保留注入
对真正在运行的 driver 而言,`steer()``inject()` 是同一次交付:对于运行中且未中止的相位,`wakeDriver()` 会提前返回且不设置 latch。二者只在一种所有者上有区别——轮次已取消但尚未收敛此时 steer 会重定向到下一轮并在收敛时重放唤醒。
在那里注入才是对的。轮次被取消意味着用户按了停止,替他们重新开一轮等于把一次中断洗成了他们没有要求的模型请求。普通情形已由轮次循环覆盖:只要 next-step inbox 还有内容,轮次就无法结束,因此在该检查之前抵达的通知会延长当前轮次,同时结算的多个任务只花掉一步而不是各占一轮。
### 唤醒有界,且该界不是时间
`maxConsecutiveWakes`(默认 3限制一个所有者由此开启的轮数超出后通知降级为注入等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
设界是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent被唤醒的一轮却可能启动某个后台任务而它的完成又会唤醒同一个所有者且无人旁观。`dsh run` 不需要单独策略:它唯一的用户消息在第一轮就被领取且不会重复,因此预算单调消耗,进程必然终止。
`completionDelivery: quiet` 为空闲所有者恢复旧通道。它的存在是为了确定性 transcript并在名称、取值与默认值上都对齐 `tool-subagent-report``reportDelivery` 开关。
### 销毁自行认领报告
`cancelForTeardown` 现在会把记录标记为 `reported`,与 `kill()` 在取消之后所做的完全一致。当通知只是一次无害的注入时,这处不对称看不出来;而会唤醒的报告方会把它变成每个 teardown 层级一次模型请求,作用在宿主正要销毁的 agent 上。
`reported` 本来就是正确的那个 bit——「kill、read 或 wait 已报告或承诺报告终止状态」——而 teardown 是一次没有调用方的 kill。用它可以让该结算的每一个观察者都保持完整`onTaskDone` 仍会触发,因此运行时不变量与强制失败路径依旧被覆盖,只有通知报告方会安静下来。
### 完成是最后才宣布的
`settle()` 此前释放等待方、标记记录已结算并发布可见集变更的时机,都排在运行完成监听器**之后**。开启轮次的报告方是同步执行的,因此那个顺序会让被唤醒轮次的 `turn/start` 抢在它所响应的那次结算被提交之前落地,也抢在任何 `onTasksChanged` 观察者看到它之前。把完成放到最后宣布,使报告方成为该结算的最后一个观察者,而其他观察者都已先看到它。
## 被否决的替代方案
**在 `TaskStart` 上加生产方声明的唤醒位**,对应 Codex 的 `trigger_turn` 与 Kimi 的 `admission` 枚举。从长期看这是更好的形状——`tail -f` 流与两小时构建想要不同答案——但当前没有任何生产方需要区分它们,而仓库要求公共面必须有当下的所有者与需求。加它的自然触发点,是第一个「要让某个任务唤醒而另一个不唤醒」的生产方出现时。
**一个通用的非请求输入队列**并带优先级通道,正如 Claude Code 用来把后台任务、cron、MCP 推送与 hook 合并进同一次排空。DSH 的 inbox 本身就是那个队列——`next-turn`/`next-step` 之上的持久 `agent/inbox/spliced` splice——因此这等于在既有层之上再加一层只为决定一个 bit。
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由唤醒预算来承担。
**在计数之上再加墙钟窗口**。对交互式 agent 而言慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 已被它无法补充的计数封顶。只有当出现无人值守的长生命周期部署时才值得重新考虑。
**在 owner 排空期间整体压制 `onTaskDone`**,与服务级的 `listenersClosed` 对称。它读起来更干净,但会移走一个不只服务于通知的信号:强制失败记录与运行时不变量都会观察 teardown 结算。`reported` 位恰好只否决报告方,别的什么也不否决。
## 影响
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,按所有者、在两次用户消息之间由 `maxConsecutiveWakes` 封顶。想要旧行为的部署设置 `completionDelivery: quiet`
- `tool-tasks` 的提示词段落无需改动;「任务完成时你会在会话内收到通知」从愿景变成了事实。
- `TaskSnapshot.reported` 新增 teardown 作为第四个置位方,记录在 Service Definition 与[子系统参考](../../../../docs/subsystems/tasks.md)中。
- `settle()` 在提交记录并发布可见集变更之后才宣布完成。任何依赖「在释放等待方之前或在 `onTasksChanged` 之前运行」的监听器现在都排在两者之后。
- `tool-bash` 的 real-composition 测试去掉了第二条用户消息:仅靠结算就能把通知带入一个收集输出的轮次。它断言持久结果而非轮次边界,因为命令是否活得比它的轮次久是一场竞态;通道选择改由 `tool-tasks` 单元测试钉住。
- 单元覆盖钉住空闲唤醒、繁忙注入、quiet 交付、预算耗尽、用户输入恢复预算、插件通知不恢复预算,以及 teardown 静默。
### 已接受的风险
已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能。
`quiet` 下待领于空闲所有者的通知仍会随该所有者释放而消亡,与此前一致:释放时的取消会清空未领取的 inbox日志保留插入/取消这一对作为记录。[结算交付 note](2026-08-06-manager-owned-subagent-settlement-delivery.md) 承载这需要的离线信箱讨论。
对短命任务而言,完成究竟是延长运行中的轮次还是开启新轮次是一场真实竞态,因此没有哪份编写的 transcript 能同时容纳两种顺序。组装态覆盖断言结果;通道选择由单元测试钉住。
还残留一个微任务窗口:结算若落在轮次循环最后一次检查 inbox 之后、driver 提交 idle 相位之前,读到的仍是 `status === 'running'`,于是走注入且无人唤醒。改用 steer 也堵不上——`wakeDriver()` 只为 maintenance 与取消后的相位设置 latch不为「最后一次检查与自身退休之间」的 driver 设置。要堵上它需要 `agent-loop` 在最后一次领取之前就发布退休状态,那属于核心 agent 的决策,而非交付策略。