feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers

One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
This commit is contained in:
Yichen Jiang
2026-07-09 21:22:54 +08:00
parent e7e382f9d1
commit 184e164091
83 changed files with 3909 additions and 1627 deletions

View File

@@ -20,7 +20,10 @@ Agent *creation* is provided by whichever plugin implements `AgentFactory` (phas
- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`/`meta.parentSession`/`meta.seedLength` and optional `seed` events for forked children). Distinct from `register` (which only records). Throws if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), drains the registered per-agent cleanups, unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached.
- `ctx.agents.onCleanup(agentId, cleanup: () => Promise<void>): () => void` — register an AWAITED per-agent cleanup: the agent's disposal chain runs it (after loop drain, before unregistration) and `AgentHandle.dispose()` resolves only after it settles. The seam for resources that must not outlive their owner (`ctx.tasks` background tasks) — the `agent/disposed` EMIT cannot promise that, because emit listeners are not awaited. Throws for an unregistered agent id; effect-scoped.
- `ctx.agents.drainCleanups(agentId): Promise<void>` — LIFECYCLE OWNERS ONLY: run and detach every registered cleanup (registration order, per-cleanup containment, loops so a cleanup registered mid-drain still runs). Part of the `AgentFactory` dispose contract — a replacement loop must call it in its disposal chain. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
### Events

View File

@@ -88,6 +88,13 @@ export interface AgentHandle {
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
* consumers (e.g. the ACP bridge) program against `ctx.agents` without
* depending on the concrete `dsh-agent-loop` package.
*
* Dispose contract: the handle's `dispose()` must, after draining the loop and
* BEFORE unregistering the agent, await
* {@link AgentRegistry.drainCleanups | ctx.agents.drainCleanups(agent.id)} —
* that is what makes {@link AgentRegistry.onCleanup} registrations an awaited
* quiescence guarantee for every plugin, whichever loop implementation is
* installed.
*/
export interface AgentFactory {
/**
@@ -117,6 +124,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
export class AgentRegistry extends Service {
private store = new Map<AgentId, Agent>()
private factory: AgentFactory | undefined
private cleanups = new Map<AgentId, Set<() => Promise<void>>>()
constructor(ctx: Context) {
super(ctx, 'agents')
@@ -217,6 +225,66 @@ export class AgentRegistry extends Service {
return this.store.get(id)
}
/**
* Register an AWAITED per-agent cleanup: the agent's disposal chain runs it
* (via {@link drainCleanups}) after the loop has drained and before the agent
* unregisters, and `AgentHandle.dispose()` resolves only after it settles.
* This is the seam for resources that must not outlive their owning agent
* (e.g. `ctx.tasks` background tasks) — the `agent/disposed` EMIT cannot
* promise that, because emit listeners are not awaited. Throws for an agent
* id not currently registered: a cleanup attached to a dead agent would
* silently never run. Effect-scoped: disposed with the calling fiber.
* @param agentId - the LIVE agent whose disposal must await this cleanup.
* @param cleanup - awaited during disposal; a rejection is logged, never propagated.
* @returns the disposer that detaches the cleanup without running it.
*/
onCleanup(agentId: AgentId, cleanup: () => Promise<void>): () => void {
const dispose = this.ctx.effect(() => {
if (!this.store.has(agentId)) {
throw new Error(`agent "${agentId}" is not registered (cleanup would never run)`)
}
let set = this.cleanups.get(agentId)
if (set === undefined) {
set = new Set()
this.cleanups.set(agentId, set)
}
set.add(cleanup)
return () => {
set.delete(cleanup)
// Guard the map removal with an identity check: after drainCleanups
// detached this set, the same id may map to a FRESH set (a cleanup
// registered mid-drain) that this stale disposer must not remove.
if (set.size === 0 && this.cleanups.get(agentId) === set) this.cleanups.delete(agentId)
}
}, 'agents.onCleanup()')
return () => void dispose()
}
/**
* Run and detach every cleanup registered for an agent (registration order,
* awaited sequentially, per-cleanup containment — a rejecting cleanup is
* logged and never starves the ones after it or the caller's disposal chain).
* For LIFECYCLE OWNERS ONLY: the agent factory's disposal chain calls this
* between loop drain and unregistration (part of the {@link AgentFactory}
* dispose contract); other plugins register via {@link onCleanup}, never
* drain. Loops until no cleanups remain, so one registered DURING the drain
* (from a settling task) still runs instead of leaking.
* @param agentId - the agent being disposed.
* @returns resolves when every registered cleanup has settled.
*/
async drainCleanups(agentId: AgentId): Promise<void> {
for (let set = this.cleanups.get(agentId); set !== undefined; set = this.cleanups.get(agentId)) {
this.cleanups.delete(agentId)
for (const cleanup of set) {
try {
await cleanup()
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${agentId}": disposal cleanup threw: ${String(error)}`)
}
}
}
}
/**
* All live agents, in registration order.
* @returns a fresh array; mutating it does not affect the registry.

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
@@ -76,6 +76,129 @@ describe('AgentRegistry', () => {
})
})
describe('AgentRegistry.onCleanup / drainCleanups', () => {
it('drains cleanups in registration order, awaiting each', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const agent = stubAgent('a1')
ctx.agents.register(agent)
const ran: string[] = []
ctx.agents.onCleanup(agent.id, async () => {
ran.push('first:start')
await new Promise(r => setTimeout(r, 10))
ran.push('first:end')
})
ctx.agents.onCleanup(agent.id, () => {
ran.push('second')
return Promise.resolve()
})
await ctx.agents.drainCleanups(agent.id)
// Sequential await: the second cleanup starts only after the first settled.
expect(ran).toEqual(['first:start', 'first:end', 'second'])
// Drained cleanups are detached: a second drain is a no-op.
await ctx.agents.drainCleanups(agent.id)
expect(ran).toEqual(['first:start', 'first:end', 'second'])
})
it('contains a rejecting cleanup: logged, later cleanups still run', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const agent = stubAgent('a1')
ctx.agents.register(agent)
let ranAfter = false
ctx.agents.onCleanup(agent.id, () => Promise.reject(new Error('cleanup boom')))
ctx.agents.onCleanup(agent.id, () => {
ranAfter = true
return Promise.resolve()
})
await expect(ctx.agents.drainCleanups(agent.id)).resolves.toBeUndefined()
expect(ranAfter).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup boom'))
})
it('rejects a cleanup for an agent that is not registered', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
expect(() => ctx.agents.onCleanup(AgentId('ghost'), () => Promise.resolve()))
.toThrow('agent "ghost" is not registered')
})
it('detaches without running on disposer call and on fiber dispose (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const agent = stubAgent('a1')
ctx.agents.register(agent)
let ranA = false
let ranB = false
const detach = ctx.agents.onCleanup(agent.id, () => {
ranA = true
return Promise.resolve()
})
detach()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.agents.onCleanup(agent.id, () => {
ranB = true
return Promise.resolve()
})
}, { inject: ['agents'] }))
await fiber.dispose()
await ctx.agents.drainCleanups(agent.id)
expect(ranA).toBe(false)
expect(ranB).toBe(false)
})
it('runs a cleanup registered during the drain instead of leaking it', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const agent = stubAgent('a1')
ctx.agents.register(agent)
const ran: string[] = []
ctx.agents.onCleanup(agent.id, () => {
ran.push('outer')
// A settling task registering follow-up cleanup mid-drain: the drain
// loop must pick up the fresh set rather than strand it.
ctx.agents.onCleanup(agent.id, () => {
ran.push('mid-drain')
return Promise.resolve()
})
return Promise.resolve()
})
await ctx.agents.drainCleanups(agent.id)
expect(ran).toEqual(['outer', 'mid-drain'])
})
it('a stale disposer from a drained set does not remove a fresh registration', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const agent = stubAgent('a1')
ctx.agents.register(agent)
const detachOld = ctx.agents.onCleanup(agent.id, () => Promise.resolve())
await ctx.agents.drainCleanups(agent.id)
let ranFresh = false
ctx.agents.onCleanup(agent.id, () => {
ranFresh = true
return Promise.resolve()
})
// The old registration's disposer fires after its set was drained; the
// identity guard must keep it away from the fresh set under the same id.
detachOld()
await ctx.agents.drainCleanups(agent.id)
expect(ranFresh).toBe(true)
})
})
describe('AgentRegistry factory seam', () => {
/** A stub AgentFactory that records calls and returns a stub agent. */
function stubFactory() {