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:
Yichen Jiang
2026-08-11 20:39:04 +08:00
parent 75b26988dc
commit fa349202c7
9 changed files with 59 additions and 25 deletions

View File

@@ -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)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/tasks/tool-tasks/README.md
README.md: f08ca3708d3ecc0dd1b5bfb3286207f23cb87898
README.zh.md: 15a7e6ba41af1011a9760e1d332a01f44c0a8794
README.md: a899fbaeb402096f523e230ee03d6f422d321810
README.zh.md: d0d4b7bdc66d5261d914ae8380488c36c17a0f0a

View File

@@ -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.

View File

@@ -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。

View File

@@ -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`)
}
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<ToolExecution, number>()
ctx.on('tools/pre-execute', (exec, next) => {

View File

@@ -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> => {