| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762 |
- import { describe, expect, expectTypeOf, it, vi } from 'vitest'
- import { Context } from 'cordis'
- import { Session, SessionId } from '@deepseek-ai/dsh-session'
- import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import { TaskId } from '@deepseek-ai/dsh-tasks'
- import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
- import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
- declare module '@deepseek-ai/dsh-tasks' {
- interface TaskKindMap {
- workflow: 'workflow'
- }
- }
- const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
- function stubAgent(ctx: Context, rawId: string): Agent {
- const id = SessionId(rawId)
- const scopeFiber = ctx.plugin(() => {})
- const session = Session.create(id)
- const agent = {
- id,
- options: {},
- session,
- inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
- status: 'idle' as const,
- ctx: scopeFiber.ctx,
- send: () => {},
- followup: () => {},
- steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
- inject: () => {},
- cancel() {},
- runMaintenance: <T>(task: (signal: AbortSignal) => Promise<T>) => task(new AbortController().signal),
- whenIdle() { return Promise.resolve() },
- }
- agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
- return agent
- }
- async function disposeAgentScope(agent: Agent): Promise<void> {
- const dispose = agentScopeDisposers.get(agent)
- if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`)
- await dispose()
- }
- /** A controllable producer start-spec: settle its `done` on demand, record cancels. */
- function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
- let settle!: (outcome: TaskOutcome) => void
- let reject!: (error: unknown) => void
- const cancels: (string | undefined)[] = []
- const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
- const hooks: TaskHooks = {
- cancel(reason) { cancels.push(reason) },
- done: new Promise<TaskOutcome>((res, rej) => { settle = res; reject = rej }),
- ...hookOverrides,
- }
- const spec: TaskStart = {
- kind,
- label,
- ...owner !== undefined ? { owner } : {},
- ...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
- run: () => hooks,
- }
- return { spec, settle, reject, cancels }
- }
- async function harness() {
- const ctx = new Context()
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalTaskService)
- ctx.tasks.attachSurface('test-surface')
- return ctx
- }
- /** Let the settlement continuation (a `done.then`) run. */
- const tick = () => new Promise<void>(r => setTimeout(r, 0))
- /** Inspect the internal resolver registry to pin bounded retention while a task stays live. */
- function waitResolverCount(ctx: Context, id: TaskId): number {
- const service = ctx.tasks as unknown as { store: Map<TaskId, { waitResolvers: Set<() => void> }> }
- const task = service.store.get(id)
- if (task === undefined) throw new Error(`missing test task ${id}`)
- return task.waitResolvers.size
- }
- describe('LocalTaskService.start', () => {
- it('preserves the SessionId brand on public owner snapshots', () => {
- expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
- })
- it('refuses to register while no control surface is attached', async () => {
- const ctx = new Context()
- await ctx.plugin(LocalTaskService)
- expect(() => ctx.tasks.start(producer().spec))
- .toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
- })
- it('rejects an empty kind, empty label, and invalid output limit', async () => {
- const ctx = await harness()
- expect(() => ctx.tasks.start(producer({ kind: '' as TaskKind }).spec)).toThrow('invalid task kind')
- expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label')
- expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes')
- })
- it('issues kind-prefixed ids from per-kind counters', async () => {
- const ctx = await harness()
- expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
- expect(ctx.tasks.start(producer().spec)).toBe('bash-2')
- expect(ctx.tasks.start(producer({ kind: 'subagent' }).spec)).toBe('subagent-1')
- expect(ctx.tasks.start(producer({ kind: 'workflow' }).spec)).toBe('workflow-1')
- })
- })
- describe('LocalTaskService reads and settlement', () => {
- it('stream kinds read a consuming delta; terminal reads mark reported', async () => {
- const ctx = await harness()
- const chunks = ['first', '', 'rest']
- const p = producer({ readOutput: () => chunks.shift() ?? '' })
- const id = ctx.tasks.start(p.spec)
- expect(ctx.tasks.read(id)).toMatchObject({ text: 'first', snapshot: { status: 'running', reported: false } })
- expect(ctx.tasks.read(id).text).toBe('')
- p.settle({ status: 'completed', detail: 'exit code: 0' })
- await tick()
- const read = ctx.tasks.read(id)
- expect(read.text).toBe('rest')
- expect(read.snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', reported: true })
- expect(read.snapshot.finishedAt).toBeTypeOf('number')
- })
- it('projects a producer-owned model output limit into reads and snapshots', async () => {
- const ctx = await harness()
- const p = producer({ outputLimitBytes: 64, readOutput: () => 'delta' })
- const id = ctx.tasks.start(p.spec)
- expect(ctx.tasks.read(id)).toMatchObject({
- text: 'delta', snapshot: { outputLimitBytes: 64 },
- })
- expect(ctx.tasks.get(id)).toMatchObject({ outputLimitBytes: 64 })
- })
- it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => {
- const ctx = await harness()
- const p = producer({ kind: 'subagent', label: 'research task' })
- const id = ctx.tasks.start(p.spec)
- expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } })
- p.settle({ status: 'completed', output: 'final answer' })
- await tick()
- expect(ctx.tasks.read(id).text).toBe('final answer')
- expect(ctx.tasks.read(id).text).toBe('final answer') // idempotent, not consumed
- })
- it('a settled task without output reads as empty text', async () => {
- const ctx = await harness()
- const p = producer({ kind: 'subagent' })
- const id = ctx.tasks.start(p.spec)
- p.settle({ status: 'failed', detail: 'max-tokens' })
- await tick()
- expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'failed', detail: 'max-tokens' } })
- })
- it('throws for unknown task ids', async () => {
- const ctx = await harness()
- expect(() => ctx.tasks.read(TaskId('bash-99'))).toThrow('unknown task bash-99')
- })
- it('notifies onTaskDone once per task with containment across listeners', async () => {
- const ctx = await harness()
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
- const seen: TaskSnapshot[] = []
- ctx.tasks.onTaskDone(() => { throw new Error('listener boom') })
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- p.settle({ status: 'completed', detail: 'exit code: 0' })
- await tick()
- expect(seen).toHaveLength(1)
- expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom'))
- })
- it('contains a rejecting onTaskDone listener without starving later listeners', async () => {
- const ctx = await harness()
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
- const seen: TaskId[] = []
- ctx.tasks.onTaskDone(async () => { throw new Error('async listener boom') })
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- p.settle({ status: 'completed' })
- await tick()
- expect(seen).toEqual([id])
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTaskDone listener rejected'))
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom'))
- })
- it('contains a rejecting done as a failed outcome (producer contract violation)', async () => {
- const ctx = await harness()
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- p.reject(new Error('transport exploded'))
- await tick()
- expect(ctx.tasks.read(id).snapshot).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' })
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('producer contract violation'))
- })
- it('unregisters onTaskDone listeners with the contributing fiber (HMR safety)', async () => {
- const ctx = await harness()
- const seen: string[] = []
- const fiber = await ctx.plugin(Object.assign((inner: Context) => {
- inner.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
- }, { inject: ['tasks'] }))
- await fiber.dispose()
- // The returned disposer detaches too (the non-fiber path).
- const detach = ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
- detach()
- const p = producer()
- ctx.tasks.start(p.spec)
- p.settle({ status: 'completed' })
- await tick()
- expect(seen).toEqual([])
- })
- })
- describe('LocalTaskService.kill', () => {
- it('cancels a live task with the forwarded reason and suppresses the notice', async () => {
- const ctx = await harness()
- const seen: TaskSnapshot[] = []
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- expect(ctx.tasks.kill(id, undefined, 'no longer needed')).toBe('requested')
- expect(p.cancels).toEqual(['no longer needed'])
- expect(ctx.tasks.list()[0]).toMatchObject({ status: 'stopping', reported: true })
- p.settle({ status: 'killed' })
- await tick()
- // The listener still fires (telemetry may care), but carries reported: true
- // so the notice surface suppresses its redundant "finished".
- expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true })
- })
- it('reports an already-finished task instead of failing', async () => {
- const ctx = await harness()
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- p.settle({ status: 'completed' })
- await tick()
- expect(ctx.tasks.kill(id)).toBe('already-finished')
- })
- it('propagates a throwing producer cancel and leaves the task untouched', async () => {
- const ctx = await harness()
- const seen: TaskSnapshot[] = []
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
- let broken = true
- let settle!: (outcome: TaskOutcome) => void
- const id = ctx.tasks.start({
- kind: 'bash',
- label: 'flaky cancel',
- run: () => ({
- cancel() { if (broken) throw new Error('cancel boom') },
- done: new Promise<TaskOutcome>((res) => { settle = res }),
- }),
- })
- expect(() => ctx.tasks.kill(id)).toThrow('cancel boom')
- // The failed kill mutated NOTHING: still running, notice not suppressed,
- // and a later (successful) kill still works.
- expect(ctx.tasks.get(id)).toMatchObject({ status: 'running', reported: false })
- settle({ status: 'completed' })
- await tick()
- expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire
- broken = false
- expect(ctx.tasks.kill(id)).toBe('already-finished')
- })
- })
- describe('LocalTaskService.wait', () => {
- it('resolves with the terminal snapshot when the task settles, marked reported', async () => {
- const ctx = await harness()
- const seen: TaskSnapshot[] = []
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- const wait = ctx.tasks.wait(id, 5_000)
- p.settle({ status: 'completed', detail: 'exit code: 0' })
- expect(await wait).toMatchObject({ status: 'completed', reported: true })
- // A waiting reader claims delivery before completion listeners inspect the snapshot.
- expect(seen[0]).toMatchObject({ id, reported: true })
- })
- it('returns the live snapshot on timeout without marking reported', async () => {
- const ctx = await harness()
- const id = ctx.tasks.start(producer().spec)
- expect(await ctx.tasks.wait(id, 5)).toMatchObject({ status: 'running', reported: false })
- })
- it('unregisters timed-out and aborted wait resolvers while the task remains live', async () => {
- const ctx = await harness()
- const id = ctx.tasks.start(producer().spec)
- for (let index = 0; index < 3; index += 1) {
- const wait = ctx.tasks.wait(id, 5)
- expect(waitResolverCount(ctx, id)).toBe(1)
- await expect(wait).resolves.toMatchObject({ status: 'running' })
- expect(waitResolverCount(ctx, id)).toBe(0)
- }
- const controller = new AbortController()
- const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
- expect(waitResolverCount(ctx, id)).toBe(1)
- controller.abort()
- await expect(wait).rejects.toThrow('wait aborted')
- expect(waitResolverCount(ctx, id)).toBe(0)
- expect(ctx.tasks.get(id).status).toBe('running')
- })
- it('returns immediately for an already-finished task', async () => {
- const ctx = await harness()
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- p.settle({ status: 'completed' })
- await tick()
- expect(await ctx.tasks.wait(id, 5_000)).toMatchObject({ status: 'completed', reported: true })
- })
- it('rejects a non-positive or non-finite timeout', async () => {
- const ctx = await harness()
- const id = ctx.tasks.start(producer().spec)
- await expect(ctx.tasks.wait(id, 0)).rejects.toThrow('invalid wait timeout')
- await expect(ctx.tasks.wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout')
- })
- it('an aborted signal rejects the wait only — the task stays alive', async () => {
- const ctx = await harness()
- const id = ctx.tasks.start(producer().spec)
- const controller = new AbortController()
- const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
- controller.abort()
- await expect(wait).rejects.toThrow('wait aborted')
- expect(ctx.tasks.list()[0]).toMatchObject({ status: 'running' })
- const preAborted = new AbortController()
- preAborted.abort()
- await expect(ctx.tasks.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted')
- })
- it('an abort racing settlement in the same tick does not swallow the notice', async () => {
- const ctx = await harness()
- const seen: TaskSnapshot[] = []
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- const controller = new AbortController()
- const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
- // Settlement is queued first, so abort must remove the waiter synchronously;
- // otherwise settlement suppresses the notice for a reader that receives nothing.
- p.settle({ status: 'completed', detail: 'exit code: 0' })
- controller.abort()
- await expect(wait).rejects.toThrow('wait aborted')
- expect(seen).toHaveLength(1)
- expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
- })
- it('an abort landing after settlement still delivers the terminal snapshot it owes', async () => {
- const ctx = await harness()
- const controller = new AbortController()
- const seen: TaskSnapshot[] = []
- // The listener aborts after settlement has assigned delivery to this waiter
- // but before its resolve microtask; the waiter must still receive the result.
- ctx.tasks.onTaskDone((snapshot) => {
- seen.push(snapshot)
- controller.abort()
- })
- const p = producer()
- const id = ctx.tasks.start(p.spec)
- const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
- p.settle({ status: 'completed', detail: 'exit code: 0' })
- await expect(wait).resolves.toMatchObject({ status: 'completed', reported: true })
- expect(seen[0]).toMatchObject({ id, reported: true }) // suppression stays honest: the wait delivered
- })
- })
- describe('LocalTaskService owner isolation', () => {
- it('fences read/kill/wait to the owning session and keeps unowned tasks open', async () => {
- const ctx = await harness()
- const owner = stubAgent(ctx, 'owner')
- ctx.agents.register(owner)
- const other = stubAgent(ctx, 'other')
- const owned = ctx.tasks.start(producer({ owner }).spec)
- const open = ctx.tasks.start(producer().spec)
- // The owner and the unowned task are reachable.
- expect(ctx.tasks.read(owned, owner).snapshot.id).toBe(owned)
- expect(ctx.tasks.read(open, other).snapshot.id).toBe(open)
- // A different session and a no-agent caller are rejected.
- expect(() => ctx.tasks.read(owned, other)).toThrow(`task ${owned} belongs to another session`)
- expect(() => ctx.tasks.kill(owned, other)).toThrow('belongs to another session')
- await expect(ctx.tasks.wait(owned, 10, other)).rejects.toThrow('belongs to another session')
- expect(() => ctx.tasks.read(owned)).toThrow('belongs to another session')
- })
- it('list() shows only caller-owned plus unowned tasks', async () => {
- const ctx = await harness()
- const alice = stubAgent(ctx, 'alice')
- const bob = stubAgent(ctx, 'bob')
- ctx.agents.register(alice)
- ctx.agents.register(bob)
- const aliceTask = ctx.tasks.start(producer({ owner: alice }).spec)
- const bobTask = ctx.tasks.start(producer({ owner: bob }).spec)
- const openTask = ctx.tasks.start(producer({ kind: 'subagent' }).spec)
- expect(ctx.tasks.list(alice).map(t => t.id)).toEqual([aliceTask, openTask])
- expect(ctx.tasks.list(bob).map(t => t.id)).toEqual([bobTask, openTask])
- expect(ctx.tasks.list().map(t => t.id)).toEqual([openTask])
- })
- it('rejects an owned registration when no agent registry is mounted', async () => {
- const ctx = new Context()
- await ctx.plugin(LocalTaskService)
- ctx.tasks.attachSurface('test-surface')
- expect(() => ctx.tasks.start(producer({ owner: stubAgent(ctx, 'a') }).spec))
- .toThrow('background task ownership requires the agent registry')
- // The failed registration mutated nothing: no stored task, counter untouched.
- expect(ctx.tasks.list()).toEqual([])
- expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
- })
- it('a failed owner-cleanup attach leaves the registry unchanged and does not poison the owner', async () => {
- const ctx = await harness()
- const ghost = stubAgent(ctx, 'ghost') // never registered in ctx.agents
- // Exact-instance validation precedes registry mutation and cleanup attachment.
- expect(() => ctx.tasks.start(producer({ owner: ghost }).spec))
- .toThrow('is not the registered agent instance')
- expect(ctx.tasks.list(ghost)).toEqual([])
- // A later valid registration must still attach cleanup for the same object.
- ctx.agents.register(ghost)
- const cancels: (string | undefined)[] = []
- let settle!: (outcome: TaskOutcome) => void
- const id = ctx.tasks.start({
- kind: 'bash',
- label: 'after retry',
- owner: ghost,
- run: () => ({
- cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
- done: new Promise<TaskOutcome>((res) => { settle = res }),
- }),
- })
- expect(id).toBe('bash-1') // the failed attempt burned no counter
- await disposeAgentScope(ghost)
- expect(cancels).toEqual(['owner disposed'])
- expect(ctx.tasks.list(ghost)).toEqual([])
- })
- it('rejects a stale owner instance after another agent reuses its id', async () => {
- const ctx = await harness()
- const staleOwner = stubAgent(ctx, 'owner')
- const unregisterStale = ctx.agents.register(staleOwner)
- unregisterStale()
- const currentOwner = stubAgent(ctx, 'owner')
- ctx.agents.register(currentOwner)
- const current = producer({ owner: currentOwner })
- ctx.tasks.start(current.spec) // Attach the current owner's cleanup first.
- const stale = producer({ owner: staleOwner })
- const staleRun = vi.fn(() => stale.spec.run())
- expect(() => ctx.tasks.start({ ...stale.spec, run: staleRun }))
- .toThrow('is not the registered agent instance')
- expect(staleRun).not.toHaveBeenCalled()
- // Access is keyed by the unified session id, so a reconnect carrying the
- // same identity can observe the current task even though stale ownership
- // registration is rejected by exact-instance validation.
- expect(ctx.tasks.list(staleOwner)).toHaveLength(1)
- expect(ctx.tasks.list(currentOwner)).toHaveLength(1)
- current.settle({ status: 'completed' })
- await tick()
- await disposeAgentScope(currentOwner)
- })
- })
- describe('LocalTaskService owner cleanup', () => {
- it('drains the owner: cancels live tasks, awaits settlement, drops snapshots', async () => {
- const ctx = await harness()
- const owner = stubAgent(ctx, 'owner')
- ctx.agents.register(owner)
- // The producer settles only when cancelled — models a child that stops on request.
- let settle!: (outcome: TaskOutcome) => void
- const cancels: (string | undefined)[] = []
- ctx.tasks.start({
- kind: 'subagent',
- label: 'long research',
- owner,
- run: () => ({
- cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
- done: new Promise<TaskOutcome>((res) => { settle = res }),
- }),
- })
- const terminal = producer({ owner })
- ctx.tasks.start(terminal.spec)
- terminal.settle({ status: 'completed' })
- await tick()
- await disposeAgentScope(owner)
- expect(cancels).toEqual(['owner disposed'])
- // Snapshots dropped: nothing of the owner's remains, listing is empty.
- expect(ctx.tasks.list(owner)).toEqual([])
- })
- it('attaches one cleanup per owner and drains all owned tasks with the scope', async () => {
- const ctx = await harness()
- const owner = stubAgent(ctx, 'owner')
- ctx.agents.register(owner)
- const first = producer({ owner })
- const second = producer({ owner })
- ctx.tasks.start(first.spec)
- ctx.tasks.start(second.spec)
- first.settle({ status: 'completed' })
- second.settle({ status: 'completed' })
- await tick()
- expect(owner.ctx.fiber.getEffects().filter(effect => effect.label === 'tasks.ownerCleanup()')).toHaveLength(1)
- await disposeAgentScope(owner)
- expect(ctx.tasks.list(owner)).toEqual([])
- })
- it('does not let an old scope cleanup cancel a same-id/session replacement task', async () => {
- const ctx = await harness()
- const oldOwner = stubAgent(ctx, 'owner')
- const detachOld = ctx.agents.register(oldOwner)
- const cancels: string[] = []
- function start(owner: Agent, label: string): TaskId {
- let settle!: (outcome: TaskOutcome) => void
- return ctx.tasks.start({
- kind: 'bash',
- label,
- owner,
- run: () => ({
- cancel() { cancels.push(label); settle({ status: 'killed' }) },
- done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
- }),
- })
- }
- start(oldOwner, 'old task')
- detachOld()
- const replacement = stubAgent(ctx, 'owner')
- ctx.agents.register(replacement)
- const replacementId = start(replacement, 'replacement task')
- await disposeAgentScope(oldOwner)
- expect(cancels).toEqual(['old task'])
- expect(ctx.tasks.list(replacement).map(task => task.id)).toEqual([replacementId])
- await disposeAgentScope(replacement)
- expect(cancels).toEqual(['old task', 'replacement task'])
- })
- it('registers owner cleanup on the agent scope rather than the tasks fiber', async () => {
- const ctx = new Context()
- await ctx.plugin(AgentRegistry)
- const tasksFiber = await ctx.plugin(LocalTaskService)
- ctx.tasks.attachSurface('test-surface')
- const owner = stubAgent(ctx, 'owner')
- ctx.agents.register(owner)
- const ownerCleanupEffects = () => owner.ctx.fiber.getEffects()
- .filter(effect => effect.label === 'tasks.ownerCleanup()')
- const first = producer({ owner })
- ctx.tasks.start(first.spec)
- expect(ownerCleanupEffects()).toHaveLength(1)
- first.settle({ status: 'completed' })
- await tick()
- expect(tasksFiber.getEffects().some(effect => effect.label === 'tasks.ownerCleanup()')).toBe(false)
- await disposeAgentScope(owner)
- // Only the owner registration is released; the long-lived tasks service
- // and its own teardown effect remain active.
- expect(ownerCleanupEffects()).toHaveLength(0)
- expect(ctx.get('tasks')).toBeDefined()
- expect(tasksFiber.getEffects().some(effect => effect.label === 'tasks teardown')).toBe(true)
- })
- it('force-fails a throwing teardown cancel without awaiting producer done, first outcome wins', async () => {
- const ctx = await harness()
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
- const owner = stubAgent(ctx, 'owner')
- ctx.agents.register(owner)
- const seen: TaskSnapshot[] = []
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
- let settle!: (outcome: TaskOutcome) => void
- ctx.tasks.start({
- kind: 'bash',
- label: 'broken producer',
- owner,
- run: () => ({
- cancel() { throw new Error('cancel boom') },
- done: new Promise<TaskOutcome>((res) => { settle = res }),
- }),
- })
- const drain = disposeAgentScope(owner)
- let drained = false
- void drain.then(() => { drained = true })
- await tick()
- const drainedWithoutProducerDone = drained
- if (!drainedWithoutProducerDone) {
- // Release the producer if the assertion fails so the test can finish.
- settle({ status: 'completed' })
- await drain
- } else {
- // A late producer completion must not replace the failure or notify twice.
- settle({ status: 'completed' })
- await tick()
- }
- expect(drainedWithoutProducerDone).toBe(true)
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
- expect(seen).toHaveLength(1)
- expect(seen[0]?.status).toBe('failed')
- expect(seen[0]?.detail).toContain('cancel threw during teardown')
- expect(ctx.tasks.list(owner)).toEqual([])
- })
- })
- describe('LocalTaskService disposal', () => {
- it('cancels live tasks, awaits settlement, and silences listeners', async () => {
- const ctx = new Context()
- await ctx.plugin(AgentRegistry)
- const fiber = await ctx.plugin(LocalTaskService)
- const surface = await ctx.plugin(Object.assign((inner: Context) => {
- inner.tasks.attachSurface('test-surface')
- }, { inject: ['tasks'] }))
- void surface
- const seen: string[] = []
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
- let settle!: (outcome: TaskOutcome) => void
- const cancels: (string | undefined)[] = []
- ctx.tasks.start({
- kind: 'bash',
- label: 'sleep 600',
- run: () => ({
- cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
- done: new Promise<TaskOutcome>((res) => { settle = res }),
- }),
- })
- await fiber.dispose()
- expect(cancels).toEqual(['tasks service disposed'])
- // The teardown kill settles AFTER the listener registry closed: silent.
- expect(seen).toEqual([])
- })
- it('force-fails a throwing cancel so service disposal does not await producer done', async () => {
- const ctx = new Context()
- await ctx.plugin(AgentRegistry)
- const fiber = await ctx.plugin(LocalTaskService)
- ctx.tasks.attachSurface('test-surface')
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
- const seen: TaskSnapshot[] = []
- ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
- let settle!: (outcome: TaskOutcome) => void
- ctx.tasks.start({
- kind: 'bash',
- label: 'broken service task',
- run: () => ({
- cancel() { throw new Error('service cancel boom') },
- done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
- }),
- })
- const disposal = fiber.dispose()
- let disposed = false
- void disposal.then(() => { disposed = true })
- await tick()
- const disposedWithoutProducerDone = disposed
- if (!disposedWithoutProducerDone) {
- // Release the producer if the assertion fails so the test can finish.
- settle({ status: 'completed' })
- await disposal
- } else {
- settle({ status: 'completed' })
- await tick()
- }
- expect(disposedWithoutProducerDone).toBe(true)
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
- expect(seen).toEqual([])
- })
- it('detaches owner effects from still-live agent scopes when the service unloads', async () => {
- const ctx = new Context()
- await ctx.plugin(AgentRegistry)
- const tasksFiber = await ctx.plugin(LocalTaskService)
- ctx.tasks.attachSurface('test-surface')
- const owner = stubAgent(ctx, 'owner')
- ctx.agents.register(owner)
- let settle!: (outcome: TaskOutcome) => void
- ctx.tasks.start({
- kind: 'bash',
- label: 'owned work',
- owner,
- run: () => ({
- cancel() { settle({ status: 'killed' }) },
- done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
- }),
- })
- const ownerEffects = () => owner.ctx.fiber.getEffects()
- .filter(effect => effect.label === 'tasks.ownerCleanup()')
- expect(ownerEffects()).toHaveLength(1)
- await tasksFiber.dispose()
- expect(ownerEffects()).toHaveLength(0)
- })
- it('detaching the last surface re-arms the register fence', async () => {
- const ctx = new Context()
- await ctx.plugin(LocalTaskService)
- const detachA1 = ctx.tasks.attachSurface('a')
- const detachA2 = ctx.tasks.attachSurface('a') // duplicate name counts independently
- const fiber = await ctx.plugin(Object.assign((inner: Context) => {
- inner.tasks.attachSurface('b')
- }, { inject: ['tasks'] }))
- detachA1()
- detachA1() // second call of the same disposer is a no-op
- expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // a ×1 + b remain
- detachA2()
- expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains
- await fiber.dispose() // detaches b with its fiber (HMR safety)
- expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
- })
- })
|