fix(tasks): bind cleanup and notices to exact owners

Task records previously retained only ownerSession. If an old agent scope unwound after another agent reused the same agent and session ids, cleanup selected both records and could cancel replacement work. The completion surface also re-resolved the session at settlement, which could inject an old task notice into the replacement agent.

Retain the exact Agent instance for lifecycle work, select owner cleanup by object identity, and pass that exact owner to completion listeners. Keep read, list, kill, and wait authorization session-based as the runtime RFC intends. Add regressions for cleanup and notice routing under id reuse, then update the public docs and generated API catalogs.
This commit is contained in:
Tianyi Cui
2026-07-15 11:40:37 +08:00
parent c2d5a5be80
commit 1a69a3debe
11 changed files with 117 additions and 58 deletions

View File

@@ -12,7 +12,7 @@ ACP render intent: all three are `generic` cards (`read`/`read`/`execute`) — a
## Completion notices
On `onTaskDone`, injects `background task <id> (<kind>: <label>) finished [status: …]. Read its output with task_output.` into the owning agent's session (`agent.inject()` — durable context for the next request, not a wake-up). Suppressed when the snapshot is `reported` (the model already killed it, or a read/wait returned the end) — never a redundant "finished". The disposed-owner race is contained; a missing agent registry drops the notice.
On `onTaskDone`, injects `background task <id> (<kind>: <label>) finished [status: …]. Read its output with task_output.` through the exact owner `Agent` captured at task start (`agent.inject()` — durable context for the next request, not a wake-up). It never re-resolves a reusable agent/session id to a replacement. Suppressed when the snapshot is `reported` (the model already killed it, or a read/wait returned the end) — never a redundant "finished"; the disposed-owner race is contained.
## Config

View File

@@ -93,19 +93,15 @@ export function apply(ctx: Context, config: Config): void {
text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.',
})
// Background completion → inject a notice into the owning agent's session.
// `ctx.get('agents')` (not static inject): this listener runs from a
// detached settlement continuation on the tasks fiber — a foreign fiber —
// where the `ctx.agents` property proxy would throw; `ctx.get` is the
// topology-independent lookup. No registry mounted → drop the notice.
ctx.tasks.onTaskDone((snapshot) => {
// Background completion → inject a notice through the exact lifecycle owner.
// Re-resolving by a reusable agent/session id could target a replacement
// while the old owner's scope is still unwinding.
ctx.tasks.onTaskDone((snapshot, owner) => {
// A reported terminal state was already surfaced by an explicit
// read/wait/kill response — a notice would be a redundant "finished".
if (snapshot.reported || snapshot.ownerSession === undefined) return
const agent = ctx.get('agents')?.list().find(a => a.session.header.id === snapshot.ownerSession)
if (!agent) return
if (snapshot.reported || owner === undefined) return
try {
agent.inject(
owner.inject(
[{ type: 'text', text: `background task ${snapshot.id} (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}. Read its output with task_output.` }],
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
)

View File

@@ -10,6 +10,8 @@ import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
const agentRegistryDisposers = new WeakMap<Agent, () => void>()
async function setup(config: ToolTasks.Config = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -21,10 +23,9 @@ async function setup(config: ToolTasks.Config = {}) {
}
/**
* A fake agent whose session token is `sessionId`, registered in `ctx.agents`
* (the notice path finds the owner by scanning the registry for a matching
* `session.header.id` — the agent id is deliberately DIFFERENT so a
* wrong-field match fails the test).
* A fake agent whose session token is `sessionId`, registered in `ctx.agents`.
* The agent id is deliberately different so session authorization and exact
* lifecycle ownership cannot be confused in tests.
*/
function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
const scopeFiber = ctx.plugin(() => {})
@@ -34,10 +35,16 @@ function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[])
inject,
session: { header: { version: 0, id: sessionId, createdAt: 0 } },
} as unknown as Agent
ctx.agents.register(agent)
agentRegistryDisposers.set(agent, ctx.agents.register(agent))
return agent
}
function detachAgent(agent: Agent): void {
const dispose = agentRegistryDisposers.get(agent)
if (dispose === undefined) throw new Error(`missing registry disposer for agent "${agent.id}"`)
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
@@ -278,6 +285,23 @@ describe('completion notices', () => {
expect(inject).toHaveBeenCalledTimes(1)
})
it('does not route an old owner completion notice to a same-session replacement', async () => {
const { ctx } = await setup()
const oldInject = vi.fn(() => { throw new Error('agent "agent-shared" is disposed') })
const oldOwner = fakeAgent(ctx, 'shared', oldInject)
const p = producer({ owner: oldOwner })
ctx.tasks.start(p.spec)
detachAgent(oldOwner)
const replacementInject = vi.fn()
fakeAgent(ctx, 'shared', replacementInject)
p.settle({ status: 'completed' })
await tick()
expect(oldInject).toHaveBeenCalledTimes(1)
expect(replacementInject).not.toHaveBeenCalled()
})
it('propagates a non-disposed inject failure (a real bug must surface)', async () => {
const { ctx } = await setup()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
@@ -291,15 +315,15 @@ describe('completion notices', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
})
it('drops the notice when no live agent matches and when the agent registry is gone', async () => {
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)
// Owner known at registration, unregistered before settlement → no match.
// Settlement must not depend on a later registry lookup: the exact owner
// supplied at start remains the destination while its own scope is live.
const p1 = producer({ owner })
ctx.tasks.start(p1.spec)
// A second task whose settlement happens after the whole registry is gone.
const p2 = producer({ owner })
ctx.tasks.start(p2.spec)
@@ -307,6 +331,6 @@ describe('completion notices', () => {
p1.settle({ status: 'completed' })
p2.settle({ status: 'failed' })
await tick()
expect(inject).not.toHaveBeenCalled()
expect(inject).toHaveBeenCalledTimes(2)
})
})