diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index 511aa0f0c3..68e6475a11 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.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 .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md -2026-06-20-generic-long-running-tool-runtime.md: 457eaac7de92ea37287803349672271148d59185 -2026-06-20-generic-long-running-tool-runtime.zh.md: 9a86c4cf2b49973212adfd4b9acdb54150af01d1 +2026-06-20-generic-long-running-tool-runtime.md: c34d0708ee88b94fcf9b9294fe002a9c3e081cfd +2026-06-20-generic-long-running-tool-runtime.zh.md: 311fd271ccbd139f7d82270eb56e2471a34a4bb3 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 457eaac7de..c34d0708ee 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -77,7 +77,7 @@ A producer loaded without any controller would let callers start work they canno Stream reads share one task-scoped consuming cursor because the owning model is the intended reader. A UI or multiple independent readers need a separate non-consuming observation API; sharing this cursor would let readers consume one another's output. -The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent. +The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion delivers a logged message to the exact owner's session. A busy owner is injected; an idle owner is woken, under the bounded policy the [idle-owner wake decision](../feature/2026-08-11-background-task-completion-wakes-an-idle-owner.md) owns. The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task ` prefix and `task_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The task controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size. @@ -131,4 +131,4 @@ Unit coverage pins preflight atomicity, per-kind ids, output-limit validation an Bash commands and subagents share one id vocabulary, listing, notice format, prompt habit, and set of control tools. New long-running producers implement execution hooks instead of another registry and tool family. The [tool cookbook](../../../../docs/cookbook/adding-a-tool.md) points producers to this contract. -Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, completion notices do not wake idle agents, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs. +Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs. diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 9a86c4cf2b..311fd271cc 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -77,7 +77,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 流式读取共享一个任务作用域内的消费游标,因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API;共享该游标会让读取方彼此消费对方的输出。 -系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话注入一条已记录的 `context/message`;它会成为下一个请求的持久上下文,但不会唤醒空闲的 agent。 +系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-task-completion-wakes-an-idle-owner.md)负责。 当读取或等待交付终止任务、实时等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-tasks` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background task ` 前缀与 `task_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 @@ -131,4 +131,4 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。 -有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方,完成通知不会唤醒空闲 agent;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。 +有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。 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 index 858d68abb4..6099f7c815 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-background-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 +2026-08-11-background-task-completion-wakes-an-idle-owner.md: 16cbd7ee3772edfb237f269a972217b8a6ad51a3 +2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md: a09f8e9c99b3ca2e516c3e0de4727d4bce3008d6 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 index e9c437814f..16cbd7ee37 100644 --- 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 @@ -70,3 +70,5 @@ A spent budget is restored only by user input. An unattended agent that exhausts A notice pending on an idle owner under `quiet` still dies with that owner's disposal, unchanged from before: the disposal cancel clears the unclaimed inbox and the log keeps the insert/cancel pair as the record. The [settlement delivery note](2026-08-06-manager-owned-subagent-settlement-delivery.md) owns the offline-mailbox discussion this would need. Whether a completion extends the running turn or opens a new one is a genuine race for short-lived tasks, so no authored transcript can hold both orders. Assembled coverage asserts the outcome; the lane choice is pinned in unit tests. + +One microtask window survives: a settlement landing after the turn loop's last inbox check but before the driver commits its idle phase still reads `status === 'running'`, so it injects and nothing wakes. Steering would not close it either — `wakeDriver()` latches only for maintenance and post-cancel phases, not for a driver between its final check and its own retirement. Closing it needs an `agent-loop` boundary that publishes retirement before the last claim, which is a core-agent decision rather than a delivery-policy one. 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 index 390b576e4e..a09f8e9c99 100644 --- 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 @@ -70,3 +70,5 @@ Status: implemented 在 `quiet` 下待领于空闲所有者的通知仍会随该所有者释放而消亡,与此前一致:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。[结算交付 note](2026-08-06-manager-owned-subagent-settlement-delivery.md) 承载这需要的离线信箱讨论。 对短命任务而言,完成究竟是延长运行中的轮次还是开启新轮次是一场真实竞态,因此没有哪份编写的 transcript 能同时容纳两种顺序。组装态覆盖断言结果;通道选择由单元测试钉住。 + +还残留一个微任务窗口:结算若落在轮次循环最后一次检查 inbox 之后、driver 提交 idle 相位之前,读到的仍是 `status === 'running'`,于是走注入且无人唤醒。改用 steer 也堵不上——`wakeDriver()` 只为 maintenance 与取消后的相位设置 latch,不为「最后一次检查与自身退休之间」的 driver 设置。要堵上它需要 `agent-loop` 在最后一次领取之前就发布退休状态,那属于核心 agent 的决策,而非交付策略。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cfd6a7a614..614684db0e 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: ef9d8288822eee6428a74c3ec30569fcd4eed553 -config-catalog.zh.md: fece46a8a97308c6cfb465fa6aa1c0156f46c7a4 +config-catalog.md: 6f1f675634034285c8bdf4366f118786a1131d0d +config-catalog.zh.md: d6c960377e266b5d9715fe53f12e47cacf1fc9cb diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ef9d828882..6f1f675634 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2410,7 +2410,7 @@ export interface Config { export type CompletionDelivery = 'quiet' | 'wakeup' ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:31`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:32`](../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 fece46a8a9..d6c960377e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2411,7 +2411,7 @@ export interface Config { export type CompletionDelivery = 'quiet' | 'wakeup' ``` -来源:[`packages/tasks/tool-tasks/src/index.ts:31`](../packages/tasks/tool-tasks/src/index.ts) +来源:[`packages/tasks/tool-tasks/src/index.ts:32`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 1b52f7dcc0..73c56b9ca6 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -466,14 +466,17 @@ export class LocalTaskService extends TaskService { private cancelForTeardown(tasks: TrackedTask[], reason: string): void { for (const task of tasks) { if (isTerminal(task.status)) continue + // 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. This is + // decided before the producer runs: the force-failure below settles the + // record too, so a throwing cancel must not be the one path that + // announces an unreported completion into a disposing owner. + task.reported = true 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/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index d751335058..b407a06ea5 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -1,8 +1,9 @@ /** * Model-facing `task_output`, `task_list`, and `task_kill` tools over * `ctx.tasks`. Loading the plugin attaches the controller required by - * producers. It also injects unreported completions as durable context for the - * owner's next request; notices do not wake idle agents. + * producers. It also delivers unreported completions to the owning agent: + * injected into a busy owner's next step, or opening a turn on an idle one + * under the default `wakeup` delivery, bounded per owner. * @module @deepseek-ai/dsh-tool-tasks */ @@ -214,6 +215,11 @@ export function apply(ctx: Context, config: Config): void { if (waitDefault > waitCap) { throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`) } + // A budget is a count of turns. `Infinity` would leave the runaway chain this + // field exists to bound unbounded, and a fraction never names a turn at all. + if (!Number.isSafeInteger(wakeBudget)) { + throw new Error(`tool-tasks: maxConsecutiveWakes (${wakeBudget}) must be a whole number of turns`) + } 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. diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 8e40b444b4..bab7374334 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -128,6 +128,28 @@ describe('tool-tasks setup', () => { .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)') }) + it('rejects a wake budget that cannot bound anything', async () => { + // Reports the load outcome as text: a resolved fiber is not safely printable. + const loadWith = async (maxConsecutiveWakes: number): Promise => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + try { + await ctx.plugin(ToolTasks, { maxConsecutiveWakes }) + return 'loaded' + } catch (error: unknown) { + return String(error) + } + } + + // The field exists to bound runaway waking; a fractional budget counts + // nothing and an infinite one removes the bound it was configured for. + expect(await loadWith(Number.POSITIVE_INFINITY)).toContain('maxConsecutiveWakes') + expect(await loadWith(2.5)).toContain('maxConsecutiveWakes') + expect(await loadWith(1)).toBe('loaded') + }) + it('renders status lines with and without producer detail', () => { const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as TaskSnapshot expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]') @@ -607,6 +629,33 @@ describe('completion notice delivery', () => { expect(inject).not.toHaveBeenCalled() }) + it('neither wakes nor injects when the teardown cancel itself threw', async () => { + const { ctx } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const inject = vi.fn() + const followup = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) + ctx.tasks.start({ + kind: 'bash', + label: 'broken producer', + owner, + run: () => ({ + cancel() { throw new Error('cancel boom') }, + done: new Promise(() => {}), + }), + }) + + // The registry force-fails the record instead of deadlocking. That path + // settles the task too, so it must claim the report as the ordinary + // teardown cancel does — otherwise a throwing producer is all it takes to + // spend a model request on an owner being destroyed. + await disposeAgentScope(owner) + await tick() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) + 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()