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:
@@ -12,7 +12,7 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + drain the `ctx.agents.onCleanup` registrations + unregister + remove session).
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
|
||||
|
||||
@@ -274,11 +274,15 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* chain — the runtime awaits each disposer's returned promise before the next:
|
||||
*
|
||||
* yield session-detach (disposed LAST — detach onAppend + remove entry)
|
||||
* yield register (disposed 2nd — unregister)
|
||||
* yield register (disposed 3rd — unregister)
|
||||
* yield cleanup-drain (disposed 2nd — await ctx.agents.drainCleanups)
|
||||
* yield stop-and-drain (disposed FIRST — request loop stop, await agent.done)
|
||||
*
|
||||
* So on teardown: the loop is stopped and AWAITED to exit (its final
|
||||
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
|
||||
* THEN the awaited per-agent cleanups drain (background tasks cancel and
|
||||
* reach quiescence while the agent is STILL registered — a settling task's
|
||||
* completion notice can still find it, and `agent/disposed` has not fired),
|
||||
* THEN the agent is unregistered, THEN the session is detached — capturing the
|
||||
* closing events before detach, whether the trigger is the handle's `dispose()`
|
||||
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
|
||||
@@ -304,6 +308,11 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
yield this.ctx.sessions.enter(session)
|
||||
this.ctx.sessions.announce(session)
|
||||
yield this.ctx.agents.register(agent)
|
||||
// Disposed 2nd (after stop-and-drain below, before unregister above):
|
||||
// drain the awaited per-agent cleanups — the AgentFactory dispose
|
||||
// contract that lets other plugins (ctx.tasks) tie resources to this
|
||||
// agent's quiescence. drainCleanups contains rejections itself.
|
||||
yield async () => { await this.ctx.agents.drainCleanups(agent.id) }
|
||||
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
|
||||
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
|
||||
// never aborts construction (no open turn to balance here).
|
||||
|
||||
57
packages/core/agent-loop/tests/cleanup-drain.spec.ts
Normal file
57
packages/core/agent-loop/tests/cleanup-drain.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter } from './mock-adapter.ts'
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('agent disposal drains onCleanup registrations', () => {
|
||||
it('awaits the cleanup after loop drain and before unregistration', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('owner'), sessionId: SessionId('owner-sess'), agentOptions: { model: 'mock' } })
|
||||
|
||||
const order: string[] = []
|
||||
ctx.on('agent/disposed', () => void order.push('agent/disposed'))
|
||||
let cleanupSettled = false
|
||||
ctx.agents.onCleanup(handle.agent.id, async () => {
|
||||
// The agent must STILL be registered while cleanups drain (a settling
|
||||
// task's completion notice can still find it by session id).
|
||||
order.push(`cleanup:registered=${ctx.agents.get(handle.agent.id) !== undefined}`)
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
cleanupSettled = true
|
||||
order.push('cleanup:done')
|
||||
})
|
||||
|
||||
await handle.dispose()
|
||||
// dispose() resolves only after the cleanup settled (awaited, not fired).
|
||||
expect(cleanupSettled).toBe(true)
|
||||
expect(order).toEqual(['cleanup:registered=true', 'cleanup:done', 'agent/disposed'])
|
||||
})
|
||||
|
||||
it('a rejecting cleanup never breaks the disposal chain', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const handle = ctx.agents.create({ agentId: AgentId('owner'), sessionId: SessionId('owner-sess'), agentOptions: { model: 'mock' } })
|
||||
ctx.agents.onCleanup(handle.agent.id, () => Promise.reject(new Error('drain boom')))
|
||||
|
||||
await expect(handle.dispose()).resolves.toBeUndefined()
|
||||
expect(ctx.agents.get(handle.agent.id)).toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('drain boom'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user