feat(tasks): wake an idle owner when a background task completes

Completion notices went through agent.inject(), which never reserves a
driver, so a task settling after its turn closed left the notice parked
until unrelated input woke the agent — while the same prompt told the
model not to poll for it.

An unreported completion now picks its lane from the owner's state: a
busy owner is injected as before, an idle owner is woken with
followup(). This adopts the delivery rule the subagent continuation
manager already ships. maxConsecutiveWakes bounds the self-exciting
chain and is reset by user-authored input; completionDelivery: quiet
restores the old lane for deterministic transcripts.

Teardown cancellation now claims the terminal report the way kill()
already does, so an owner being destroyed is never woken, and settle()
announces completion last so a reporter that opens a turn synchronously
sees a committed record.
This commit is contained in:
Yichen Jiang
2026-08-11 19:36:49 +08:00
parent 1d12ae62e7
commit d78b796a51
25 changed files with 531 additions and 96 deletions

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: e9c437814fe1b75f57e0b7470a1ba9c699532ac4
2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md: 390b576e4eacfd266d5b19d7af33cc475640f1da

View File

@@ -0,0 +1,72 @@
# 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.
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.

View File

@@ -0,0 +1,72 @@
# 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)`——同一段提示词并不鼓励的阻塞等待。
交付机制从来不是障碍。自[统一 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 能同时容纳两种顺序。组装态覆盖断言结果;通道选择由单元测试钉住。

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 docs/config-catalog.md
config-catalog.md: 36ad950428f02f0147c465b633aff8e9ccd4c002
config-catalog.zh.md: adfa319e4d753e03993e607d333df46745c20370
config-catalog.md: cd92d41ce7dd4966fa54bcd27dd554674ef05cc5
config-catalog.zh.md: 41db6f9c1310b7a83ba785f2f350fbca12fbc3ba

View File

@@ -2379,16 +2379,32 @@ Source: [`packages/subagent/tool-subagent-report/src/index.ts:27`](../packages/s
Requires: `tools` · `tasks` · `systemPrompt`
```ts config-catalog
/** Configures bounded `task_output` waits. */
/** Configures bounded `task_output` waits and completion-notice delivery. */
export interface Config {
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
waitTimeoutMs?: number
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
maxWaitTimeoutMs?: number
/** Whether a completion opens a turn on an idle owner (default `wakeup`). */
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 task whose
* completion wakes it again.
*/
maxConsecutiveWakes?: number
}
/**
* How an unreported completion reaches an owner that is already idle: `wakeup`
* opens a turn for it, `quiet` leaves it pending until something else wakes the
* owner. A busy owner is injected either way.
*/
export type CompletionDelivery = 'quiet' | 'wakeup'
```
Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts)
Source: [`packages/tasks/tool-tasks/src/index.ts:31`](../packages/tasks/tool-tasks/src/index.ts)
## `@deepseek-ai/dsh-tool-todo`

View File

@@ -2380,16 +2380,32 @@ export interface Config {
需要:`tools` · `tasks` · `systemPrompt`
```ts config-catalog
/** Configures bounded `task_output` waits. */
/** Configures bounded `task_output` waits and completion-notice delivery. */
export interface Config {
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
waitTimeoutMs?: number
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
maxWaitTimeoutMs?: number
/** Whether a completion opens a turn on an idle owner (default `wakeup`). */
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 task whose
* completion wakes it again.
*/
maxConsecutiveWakes?: number
}
/**
* How an unreported completion reaches an owner that is already idle: `wakeup`
* opens a turn for it, `quiet` leaves it pending until something else wakes the
* owner. A busy owner is injected either way.
*/
export type CompletionDelivery = 'quiet' | 'wakeup'
```
来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts)
来源:[`packages/tasks/tool-tasks/src/index.ts:31`](../packages/tasks/tool-tasks/src/index.ts)
## `@deepseek-ai/dsh-tool-todo`

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 docs/event-producer-consumer.md
event-producer-consumer.md: e14bad2b7a0f604f101281e271c4250a60b8c8eb
event-producer-consumer.zh.md: 81ced8b25dc43a4f0d37b73ea954b1e4a997bdf9
event-producer-consumer.md: 59d81478edca9ebc83368157ab76ff24fca20ec2
event-producer-consumer.zh.md: 91e0bf3ff5b20ffd68c641992ed98f5e2c609697

View File

@@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |

View File

@@ -13,7 +13,7 @@
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |

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 docs/subsystems/tasks.md
tasks.md: 2798eb2963e83d42321a343d3c0a6a2bb658be41
tasks.zh.md: 8d4539514cb4bc654dd86031d51417c4f79f8982
tasks.md: 37807bc446f607c3e1635a49432670e86760f2a4
tasks.zh.md: 39014acb13f6051431a40ae084c1e03298ae8993

View File

@@ -97,7 +97,7 @@ interface TaskOutcome {
## Consumer views
Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state.
Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service.
```ts type-equiv
/**
@@ -128,8 +128,11 @@ interface TaskSnapshot {
/** Epoch ms when the task settled; absent while `running`/`stopping`. */
finishedAt?: number
/**
* True when a kill, read, or wait has reported or committed to report the
* terminal state. Completion reporters suppress redundant notices when set.
* True when a kill, read, wait, or teardown cancel has reported or committed
* to report the terminal state. Completion reporters suppress redundant
* notices when set. Teardown claims it because the owner or service being
* destroyed leaves no reader: a reporter that opens a turn on notice would
* otherwise spend a model request per teardown layer.
*/
reported: boolean
}
@@ -169,9 +172,9 @@ Abstract background task registry. Subclass, implement the abstract methods, and
Implementations must honor these semantics:
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record.
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.
- Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
- Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome.
- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. 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.
- start refuses work while no attached task controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
```ts cordis-catalog
@@ -282,5 +285,5 @@ abstract attachController(name: string): () => void
Types: [Agent](core.md)
Source: [`packages/tasks/tasks/src/index.ts:58`](../../packages/tasks/tasks/src/index.ts)
Source: [`packages/tasks/tasks/src/index.ts:62`](../../packages/tasks/tasks/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -97,7 +97,7 @@ interface TaskOutcome {
## 消费方视图
快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知。
快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入
```ts type-equiv
/**
@@ -128,8 +128,11 @@ interface TaskSnapshot {
/** Epoch ms when the task settled; absent while `running`/`stopping`. */
finishedAt?: number
/**
* True when a kill, read, or wait has reported or committed to report the
* terminal state. Completion reporters suppress redundant notices when set.
* True when a kill, read, wait, or teardown cancel has reported or committed
* to report the terminal state. Completion reporters suppress redundant
* notices when set. Teardown claims it because the owner or service being
* destroyed leaves no reader: a reporter that opens a turn on notice would
* otherwise spend a model request per teardown layer.
*/
reported: boolean
}
@@ -169,9 +172,9 @@ Abstract background task registry. Subclass, implement the abstract methods, and
Implementations must honor these semantics:
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record.
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.
- Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
- Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome.
- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. 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.
- start refuses work while no attached task controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
```ts cordis-catalog
@@ -282,5 +285,5 @@ abstract attachController(name: string): () => void
Types: [Agent](core.md)
Source: [`packages/tasks/tasks/src/index.ts:58`](../../packages/tasks/tasks/src/index.ts)
Source: [`packages/tasks/tasks/src/index.ts:62`](../../packages/tasks/tasks/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -174,7 +174,7 @@ describe('bash tool through the agent loop', () => {
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
it('background: start ack → pending completion notice → task_output collects it', async () => {
it('background: start ack → completion continues the agent → task_output collects it', async () => {
// The task id is deterministic (a fresh LocalTaskService counts per kind from 1),
// so the script can name `bash-1` without threading a generated id.
const adapter = new MockAdapter([
@@ -193,28 +193,29 @@ describe('bash tool through the agent loop', () => {
expect(firstResult.data.message.content[0].isError).toBe(false)
expect(resultText(firstResult)).toBe('started background task bash-1')
// The task settles on its own; the tool-tasks notice listener injects a
// pending next-step message without waking the idle agent.
// No second user message. Settlement carries the notice into a turn on its
// own, and that turn collects the output. Whether it extends the running
// turn or wakes the idle agent depends on when the command exits, so this
// waits on the durable outcome rather than on a turn boundary; the lane
// choice itself is pinned in the tool-tasks unit tests.
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
e.type === 'user/message' && e.data.source.kind === 'plugin'
await pollUntil(() => agent.inbox.nextStep.some(message => message.source.kind === 'plugin'))
const pendingNotice = agent.inbox.nextStep.find(message => message.source.kind === 'plugin')!
expect(pendingNotice.content.some(
const lastResultText = (): string => {
const found = events(agent).findLast(event => event.type === 'tool/result')
return found === undefined ? '' : resultText(found)
}
await pollUntil(() => events(agent).some(isNotice) && lastResultText().includes('bg-ok'))
const notice = events(agent).find(isNotice)!
expect(notice.data.content.some(
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
)).toBe(true)
expect(pendingNotice.source).toEqual({
expect(notice.data.source).toEqual({
kind: 'plugin',
plugin: 'tool-tasks',
form: 'notice',
summary: 'bash echo bg-ok [status: completed, exit code: 0]',
})
// The next turn first admits that notice as user/message, then collects
// the output through the generic task tool.
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const notice = events(agent).find(isNotice)!
expect(notice.data).toEqual(pendingNotice)
const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.message.content[0].isError).toBe(false)
expect(resultText(readResult)).toContain('bg-ok')

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 packages/tasks/tasks-local/README.md
README.md: 6f9e1bf2524d7db0aff8dac0a43c86e82a20fbab
README.zh.md: 5e62a8289e9c08ac9d745d576d1893b71bcb2f7b
README.md: cc2e8422c367eeacfc5fc504298ecd6bfeae4c67
README.zh.md: 81fc0a5b1e12b2b15705370bb0748b1733b312aa

View File

@@ -10,7 +10,7 @@ Tasks belong to their owner and backend, not the producer tool fiber, so produce
Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion reporters do not duplicate notices.
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, releases waiters, and notifies listeners once with per-listener containment. Pending waits mark the task reported before listeners run so completion reporters do not duplicate notices, and a teardown cancel marks it for the same reason: nothing will read a notice addressed to an owner being destroyed. Completion is the last thing a settlement announces, after the record is committed and the visible-set change is published, because a reporter may open a model turn synchronously and every other observer must already have seen the settled record.
Controllers and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background tasks unavailable: no task controller serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered.

View File

@@ -10,7 +10,7 @@
服务 dispose 会关闭监听器、取消所有存活任务、等待其记录完成,并从仍存活的所有者 scope 中分离 effect。如果销毁期间的取消操作抛出异常服务会强制将记录标为失败并警告工作可能成为孤立工作而不会死锁。取消操作已返回但 `done` 始终未结算时,系统无法将其与缓慢停止区分开,销毁过程可能因此停滞。
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,只通知监听器一次;各监听器的故障会单独隔离,随后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知。
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,随后释放等待方,再只通知监听器一次;各监听器的故障会单独隔离。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知;销毁时的取消出于同样的理由也会标记:面向正在被销毁的所有者的通知不会有人读到。完成是一次结算最后才宣布的事情,排在记录提交与可见集变更发布之后,因为报告方可能同步开启一个模型轮次,而该结算的其他所有观察者都必须已经看到已结算的记录
控制器与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何控制器的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background tasks unavailable: no task controller serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。

View File

@@ -224,11 +224,12 @@ export class LocalTaskService extends TaskService {
}
const onAbort = (): void => {
task.waitResolvers.delete(onSettled)
// A settled task cannot reach here: settlement releases every waiter
// before it announces completion, and each released waiter detaches
// this listener in the same synchronous span, so nothing that reacts
// to a settlement can abort a wait the settlement already owed.
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
resolve()
} else if (isTerminal(task.status)) {
// Settlement suppressed the notice for this waiter; deliver it.
resolve()
} else {
uncount()
reject(new Error('wait aborted'))
@@ -364,9 +365,12 @@ export class LocalTaskService extends TaskService {
}
/**
* Record the first terminal outcome, notify contained listeners, and release
* waiters. First-wins preserves a teardown force-failure against late producer
* settlement. Pending waits mark the task reported before listeners run.
* Record the first terminal outcome, release waiters, then announce
* completion. First-wins preserves a teardown force-failure against late
* producer settlement. Pending waits mark the task reported before listeners
* run. Completion is announced last because a reporter may open a model turn
* synchronously: every other observer of this settlement must already have
* seen the committed record.
*/
private settle(task: TrackedTask, outcome: TaskOutcome): void {
if (isTerminal(task.status)) return
@@ -375,24 +379,23 @@ export class LocalTaskService extends TaskService {
task.output = outcome.output
task.finishedAt = Date.now()
if (task.waiters > 0) task.reported = true
if (!this.listenersClosed) {
const snapshot = this.snapshot(task)
for (const listener of this.listenersFor(task.owner)) {
try {
const returned = listener(snapshot, task.owner)
void Promise.resolve(returned).catch((error: unknown) => {
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
})
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
}
}
}
const snapshot = this.snapshot(task)
const waitResolvers = [...task.waitResolvers]
task.waitResolvers.clear()
for (const resolveWait of waitResolvers) resolveWait()
task.markSettled()
this.notifyChanged(task.owner)
if (this.listenersClosed) return
for (const listener of this.listenersFor(task.owner)) {
try {
const returned = listener(snapshot, task.owner)
void Promise.resolve(returned).catch((error: unknown) => {
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
})
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
}
}
}
/**
@@ -466,6 +469,11 @@ export class LocalTaskService extends TaskService {
try {
task.cancel(reason)
task.status = 'stopping'
// Teardown cancellation is a kill without a caller, so it claims the
// terminal report the same way `kill()` does. Nothing will read a
// notice for a task whose owner or service is being destroyed, and a
// waking reporter would spend a model request per teardown layer.
task.reported = true
// Teardown reaches settlement only after the producer releases, which a
// slow stop can defer; announcing the transition here is what keeps an
// observer from showing `running` for that whole window.

View File

@@ -444,8 +444,9 @@ describe('LocalTaskService.wait', () => {
const ctx = await harness()
const controller = new AbortController()
const seen: TaskSnapshot[] = []
// The listener aborts after settlement has assigned delivery to this waiter
// but before its resolve microtask; the waiter must still receive the result.
// The listener aborts after settlement released this waiter but before its
// resolve microtask runs. Releasing waiters ahead of the announcement is
// what makes that abort harmless; this is the guard on that ordering.
ctx.tasks.onTaskDone((snapshot) => {
seen.push(snapshot)
controller.abort()
@@ -593,6 +594,51 @@ describe('LocalTaskService owner cleanup', () => {
expect(ctx.tasks.list(owner)).toEqual([])
})
it('publishes the settled visible set before announcing completion', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const p = producer({ owner })
ctx.tasks.start(p.spec)
// Registered after start so only the settlement's notifications are ordered.
const order: string[] = []
ctx.tasks.onTasksChanged(() => void order.push('changed'))
ctx.tasks.onTaskDone(() => void order.push('done'))
p.settle({ status: 'completed' })
await tick()
// A completion reporter may open a turn synchronously. Announcing before
// the visible set is published would let a client render that turn while
// its task row still reads `running`.
expect(order).toEqual(['changed', 'done'])
})
it('reports a teardown-cancelled record so completion reporters stay quiet', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
let settle!: (outcome: TaskOutcome) => void
ctx.tasks.start({
kind: 'subagent',
label: 'long research',
owner,
run: () => ({
cancel() { settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
}),
})
// Observers still receive the terminal record; the report bit is what
// keeps a notice reporter from addressing an owner being destroyed.
await disposeAgentScope(owner)
expect(seen).toHaveLength(1)
expect(seen[0]?.reported).toBe(true)
})
it('attaches one cleanup per owner and drains all owned tasks with the scope', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')

View File

@@ -41,12 +41,16 @@ declare module '@deepseek-ai/cordis' {
* Implementations must honor these semantics:
* - Registrations outlive producer and controller fibers. Owner and
* service disposal cancel live work and await compliant producers; a
* throwing teardown cancel force-fails only the record.
* throwing teardown cancel force-fails only the record. Teardown
* cancellation also marks the record reported, because a record its owner
* is being destroyed for has no reader left.
* - Owned-task access is fenced by the owner's session id. Ids are
* predictable, so authorization — not secrecy — is the boundary.
* - Settlement is first-wins: one terminal record, one round of contained
* listener notification, and released waiters, even against a late
* producer outcome.
* - Settlement is first-wins: one terminal record, released waiters, and one
* round of contained listener notification, even against a late producer
* outcome. 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.
* - {@link start} refuses work while no attached task controller serves the
* spec's owner, so a producer cannot start work that owner cannot collect
* or stop. One registry serves every composition in the process, so this

View File

@@ -118,8 +118,11 @@ export interface TaskSnapshot {
/** Epoch ms when the task settled; absent while `running`/`stopping`. */
finishedAt?: number
/**
* True when a kill, read, or wait has reported or committed to report the
* terminal state. Completion reporters suppress redundant notices when set.
* True when a kill, read, wait, or teardown cancel has reported or committed
* to report the terminal state. Completion reporters suppress redundant
* notices when set. Teardown claims it because the owner or service being
* destroyed leaves no reader: a reporter that opens a turn on notice would
* otherwise spend a model request per teardown layer.
*/
reported: boolean
}

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 packages/tasks/tool-tasks/README.md
README.md: 76607351aba6b482817b890056254da737e7b9ed
README.zh.md: 8d27510aa3be3b87ca167d88d85fc374fb1eb4f1
README.md: f08ca3708d3ecc0dd1b5bfb3286207f23cb87898
README.zh.md: 15a7e6ba41af1011a9760e1d332a01f44c0a8794

View File

@@ -18,7 +18,11 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`
## Completion notices
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice.
An unreported completion delivers `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` to the exact owner. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice, as does the teardown cancel that drains an owner or the service.
Which lane carries it depends on what the owner is doing. A busy owner is injected: the notice joins the next-step inbox, and the turn cannot close while that inbox holds it, so several tasks settling together cost one step rather than one turn each. An idle owner is instead woken with a follow-up turn, because a pending notice nothing claims is a completion the model never learns about. `completionDelivery: quiet` keeps the injection lane for idle owners too, which is what a deterministic transcript needs.
Waking is bounded. Each owner may open `maxConsecutiveWakes` turns this way 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 task whose completion wakes it again. Notices this plugin queued never refill the budget they spent.
One host registry may carry several mounts of this plugin — one per agent preset. The registry routes each settlement to the listeners the owner's scope chain reaches, so a mount under one preset never sees another preset's agents and an agent reads exactly one notice per completion however many presets are mounted. The same routing decides which agents this mount's controller serves: an agent whose composition loads no `tool-tasks` cannot start background work at all.
@@ -28,6 +32,8 @@ One host registry may carry several mounts of this plugin — one per agent pres
|---|---|---|
| `waitTimeoutMs` | `30000` | wait used when `wait: true` omits `timeout_ms` |
| `maxWaitTimeoutMs` | `600000` | cap for model-supplied waits |
| `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 |
A default above the cap fails at load.
@@ -75,7 +81,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.
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.
#### KV Cache effect
@@ -83,6 +89,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Completion notices do not wake idle agents** — callers needing an immediate result must use `task_output`.
- **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 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.
- **Stream reads are single-consumer** — independent observers need another runtime API.
- **Unowned tasks have no session fence** — external callers must supply policy or avoid them.

View File

@@ -18,7 +18,11 @@
## 完成通知
一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告并抑制重复通知。
一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 交付给确切所有者。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail因此通知仍可操作。kill 或针对已终止任务的 read/wait 会把交付标为已报告并抑制重复通知;排空 owner 或服务的 teardown 取消同样如此
由哪条通道承载取决于所有者当时在做什么。繁忙的所有者走注入:通知进入 next-step inbox而该 inbox 尚有内容时 turn 无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的所有者则被 follow-up 唤醒,因为无人领取的待发通知等于模型永远不会知道的完成。`completionDelivery: quiet` 让空闲所有者也留在注入通道上,确定性 transcript 需要的正是这一点。
唤醒是有界的。每个所有者最多可通过唤醒开启 `maxConsecutiveWakes` 轮,此后的通知降级为注入;领取任何用户撰写的消息都会恢复该预算。设界是因为这条链会自激:被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者。本插件自己排队的通知永远不会补充它刚花掉的预算。
一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent无论挂载了多少 preset一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制器服务哪些 agent组合中未加载 `tool-tasks` 的 agent 根本无法启动后台工作。
@@ -28,6 +32,8 @@
|---|---|---|
| `waitTimeoutMs` | `30000` | `wait: true` 省略 `timeout_ms` 时使用的等待时间 |
| `maxWaitTimeoutMs` | `600000` | 模型所给等待时间的上限 |
| `completionDelivery` | `wakeup` | `wakeup` 为空闲所有者开启一轮;`quiet` 让通知继续待领 |
| `maxConsecutiveWakes` | `3` | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入 |
默认值高于上限时,插件会在加载时失败。
@@ -75,7 +81,7 @@ Track every background task id you start. You are notified in-session when a tas
#### Token 影响
结果与通知在压缩compaction前保留于父级历史。流读取不会重复已消费的输出生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。
结果与通知在压缩compaction前保留于父级历史。流读取不会重复已消费的输出生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。`wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量按所有者由 `maxConsecutiveWakes` 封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
#### KV Cache 影响
@@ -83,6 +89,7 @@ Track every background task id you start. You are notified in-session when a tas
## 已知限制与暂缓事项
- **完成通知不会唤醒空闲 agent**:需要立即获得结果的调用方必须使用 `task_output`
- **已花掉的唤醒预算不会随时间恢复**:只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知
- **待领于空闲所有者的通知无法在该所有者释放后存活**:释放时的取消会清空未领取的 inbox日志保留插入/取消这一对作为记录。
- **流读取只有单一消费方**:独立观察者需要另一套运行时 API。
- **无 owner 的任务没有会话隔离**:外部调用方必须提供策略或避开这些任务。

View File

@@ -15,21 +15,40 @@ import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-a
import { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-agent'
export const name = 'tool-tasks'
export const inject = ['tools', 'tasks', 'systemPrompt']
/** Configures bounded `task_output` waits. */
/**
* How an unreported completion reaches an owner that is already idle: `wakeup`
* opens a turn for it, `quiet` leaves it pending until something else wakes the
* owner. A busy owner is injected either way.
*/
export type CompletionDelivery = 'quiet' | 'wakeup'
/** Configures bounded `task_output` waits and completion-notice delivery. */
export interface Config {
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
waitTimeoutMs?: number
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
maxWaitTimeoutMs?: number
/** Whether a completion opens a turn on an idle owner (default `wakeup`). */
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 task whose
* completion wakes it again.
*/
maxConsecutiveWakes?: number
}
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),
})
/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
@@ -185,9 +204,21 @@ function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: str
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
// Turns this plugin opened on each owner since that owner last consumed
// human input. Keyed by the exact Agent, so a same-session replacement
// starts with a full budget.
const spentWakes = new WeakMap<object, number>()
if (waitDefault > waitCap) {
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
}
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.
if (message.source.kind === 'user') spentWakes.delete(agent)
})
const outputLimits = new WeakMap<ToolExecution, number>()
ctx.on('tools/pre-execute', (exec, next) => {
@@ -227,16 +258,18 @@ export function apply(ctx: Context, config: Config): void {
})
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
// Delivery targets the exact lifecycle owner. The notice waits in its
// next-step inbox until another step claims it; disposal before that
// boundary discards it with the owner.
// A busy owner is injected: the notice waits in its next-step inbox, which
// the turn cannot close over, so tasks settling together cost one step. An
// idle owner is woken instead, because an unclaimed notice is a completion
// the model never learns about. Either way, disposal before the claim
// discards it with the owner, and teardown settlements arrive `reported`.
//
// The registry routes each settlement to the listeners its owner's scope
// chain reaches, so a mount under one preset never sees another preset's
// agents; this listener owns delivery, not the choice of whom to deliver to.
ctx.tasks.onTaskDone((snapshot, owner) => {
if (snapshot.reported || owner === undefined) return
owner.inject(createUserMessage({
const message = createUserMessage({
content: [{
type: 'text',
text: fitCompletionNotice(snapshot),
@@ -247,7 +280,14 @@ export function apply(ctx: Context, config: Config): void {
form: 'notice',
summary: completionSummary(snapshot),
},
}))
})
const spent = spentWakes.get(owner) ?? 0
if (delivery === 'wakeup' && owner.status === 'idle' && spent < wakeBudget) {
spentWakes.set(owner, spent + 1)
owner.followup(message)
return
}
owner.inject(message)
})
ctx.tools.register(defineTool({

View File

@@ -3,8 +3,9 @@ import { Context } from '@deepseek-ai/cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import { TaskId } from '@deepseek-ai/dsh-tasks'
@@ -16,6 +17,7 @@ import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
const testToolSignal = new AbortController().signal
const agentRegistryDisposers = new WeakMap<Agent, () => void>()
const agentScopeFibers = new WeakMap<Agent, { dispose: () => Promise<void> }>()
async function setup(config: ToolTasks.Config = {}) {
const ctx = new Context()
@@ -27,20 +29,31 @@ async function setup(config: ToolTasks.Config = {}) {
return { ctx, agentsFiber, toolsFiber }
}
/** The delivery surface a completion notice may reach on a fake owner. */
interface FakeDelivery {
inject?: (...args: unknown[]) => void
followup?: (...args: unknown[]) => void
/** Defaults to `running`, the lane that never wakes, so notice-content tests pin one lane. */
status?: 'idle' | 'running'
}
/**
* A fake agent with the shared agent/session identity, registered in
* `ctx.agents` with a dedicated lifecycle scope.
*/
function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Agent {
const scopeFiber = ctx.plugin(() => {})
const id = SessionId(sessionId)
const agent = {
id,
ctx: scopeFiber.ctx,
inject,
inject: delivery.inject ?? (() => {}),
followup: delivery.followup ?? (() => {}),
status: delivery.status ?? 'running',
session: { id, header: { version: 0, id, createdAt: 0 } },
} as unknown as Agent
agentRegistryDisposers.set(agent, ctx.agents.register(agent))
agentScopeFibers.set(agent, scopeFiber)
return agent
}
@@ -50,6 +63,13 @@ function detachAgent(agent: Agent): void {
dispose()
}
/** Dispose the agent's own lifecycle scope, which is what drains its owned tasks. */
async function disposeAgentScope(agent: Agent): Promise<void> {
const fiber = agentScopeFibers.get(agent)
if (fiber === undefined) throw new Error(`missing scope fiber for agent "${agent.id}"`)
await fiber.dispose()
}
/** A controllable producer start-spec (settle `done` on demand, record cancels). */
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
let settle!: (outcome: TaskOutcome) => void
@@ -81,6 +101,16 @@ function text(result: { content: { type: string; text?: string }[] }): string {
const tick = () => new Promise<void>(r => setTimeout(r, 0))
/** Start and settle `count` owned tasks one at a time, letting each notice land. */
async function settleTasks(ctx: Context, owner: Agent, count: number): Promise<void> {
for (let i = 0; i < count; i += 1) {
const p = producer({ owner })
ctx.tasks.start(p.spec)
p.settle({ status: 'completed' })
await tick()
}
}
describe('tool-tasks setup', () => {
it('attaches the task controller on load and detaches it with the fiber', async () => {
const { ctx, toolsFiber } = await setup()
@@ -494,11 +524,112 @@ describe('completion notices across scoped mounts', () => {
})
})
describe('completion notice delivery', () => {
it('opens a turn on an idle owner when a task settles', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const followup = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
const p = producer({ owner, label: 'pnpm test' })
ctx.tasks.start(p.spec)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(followup).toHaveBeenCalledTimes(1)
expect(inject).not.toHaveBeenCalled()
})
it('never wakes an idle owner under quiet delivery', async () => {
const { ctx } = await setup({ completionDelivery: 'quiet' })
const inject = vi.fn()
const followup = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
const p = producer({ owner })
ctx.tasks.start(p.spec)
p.settle({ status: 'completed' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
expect(followup).not.toHaveBeenCalled()
})
it('degrades to injection once the consecutive wake budget is spent', async () => {
const { ctx } = await setup({ maxConsecutiveWakes: 2 })
const inject = vi.fn()
const followup = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
await settleTasks(ctx, owner, 3)
// A woken turn that starts another task is the self-exciting case: the
// budget stops the chain, and the notice still reaches the inbox.
expect(followup).toHaveBeenCalledTimes(2)
expect(inject).toHaveBeenCalledTimes(1)
})
it('restores the wake budget when the owner claims a user message', async () => {
const { ctx } = await setup({ maxConsecutiveWakes: 1 })
const inject = vi.fn()
const followup = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
await settleTasks(ctx, owner, 2)
expect(followup).toHaveBeenCalledTimes(1)
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
message: createUserMessage({ content: [{ type: 'text', text: 'carry on' }], source: { kind: 'user' } }),
turn: 1,
})
await settleTasks(ctx, owner, 1)
expect(followup).toHaveBeenCalledTimes(2)
})
it('neither wakes nor injects into an owner its own teardown is draining', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const followup = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
let settle!: (outcome: TaskOutcome) => void
ctx.tasks.start({
kind: 'bash',
label: 'sleep 60',
owner,
run: () => ({
cancel() { settle({ status: 'killed' }) },
done: new Promise<TaskOutcome>((res) => { settle = res }),
}),
})
// Disposal cancels and settles the owned task. Waking here would spend a
// model request on an agent the host is destroying, once per tree layer.
await disposeAgentScope(owner)
await tick()
expect(followup).not.toHaveBeenCalled()
expect(inject).not.toHaveBeenCalled()
})
it('keeps the budget spent when the owner only claims plugin notices', async () => {
const { ctx } = await setup({ maxConsecutiveWakes: 1 })
const followup = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
await settleTasks(ctx, owner, 1)
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
message: createUserMessage({
content: [{ type: 'text', text: 'background task bash-1 finished' }],
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash' },
}),
turn: 1,
})
await settleTasks(ctx, owner, 1)
expect(followup).toHaveBeenCalledTimes(1)
})
})
describe('completion notices', () => {
it('injects a notice into the owning agent when an unreported task settles', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const owner = fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner, label: 'pnpm test' })
ctx.tasks.start(p.spec)
@@ -521,7 +652,7 @@ describe('completion notices', () => {
it('preserves task ids and collection guidance in bounded completion notices', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const owner = fakeAgent(ctx, 'sess-1', { inject })
const first = producer({
owner,
kind: 'subagent',
@@ -574,7 +705,7 @@ describe('completion notices', () => {
prior.settle({ status: 'completed' })
}
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const owner = fakeAgent(ctx, 'sess-1', { inject })
const target = producer({
owner,
kind: 'pty-send',
@@ -595,7 +726,7 @@ describe('completion notices', () => {
it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const owner = fakeAgent(ctx, 'sess-1', { inject })
const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
ctx.tasks.start(tiny.spec)
@@ -616,7 +747,7 @@ describe('completion notices', () => {
it('suppresses the notice for a task the model already killed', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const owner = fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner })
ctx.tasks.start(p.spec)
@@ -629,7 +760,7 @@ describe('completion notices', () => {
it('suppresses the notice when a wait returned the terminal state', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const owner = fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner, kind: 'subagent' })
ctx.tasks.start(p.spec)
@@ -654,13 +785,13 @@ describe('completion notices', () => {
// terminal state, so the notice lands in the old owner's (detached)
// session instead of throwing or re-routing.
const oldInject = vi.fn()
const oldOwner = fakeAgent(ctx, 'shared', oldInject)
const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject })
const p = producer({ owner: oldOwner })
ctx.tasks.start(p.spec)
detachAgent(oldOwner)
const replacementInject = vi.fn()
fakeAgent(ctx, 'shared', replacementInject)
fakeAgent(ctx, 'shared', { inject: replacementInject })
p.settle({ status: 'completed' })
await tick()
@@ -671,7 +802,7 @@ describe('completion notices', () => {
it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
const { ctx } = await setup()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
const p = producer({ owner })
ctx.tasks.start(p.spec)
p.settle({ status: 'completed' })
@@ -684,7 +815,7 @@ describe('completion notices', () => {
it('keeps using the exact owner after the agent registry is gone', async () => {
const { ctx, agentsFiber } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', inject)
const owner = fakeAgent(ctx, 'sess-1', { inject })
// Settlement must not depend on a later registry lookup: the exact owner
// supplied at start remains the destination while its own scope is live.