From d78b796a51bd2e9092211fa563801dae27192359 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 19:36:49 +0800 Subject: [PATCH 1/5] feat(tasks): wake an idle owner when a background task completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...k-completion-wakes-an-idle-owner.i18n.yaml | 6 + ...und-task-completion-wakes-an-idle-owner.md | 72 ++++++++ ...-task-completion-wakes-an-idle-owner.zh.md | 72 ++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 20 ++- docs/config-catalog.zh.md | 20 ++- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 15 +- docs/subsystems/tasks.zh.md | 15 +- .../bash/tool-bash/tests/integration.spec.ts | 29 ++-- packages/tasks/tasks-local/README.i18n.yaml | 4 +- packages/tasks/tasks-local/README.md | 2 +- packages/tasks/tasks-local/README.zh.md | 2 +- packages/tasks/tasks-local/src/index.ts | 46 ++--- .../tasks/tasks-local/tests/tasks.spec.ts | 50 +++++- packages/tasks/tasks/src/index.ts | 12 +- packages/tasks/tasks/src/types.ts | 7 +- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 13 +- packages/tasks/tool-tasks/README.zh.md | 13 +- packages/tasks/tool-tasks/src/index.ts | 52 +++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 157 ++++++++++++++++-- 25 files changed, 531 insertions(+), 96 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.i18n.yaml new file mode 100644 index 0000000000..858d68abb4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.md b/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.md new file mode 100644 index 0000000000..e9c437814f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md b/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md new file mode 100644 index 0000000000..390b576e4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md @@ -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 能同时容纳两种顺序。组装态覆盖断言结果;通道选择由单元测试钉住。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7619aa7e91..80447f0318 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 36ad950428..cd92d41ce7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index adfa319e4d..41db6f9c13 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 2acd8e1b03..77696b7741 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e14bad2b7a..59d81478ed 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 81ced8b25d..91e0bf3ff5 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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) | diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index a73e8129c9..40a15cc5ac 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.i18n.yaml @@ -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 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index 2798eb2963..37807bc446 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -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) diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 8d4539514c..39014acb13 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -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) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 425fa0644f..0cd7e83bb2 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -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') diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml index 8b86718b22..869b5f1b05 100644 --- a/packages/tasks/tasks-local/README.i18n.yaml +++ b/packages/tasks/tasks-local/README.i18n.yaml @@ -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 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index 6f9e1bf252..cc2e8422c3 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -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. diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md index 5e62a8289e..81fc0a5b1e 100644 --- a/packages/tasks/tasks-local/README.zh.md +++ b/packages/tasks/tasks-local/README.zh.md @@ -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)`;一次结算也只会抵达其所有者所属组合注册的监听器。 diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 5a493fcdaa..1b52f7dcc0 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -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. diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 78fdd1f5af..56c3fa9181 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -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((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') diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 114e0a9dbf..ba5ce7eb2f 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -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 diff --git a/packages/tasks/tasks/src/types.ts b/packages/tasks/tasks/src/types.ts index 288805aa18..af7b747307 100644 --- a/packages/tasks/tasks/src/types.ts +++ b/packages/tasks/tasks/src/types.ts @@ -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 } diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index a605f3d8ce..8aaa3dd333 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-tasks/README.i18n.yaml @@ -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 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 76607351ab..f08ca3708d 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -18,7 +18,11 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` ## Completion notices -An unreported completion injects `background task (: