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 6099f7c815..0f4b9e952f 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: 16cbd7ee3772edfb237f269a972217b8a6ad51a3 -2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md: a09f8e9c99b3ca2e516c3e0de4727d4bce3008d6 +2026-08-11-background-task-completion-wakes-an-idle-owner.md: cbebf1fbe82d47db4ba6e39b318d2e8ed8d89e17 +2026-08-11-background-task-completion-wakes-an-idle-owner.zh.md: b5f3cdd594b04370dcfe99d83775f34ab8b8115c 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 16cbd7ee37..cbebf1fbe8 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 @@ -10,6 +10,8 @@ English | [中文](2026-08-11-background-task-completion-wakes-an-idle-owner.zh. The gap was recorded as a limitation rather than reasoned about, so the fallback was `task_output(wait: true)` — the blocking wait the same prompt discourages. +This supersedes one fact of the [background-task runtime decision](../architecture/2026-06-20-generic-long-running-tool-runtime.md) — that completion never wakes an idle owner — and adds teardown as a `reported` setter. That note keeps every other task-runtime decision and is updated in place rather than replaced. + The delivery machinery was never the obstacle. `Agent.send(message, target, wakeup)` has covered the `target` × `wakeup` matrix since the [unified send decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md), and `wakeDriver()` already handles idle, maintenance, and cancelled-converging phases. The missing piece was the policy choice of which lane a completion takes, plus the bound that choice needs. ## Decision 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 a09f8e9c99..b5f3cdd594 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 @@ -10,6 +10,8 @@ Status: implemented 这个缺口被记为一条限制,而不是被推敲过,于是退路成了 `task_output(wait: true)`——同一段提示词并不鼓励的阻塞等待。 +本决策取代[后台任务运行时决策](../architecture/2026-06-20-generic-long-running-tool-runtime.md)中的一条事实——完成永不唤醒空闲所有者——并把 teardown 加为 `reported` 的置位方。那份 note 仍拥有其余全部任务运行时决策,因此就地更新而非替换。 + 交付机制从来不是障碍。自[统一 send 决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)起,`Agent.send(message, target, wakeup)` 就覆盖了 `target` × `wakeup` 矩阵,`wakeDriver()` 也已经处理 idle、maintenance 和已取消未收敛三种相位。缺的是「一次完成走哪条通道」这一策略选择,以及该选择所需的界。 ## 决策 diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 0cd7e83bb2..ad873a9ddc 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' @@ -174,11 +174,24 @@ describe('bash tool through the agent loop', () => { expect(resultText(toolResult)).toContain('[exit code: 9]') }) - it('background: start ack → completion continues the agent → task_output collects it', async () => { + it('background: start ack → completion wakes the idle agent → task_output collects it', async () => { + // The command blocks on a sentinel this test creates only after the agent + // has gone idle, so settlement cannot fold into the still-running turn. + // Without that fence a fast command can settle before step 2's pre-step + // claim, which folds the notice into a turn whose scripted reply is final: + // the turn then closes with an empty next-step inbox and the collection + // entries are never reached. + const dir = mkdtempSync(join(tmpdir(), 'dsh-bg-')) + dirs.push(dir) + const sentinel = join(dir, 'release') // 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([ - toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }), + toolCallResponse('call-1', 'bash', { + command: `while [ ! -f ${JSON.stringify(sentinel)} ]; do sleep 0.02; done; echo bg-ok`, + description: 'test command', + run_in_background: true, + }), textResponse('Started it in the background.'), toolCallResponse('call-2', 'task_output', { task_id: 'bash-1' }), textResponse('Background task finished.'), @@ -192,29 +205,34 @@ describe('bash tool through the agent loop', () => { const firstResult = findEvent(events(agent), 'tool/result') expect(firstResult.data.message.content[0].isError).toBe(false) expect(resultText(firstResult)).toBe('started background task bash-1') - - // 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. + // The turn closed with the task still running, so the notice cannot exist yet. const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind === 'plugin' + expect(events(agent).some(isNotice)).toBe(false) + + // Releasing the command now settles it against a provably idle owner. No + // second user message: the wake alone opens the turn that collects it. + writeFileSync(sentinel, '') 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')) + // Two turns: the user's, then the one the completion opened by itself. + expect(events(agent).filter(event => event.type === 'turn/start')).toHaveLength(2) + // The notice carries the gated command as its label, so this pins the id, + // the terminal status, and the producer identity; the verbatim notice text + // and its bounding are pinned in the tool-tasks unit tests. 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(notice.data.source).toEqual({ + const noticeText = notice.data.content + .filter(block => block.type === 'text').map(block => block.text).join('') + expect(noticeText).toContain('background task bash-1 (bash: ') + expect(noticeText).toContain('finished [status: completed, exit code: 0]') + expect(notice.data.source).toMatchObject({ kind: 'plugin', plugin: 'tool-tasks', form: 'notice', - summary: 'bash echo bg-ok [status: completed, exit code: 0]', }) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.message.content[0].isError).toBe(false) diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 8aaa3dd333..f5e7dbef20 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: f08ca3708d3ecc0dd1b5bfb3286207f23cb87898 -README.zh.md: 15a7e6ba41af1011a9760e1d332a01f44c0a8794 +README.md: a899fbaeb402096f523e230ee03d6f422d321810 +README.zh.md: d0d4b7bdc66d5261d914ae8380488c36c17a0f0a diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index f08ca3708d..a899fbaeb4 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -89,6 +89,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work +- **A settlement inside the driver's retirement window still strands its notice** — between the turn loop's last inbox check and the driver committing its idle phase the owner still reads as busy, so the notice is injected and nothing wakes. Steering has the same hole; closing it belongs to `agent-loop`. - **A spent wake budget is not restored by time** — only user-authored input refills it, so an unattended agent whose budget ran out collects its remaining notices on the next turn something else opens. - **A 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. diff --git a/packages/tasks/tool-tasks/README.zh.md b/packages/tasks/tool-tasks/README.zh.md index 15a7e6ba41..d0d4b7bdc6 100644 --- a/packages/tasks/tool-tasks/README.zh.md +++ b/packages/tasks/tool-tasks/README.zh.md @@ -89,6 +89,7 @@ Track every background task id you start. You are notified in-session when a tas ## 已知限制与暂缓事项 +- **落在 driver 退休窗口内的结算仍会让通知搁浅**:在轮次循环最后一次检查 inbox 与 driver 提交 idle 相位之间,所有者读起来仍是繁忙,因此通知走注入且无人唤醒。steer 有同样的洞;堵上它属于 `agent-loop`。 - **已花掉的唤醒预算不会随时间恢复**:只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知。 - **待领于空闲所有者的通知无法在该所有者释放后存活**:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。 - **流读取只有单一消费方**:独立观察者需要另一套运行时 API。 diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index b407a06ea5..c211d51bcf 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -16,7 +16,7 @@ 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' +import type { Agent } from '@deepseek-ai/dsh-agent' export const name = 'tool-tasks' export const inject = ['tools', 'tasks', 'systemPrompt'] @@ -211,7 +211,7 @@ export function apply(ctx: Context, config: Config): void { // 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() + const spentWakes = new WeakMap() if (waitDefault > waitCap) { throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`) } @@ -220,11 +220,14 @@ export function apply(ctx: Context, config: Config): void { 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. - if (message.source.kind === 'user') spentWakes.delete(agent) - }) + // Nothing spends the budget under quiet delivery, so nothing needs to refill it. + if (delivery === 'wakeup') { + 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() ctx.on('tools/pre-execute', (exec, next) => { diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index bab7374334..593a368f8b 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -128,6 +128,13 @@ describe('tool-tasks setup', () => { .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)') }) + it('defaults delivery to wakeup and rejects an unknown lane', () => { + expect(ToolTasks.Config({}).completionDelivery).toBe('wakeup') + expect(ToolTasks.Config({}).maxConsecutiveWakes).toBe(3) + expect(() => ToolTasks.Config({ completionDelivery: 'loud' as never })).toThrow() + expect(() => ToolTasks.Config({ maxConsecutiveWakes: 0 })).toThrow() + }) + 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 => {