mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
test(tasks): fence the background integration test on an idle owner
The rewritten test tolerated the interleaving where a fast command settles before the running turn's next pre-step claim. The notice is then folded into a step whose scripted reply is final, the turn closes with an empty next-step inbox, and the collection entries are never reached — a real timeout, not a tolerated ordering. The command now blocks on a sentinel the test creates only after the agent has gone idle, so the wake is the only path that can deliver the notice, and the test asserts exactly two turns. Also apply the review's smaller points: key the wake budget by Agent rather than object, register the budget-refill listener only under wakeup delivery, pin the schema default and rejection like reportDelivery does, record the retirement-window stranding as a Known Limitation, and cross-link the partial supersession both ways.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 和已取消未收敛三种相位。缺的是「一次完成走哪条通道」这一策略选择,以及该选择所需的界。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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<object, number>()
|
||||
const spentWakes = new WeakMap<Agent, number>()
|
||||
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`)
|
||||
}
|
||||
// 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<ToolExecution, number>()
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
|
||||
@@ -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<string> => {
|
||||
|
||||
Reference in New Issue
Block a user