feat(tasks): wake an idle owner when a background task completes
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.
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 packages/tasks/tool-tasks/README.md
|
||||
README.md: 76607351aba6b482817b890056254da737e7b9ed
|
||||
README.zh.md: 8d27510aa3be3b87ca167d88d85fc374fb1eb4f1
|
||||
README.md: f08ca3708d3ecc0dd1b5bfb3286207f23cb87898
|
||||
README.zh.md: 15a7e6ba41af1011a9760e1d332a01f44c0a8794
|
||||
|
||||
@@ -18,7 +18,11 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`
|
||||
|
||||
## Completion notices
|
||||
|
||||
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice.
|
||||
An unreported completion delivers `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` to the exact owner. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice, as does the teardown cancel that drains an owner or the service.
|
||||
|
||||
Which lane carries it depends on what the owner is doing. A busy owner is injected: the notice joins the next-step inbox, and the turn cannot close while that inbox holds it, so several tasks settling together cost one step rather than one turn each. An idle owner is instead woken with a follow-up turn, because a pending notice nothing claims is a completion the model never learns about. `completionDelivery: quiet` keeps the injection lane for idle owners too, which is what a deterministic transcript needs.
|
||||
|
||||
Waking is bounded. Each owner may open `maxConsecutiveWakes` turns this way before further notices degrade to injection, and claiming any user-authored message restores the budget. The bound exists because the chain is self-exciting: a woken turn may start the background task whose completion wakes it again. Notices this plugin queued never refill the budget they spent.
|
||||
|
||||
One host registry may carry several mounts of this plugin — one per agent preset. The registry routes each settlement to the listeners the owner's scope chain reaches, so a mount under one preset never sees another preset's agents and an agent reads exactly one notice per completion however many presets are mounted. The same routing decides which agents this mount's controller serves: an agent whose composition loads no `tool-tasks` cannot start background work at all.
|
||||
|
||||
@@ -28,6 +32,8 @@ One host registry may carry several mounts of this plugin — one per agent pres
|
||||
|---|---|---|
|
||||
| `waitTimeoutMs` | `30000` | wait used when `wait: true` omits `timeout_ms` |
|
||||
| `maxWaitTimeoutMs` | `600000` | cap for model-supplied waits |
|
||||
| `completionDelivery` | `wakeup` | `wakeup` opens a turn on an idle owner; `quiet` leaves the notice pending |
|
||||
| `maxConsecutiveWakes` | `3` | turns one owner may open by wake before notices degrade to injection |
|
||||
|
||||
A default above the cap fails at load.
|
||||
|
||||
@@ -75,7 +81,7 @@ Reads return output or `(no new output)` followed by `[status: <status>]` and op
|
||||
|
||||
#### Token effect
|
||||
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice.
|
||||
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner by `maxConsecutiveWakes`; a notice reaching a busy owner adds a step to the turn it is already paying for.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -83,6 +89,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Completion notices do not wake idle agents** — callers needing an immediate result must use `task_output`.
|
||||
- **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.
|
||||
- **Unowned tasks have no session fence** — external callers must supply policy or avoid them.
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
|
||||
## 完成通知
|
||||
|
||||
一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。
|
||||
一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 交付给确切所有者。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。kill 或针对已终止任务的 read/wait 会把交付标为已报告并抑制重复通知;排空 owner 或服务的 teardown 取消同样如此。
|
||||
|
||||
由哪条通道承载取决于所有者当时在做什么。繁忙的所有者走注入:通知进入 next-step inbox,而该 inbox 尚有内容时 turn 无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的所有者则被 follow-up 唤醒,因为无人领取的待发通知等于模型永远不会知道的完成。`completionDelivery: quiet` 让空闲所有者也留在注入通道上,确定性 transcript 需要的正是这一点。
|
||||
|
||||
唤醒是有界的。每个所有者最多可通过唤醒开启 `maxConsecutiveWakes` 轮,此后的通知降级为注入;领取任何用户撰写的消息都会恢复该预算。设界是因为这条链会自激:被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者。本插件自己排队的通知永远不会补充它刚花掉的预算。
|
||||
|
||||
一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent,无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制器服务哪些 agent:组合中未加载 `tool-tasks` 的 agent 根本无法启动后台工作。
|
||||
|
||||
@@ -28,6 +32,8 @@
|
||||
|---|---|---|
|
||||
| `waitTimeoutMs` | `30000` | `wait: true` 省略 `timeout_ms` 时使用的等待时间 |
|
||||
| `maxWaitTimeoutMs` | `600000` | 模型所给等待时间的上限 |
|
||||
| `completionDelivery` | `wakeup` | `wakeup` 为空闲所有者开启一轮;`quiet` 让通知继续待领 |
|
||||
| `maxConsecutiveWakes` | `3` | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入 |
|
||||
|
||||
默认值高于上限时,插件会在加载时失败。
|
||||
|
||||
@@ -75,7 +81,7 @@ Track every background task id you start. You are notified in-session when a tas
|
||||
|
||||
#### Token 影响
|
||||
|
||||
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。
|
||||
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量按所有者由 `maxConsecutiveWakes` 封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -83,6 +89,7 @@ Track every background task id you start. You are notified in-session when a tas
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **完成通知不会唤醒空闲 agent**:需要立即获得结果的调用方必须使用 `task_output`。
|
||||
- **已花掉的唤醒预算不会随时间恢复**:只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知。
|
||||
- **待领于空闲所有者的通知无法在该所有者释放后存活**:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。
|
||||
- **流读取只有单一消费方**:独立观察者需要另一套运行时 API。
|
||||
- **无 owner 的任务没有会话隔离**:外部调用方必须提供策略或避开这些任务。
|
||||
|
||||
@@ -15,21 +15,40 @@ 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'
|
||||
|
||||
export const name = 'tool-tasks'
|
||||
export const inject = ['tools', 'tasks', 'systemPrompt']
|
||||
|
||||
/** Configures bounded `task_output` waits. */
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
waitTimeoutMs: z.number().min(1).default(30_000),
|
||||
maxWaitTimeoutMs: z.number().min(1).default(600_000),
|
||||
completionDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
|
||||
maxConsecutiveWakes: z.number().min(1).default(3),
|
||||
})
|
||||
|
||||
/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
|
||||
@@ -185,9 +204,21 @@ function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: str
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const waitDefault = config.waitTimeoutMs ?? 30_000
|
||||
const waitCap = config.maxWaitTimeoutMs ?? 600_000
|
||||
const delivery = config.completionDelivery ?? 'wakeup'
|
||||
const wakeBudget = config.maxConsecutiveWakes ?? 3
|
||||
|
||||
// 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>()
|
||||
if (waitDefault > waitCap) {
|
||||
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
|
||||
}
|
||||
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) => {
|
||||
@@ -227,16 +258,18 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
|
||||
// Delivery targets the exact lifecycle owner. The notice waits in its
|
||||
// next-step inbox until another step claims it; disposal before that
|
||||
// boundary discards it with the owner.
|
||||
// A busy owner is injected: the notice waits in its next-step inbox, which
|
||||
// the turn cannot close over, so tasks settling together cost one step. An
|
||||
// idle owner is woken instead, because an unclaimed notice is a completion
|
||||
// the model never learns about. Either way, disposal before the claim
|
||||
// discards it with the owner, and teardown settlements arrive `reported`.
|
||||
//
|
||||
// The registry routes each settlement to the listeners its owner's scope
|
||||
// chain reaches, so a mount under one preset never sees another preset's
|
||||
// agents; this listener owns delivery, not the choice of whom to deliver to.
|
||||
ctx.tasks.onTaskDone((snapshot, owner) => {
|
||||
if (snapshot.reported || owner === undefined) return
|
||||
owner.inject(createUserMessage({
|
||||
const message = createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: fitCompletionNotice(snapshot),
|
||||
@@ -247,7 +280,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
form: 'notice',
|
||||
summary: completionSummary(snapshot),
|
||||
},
|
||||
}))
|
||||
})
|
||||
const spent = spentWakes.get(owner) ?? 0
|
||||
if (delivery === 'wakeup' && owner.status === 'idle' && spent < wakeBudget) {
|
||||
spentWakes.set(owner, spent + 1)
|
||||
owner.followup(message)
|
||||
return
|
||||
}
|
||||
owner.inject(message)
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
@@ -16,6 +17,7 @@ import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const agentRegistryDisposers = new WeakMap<Agent, () => void>()
|
||||
const agentScopeFibers = new WeakMap<Agent, { dispose: () => Promise<void> }>()
|
||||
|
||||
async function setup(config: ToolTasks.Config = {}) {
|
||||
const ctx = new Context()
|
||||
@@ -27,20 +29,31 @@ async function setup(config: ToolTasks.Config = {}) {
|
||||
return { ctx, agentsFiber, toolsFiber }
|
||||
}
|
||||
|
||||
/** The delivery surface a completion notice may reach on a fake owner. */
|
||||
interface FakeDelivery {
|
||||
inject?: (...args: unknown[]) => void
|
||||
followup?: (...args: unknown[]) => void
|
||||
/** Defaults to `running`, the lane that never wakes, so notice-content tests pin one lane. */
|
||||
status?: 'idle' | 'running'
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake agent with the shared agent/session identity, registered in
|
||||
* `ctx.agents` with a dedicated lifecycle scope.
|
||||
*/
|
||||
function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
||||
function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const id = SessionId(sessionId)
|
||||
const agent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
inject,
|
||||
inject: delivery.inject ?? (() => {}),
|
||||
followup: delivery.followup ?? (() => {}),
|
||||
status: delivery.status ?? 'running',
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
agentRegistryDisposers.set(agent, ctx.agents.register(agent))
|
||||
agentScopeFibers.set(agent, scopeFiber)
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -50,6 +63,13 @@ function detachAgent(agent: Agent): void {
|
||||
dispose()
|
||||
}
|
||||
|
||||
/** Dispose the agent's own lifecycle scope, which is what drains its owned tasks. */
|
||||
async function disposeAgentScope(agent: Agent): Promise<void> {
|
||||
const fiber = agentScopeFibers.get(agent)
|
||||
if (fiber === undefined) throw new Error(`missing scope fiber for agent "${agent.id}"`)
|
||||
await fiber.dispose()
|
||||
}
|
||||
|
||||
/** A controllable producer start-spec (settle `done` on demand, record cancels). */
|
||||
function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
@@ -81,6 +101,16 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
|
||||
const tick = () => new Promise<void>(r => setTimeout(r, 0))
|
||||
|
||||
/** Start and settle `count` owned tasks one at a time, letting each notice land. */
|
||||
async function settleTasks(ctx: Context, owner: Agent, count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
}
|
||||
}
|
||||
|
||||
describe('tool-tasks setup', () => {
|
||||
it('attaches the task controller on load and detaches it with the fiber', async () => {
|
||||
const { ctx, toolsFiber } = await setup()
|
||||
@@ -494,11 +524,112 @@ describe('completion notices across scoped mounts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('completion notice delivery', () => {
|
||||
it('opens a turn on an idle owner when a task settles', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
p.settle({ status: 'completed', detail: 'exit code: 0' })
|
||||
await tick()
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never wakes an idle owner under quiet delivery', async () => {
|
||||
const { ctx } = await setup({ completionDelivery: 'quiet' })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('degrades to injection once the consecutive wake budget is spent', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 2 })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 3)
|
||||
// A woken turn that starts another task is the self-exciting case: the
|
||||
// budget stops the chain, and the notice still reaches the inbox.
|
||||
expect(followup).toHaveBeenCalledTimes(2)
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restores the wake budget when the owner claims a user message', async () => {
|
||||
const { ctx } = await setup({ maxConsecutiveWakes: 1 })
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 2)
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
|
||||
message: createUserMessage({ content: [{ type: 'text', text: 'carry on' }], source: { kind: 'user' } }),
|
||||
turn: 1,
|
||||
})
|
||||
await settleTasks(ctx, owner, 1)
|
||||
expect(followup).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('neither wakes nor injects into an owner its own teardown is draining', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const followup = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
ctx.tasks.start({
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
owner,
|
||||
run: () => ({
|
||||
cancel() { settle({ status: 'killed' }) },
|
||||
done: new Promise<TaskOutcome>((res) => { settle = res }),
|
||||
}),
|
||||
})
|
||||
|
||||
// Disposal cancels and settles the owned task. Waking here would spend a
|
||||
// model request on an agent the host is destroying, once per tree layer.
|
||||
await disposeAgentScope(owner)
|
||||
await tick()
|
||||
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()
|
||||
const owner = fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
|
||||
|
||||
await settleTasks(ctx, owner, 1)
|
||||
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: 'background task bash-1 finished' }],
|
||||
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash' },
|
||||
}),
|
||||
turn: 1,
|
||||
})
|
||||
await settleTasks(ctx, owner, 1)
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('completion notices', () => {
|
||||
it('injects a notice into the owning agent when an unreported task settles', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner, label: 'pnpm test' })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
@@ -521,7 +652,7 @@ describe('completion notices', () => {
|
||||
it('preserves task ids and collection guidance in bounded completion notices', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const first = producer({
|
||||
owner,
|
||||
kind: 'subagent',
|
||||
@@ -574,7 +705,7 @@ describe('completion notices', () => {
|
||||
prior.settle({ status: 'completed' })
|
||||
}
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const target = producer({
|
||||
owner,
|
||||
kind: 'pty-send',
|
||||
@@ -595,7 +726,7 @@ describe('completion notices', () => {
|
||||
it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
|
||||
const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
|
||||
ctx.tasks.start(tiny.spec)
|
||||
@@ -616,7 +747,7 @@ describe('completion notices', () => {
|
||||
it('suppresses the notice for a task the model already killed', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
@@ -629,7 +760,7 @@ describe('completion notices', () => {
|
||||
it('suppresses the notice when a wait returned the terminal state', async () => {
|
||||
const { ctx } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
const p = producer({ owner, kind: 'subagent' })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
@@ -654,13 +785,13 @@ describe('completion notices', () => {
|
||||
// terminal state, so the notice lands in the old owner's (detached)
|
||||
// session instead of throwing or re-routing.
|
||||
const oldInject = vi.fn()
|
||||
const oldOwner = fakeAgent(ctx, 'shared', oldInject)
|
||||
const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject })
|
||||
const p = producer({ owner: oldOwner })
|
||||
ctx.tasks.start(p.spec)
|
||||
|
||||
detachAgent(oldOwner)
|
||||
const replacementInject = vi.fn()
|
||||
fakeAgent(ctx, 'shared', replacementInject)
|
||||
fakeAgent(ctx, 'shared', { inject: replacementInject })
|
||||
p.settle({ status: 'completed' })
|
||||
await tick()
|
||||
|
||||
@@ -671,7 +802,7 @@ describe('completion notices', () => {
|
||||
it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
|
||||
const p = producer({ owner })
|
||||
ctx.tasks.start(p.spec)
|
||||
p.settle({ status: 'completed' })
|
||||
@@ -684,7 +815,7 @@ describe('completion notices', () => {
|
||||
it('keeps using the exact owner after the agent registry is gone', async () => {
|
||||
const { ctx, agentsFiber } = await setup()
|
||||
const inject = vi.fn()
|
||||
const owner = fakeAgent(ctx, 'sess-1', inject)
|
||||
const owner = fakeAgent(ctx, 'sess-1', { inject })
|
||||
|
||||
// Settlement must not depend on a later registry lookup: the exact owner
|
||||
// supplied at start remains the destination while its own scope is live.
|
||||
|
||||
Reference in New Issue
Block a user