tasks.spec.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761
  1. import { describe, expect, expectTypeOf, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
  5. import type { Agent } from '@deepseek-ai/dsh-agent'
  6. import { TaskId } from '@deepseek-ai/dsh-tasks'
  7. import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
  8. import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
  9. declare module '@deepseek-ai/dsh-tasks' {
  10. interface TaskKindMap {
  11. workflow: 'workflow'
  12. }
  13. }
  14. const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
  15. function stubAgent(ctx: Context, rawId: string): Agent {
  16. const id = SessionId(rawId)
  17. const scopeFiber = ctx.plugin(() => {})
  18. const agent = {
  19. id,
  20. options: {},
  21. session: new Session(id),
  22. status: 'idle' as const,
  23. acceptsNextStep: false,
  24. ctx: scopeFiber.ctx,
  25. followup: () => {},
  26. steer: () => {},
  27. inject: () => {},
  28. send: () => {},
  29. updateInbox: (): 'not-found' => 'not-found',
  30. cancel() {},
  31. whenIdle() { return Promise.resolve() },
  32. }
  33. agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
  34. return agent
  35. }
  36. async function disposeAgentScope(agent: Agent): Promise<void> {
  37. const dispose = agentScopeDisposers.get(agent)
  38. if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`)
  39. await dispose()
  40. }
  41. /** A controllable producer start-spec: settle its `done` on demand, record cancels. */
  42. function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
  43. let settle!: (outcome: TaskOutcome) => void
  44. let reject!: (error: unknown) => void
  45. const cancels: (string | undefined)[] = []
  46. const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
  47. const hooks: TaskHooks = {
  48. cancel(reason) { cancels.push(reason) },
  49. done: new Promise<TaskOutcome>((res, rej) => { settle = res; reject = rej }),
  50. ...hookOverrides,
  51. }
  52. const spec: TaskStart = {
  53. kind,
  54. label,
  55. ...owner !== undefined ? { owner } : {},
  56. ...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
  57. run: () => hooks,
  58. }
  59. return { spec, settle, reject, cancels }
  60. }
  61. async function harness() {
  62. const ctx = new Context()
  63. await ctx.plugin(AgentRegistry)
  64. await ctx.plugin(LocalTaskService)
  65. ctx.tasks.attachSurface('test-surface')
  66. return ctx
  67. }
  68. /** Let the settlement continuation (a `done.then`) run. */
  69. const tick = () => new Promise<void>(r => setTimeout(r, 0))
  70. /** Inspect the internal resolver registry to pin bounded retention while a task stays live. */
  71. function waitResolverCount(ctx: Context, id: TaskId): number {
  72. const service = ctx.tasks as unknown as { store: Map<TaskId, { waitResolvers: Set<() => void> }> }
  73. const task = service.store.get(id)
  74. if (task === undefined) throw new Error(`missing test task ${id}`)
  75. return task.waitResolvers.size
  76. }
  77. describe('LocalTaskService.start', () => {
  78. it('preserves the SessionId brand on public owner snapshots', () => {
  79. expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
  80. })
  81. it('refuses to register while no control surface is attached', async () => {
  82. const ctx = new Context()
  83. await ctx.plugin(LocalTaskService)
  84. expect(() => ctx.tasks.start(producer().spec))
  85. .toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
  86. })
  87. it('rejects an empty kind, empty label, and invalid output limit', async () => {
  88. const ctx = await harness()
  89. expect(() => ctx.tasks.start(producer({ kind: '' as TaskKind }).spec)).toThrow('invalid task kind')
  90. expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label')
  91. expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes')
  92. })
  93. it('issues kind-prefixed ids from per-kind counters', async () => {
  94. const ctx = await harness()
  95. expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
  96. expect(ctx.tasks.start(producer().spec)).toBe('bash-2')
  97. expect(ctx.tasks.start(producer({ kind: 'subagent' }).spec)).toBe('subagent-1')
  98. expect(ctx.tasks.start(producer({ kind: 'workflow' }).spec)).toBe('workflow-1')
  99. })
  100. })
  101. describe('LocalTaskService reads and settlement', () => {
  102. it('stream kinds read a consuming delta; terminal reads mark reported', async () => {
  103. const ctx = await harness()
  104. const chunks = ['first', '', 'rest']
  105. const p = producer({ readOutput: () => chunks.shift() ?? '' })
  106. const id = ctx.tasks.start(p.spec)
  107. expect(ctx.tasks.read(id)).toMatchObject({ text: 'first', snapshot: { status: 'running', reported: false } })
  108. expect(ctx.tasks.read(id).text).toBe('')
  109. p.settle({ status: 'completed', detail: 'exit code: 0' })
  110. await tick()
  111. const read = ctx.tasks.read(id)
  112. expect(read.text).toBe('rest')
  113. expect(read.snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', reported: true })
  114. expect(read.snapshot.finishedAt).toBeTypeOf('number')
  115. })
  116. it('projects a producer-owned model output limit into reads and snapshots', async () => {
  117. const ctx = await harness()
  118. const p = producer({ outputLimitBytes: 64, readOutput: () => 'delta' })
  119. const id = ctx.tasks.start(p.spec)
  120. expect(ctx.tasks.read(id)).toMatchObject({
  121. text: 'delta', snapshot: { outputLimitBytes: 64 },
  122. })
  123. expect(ctx.tasks.get(id)).toMatchObject({ outputLimitBytes: 64 })
  124. })
  125. it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => {
  126. const ctx = await harness()
  127. const p = producer({ kind: 'subagent', label: 'research task' })
  128. const id = ctx.tasks.start(p.spec)
  129. expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } })
  130. p.settle({ status: 'completed', output: 'final answer' })
  131. await tick()
  132. expect(ctx.tasks.read(id).text).toBe('final answer')
  133. expect(ctx.tasks.read(id).text).toBe('final answer') // idempotent, not consumed
  134. })
  135. it('a settled task without output reads as empty text', async () => {
  136. const ctx = await harness()
  137. const p = producer({ kind: 'subagent' })
  138. const id = ctx.tasks.start(p.spec)
  139. p.settle({ status: 'failed', detail: 'max-tokens' })
  140. await tick()
  141. expect(ctx.tasks.read(id)).toMatchObject({ text: '', snapshot: { status: 'failed', detail: 'max-tokens' } })
  142. })
  143. it('throws for unknown task ids', async () => {
  144. const ctx = await harness()
  145. expect(() => ctx.tasks.read(TaskId('bash-99'))).toThrow('unknown task bash-99')
  146. })
  147. it('notifies onTaskDone once per task with containment across listeners', async () => {
  148. const ctx = await harness()
  149. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  150. const seen: TaskSnapshot[] = []
  151. ctx.tasks.onTaskDone(() => { throw new Error('listener boom') })
  152. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
  153. const p = producer()
  154. const id = ctx.tasks.start(p.spec)
  155. p.settle({ status: 'completed', detail: 'exit code: 0' })
  156. await tick()
  157. expect(seen).toHaveLength(1)
  158. expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
  159. expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom'))
  160. })
  161. it('contains a rejecting onTaskDone listener without starving later listeners', async () => {
  162. const ctx = await harness()
  163. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  164. const seen: TaskId[] = []
  165. ctx.tasks.onTaskDone(async () => { throw new Error('async listener boom') })
  166. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
  167. const p = producer()
  168. const id = ctx.tasks.start(p.spec)
  169. p.settle({ status: 'completed' })
  170. await tick()
  171. expect(seen).toEqual([id])
  172. expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTaskDone listener rejected'))
  173. expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom'))
  174. })
  175. it('contains a rejecting done as a failed outcome (producer contract violation)', async () => {
  176. const ctx = await harness()
  177. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  178. const p = producer()
  179. const id = ctx.tasks.start(p.spec)
  180. p.reject(new Error('transport exploded'))
  181. await tick()
  182. expect(ctx.tasks.read(id).snapshot).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' })
  183. expect(warn).toHaveBeenCalledWith(expect.stringContaining('producer contract violation'))
  184. })
  185. it('unregisters onTaskDone listeners with the contributing fiber (HMR safety)', async () => {
  186. const ctx = await harness()
  187. const seen: string[] = []
  188. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  189. inner.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
  190. }, { inject: ['tasks'] }))
  191. await fiber.dispose()
  192. // The returned disposer detaches too (the non-fiber path).
  193. const detach = ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
  194. detach()
  195. const p = producer()
  196. ctx.tasks.start(p.spec)
  197. p.settle({ status: 'completed' })
  198. await tick()
  199. expect(seen).toEqual([])
  200. })
  201. })
  202. describe('LocalTaskService.kill', () => {
  203. it('cancels a live task with the forwarded reason and suppresses the notice', async () => {
  204. const ctx = await harness()
  205. const seen: TaskSnapshot[] = []
  206. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
  207. const p = producer()
  208. const id = ctx.tasks.start(p.spec)
  209. expect(ctx.tasks.kill(id, undefined, 'no longer needed')).toBe('requested')
  210. expect(p.cancels).toEqual(['no longer needed'])
  211. expect(ctx.tasks.list()[0]).toMatchObject({ status: 'stopping', reported: true })
  212. p.settle({ status: 'killed' })
  213. await tick()
  214. // The listener still fires (telemetry may care), but carries reported: true
  215. // so the notice surface suppresses its redundant "finished".
  216. expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true })
  217. })
  218. it('reports an already-finished task instead of failing', async () => {
  219. const ctx = await harness()
  220. const p = producer()
  221. const id = ctx.tasks.start(p.spec)
  222. p.settle({ status: 'completed' })
  223. await tick()
  224. expect(ctx.tasks.kill(id)).toBe('already-finished')
  225. })
  226. it('propagates a throwing producer cancel and leaves the task untouched', async () => {
  227. const ctx = await harness()
  228. const seen: TaskSnapshot[] = []
  229. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
  230. let broken = true
  231. let settle!: (outcome: TaskOutcome) => void
  232. const id = ctx.tasks.start({
  233. kind: 'bash',
  234. label: 'flaky cancel',
  235. run: () => ({
  236. cancel() { if (broken) throw new Error('cancel boom') },
  237. done: new Promise<TaskOutcome>((res) => { settle = res }),
  238. }),
  239. })
  240. expect(() => ctx.tasks.kill(id)).toThrow('cancel boom')
  241. // The failed kill mutated NOTHING: still running, notice not suppressed,
  242. // and a later (successful) kill still works.
  243. expect(ctx.tasks.get(id)).toMatchObject({ status: 'running', reported: false })
  244. settle({ status: 'completed' })
  245. await tick()
  246. expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire
  247. broken = false
  248. expect(ctx.tasks.kill(id)).toBe('already-finished')
  249. })
  250. })
  251. describe('LocalTaskService.wait', () => {
  252. it('resolves with the terminal snapshot when the task settles, marked reported', async () => {
  253. const ctx = await harness()
  254. const seen: TaskSnapshot[] = []
  255. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
  256. const p = producer()
  257. const id = ctx.tasks.start(p.spec)
  258. const wait = ctx.tasks.wait(id, 5_000)
  259. p.settle({ status: 'completed', detail: 'exit code: 0' })
  260. expect(await wait).toMatchObject({ status: 'completed', reported: true })
  261. // A waiting reader claims delivery before completion listeners inspect the snapshot.
  262. expect(seen[0]).toMatchObject({ id, reported: true })
  263. })
  264. it('returns the live snapshot on timeout without marking reported', async () => {
  265. const ctx = await harness()
  266. const id = ctx.tasks.start(producer().spec)
  267. expect(await ctx.tasks.wait(id, 5)).toMatchObject({ status: 'running', reported: false })
  268. })
  269. it('unregisters timed-out and aborted wait resolvers while the task remains live', async () => {
  270. const ctx = await harness()
  271. const id = ctx.tasks.start(producer().spec)
  272. for (let index = 0; index < 3; index += 1) {
  273. const wait = ctx.tasks.wait(id, 5)
  274. expect(waitResolverCount(ctx, id)).toBe(1)
  275. await expect(wait).resolves.toMatchObject({ status: 'running' })
  276. expect(waitResolverCount(ctx, id)).toBe(0)
  277. }
  278. const controller = new AbortController()
  279. const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
  280. expect(waitResolverCount(ctx, id)).toBe(1)
  281. controller.abort()
  282. await expect(wait).rejects.toThrow('wait aborted')
  283. expect(waitResolverCount(ctx, id)).toBe(0)
  284. expect(ctx.tasks.get(id).status).toBe('running')
  285. })
  286. it('returns immediately for an already-finished task', async () => {
  287. const ctx = await harness()
  288. const p = producer()
  289. const id = ctx.tasks.start(p.spec)
  290. p.settle({ status: 'completed' })
  291. await tick()
  292. expect(await ctx.tasks.wait(id, 5_000)).toMatchObject({ status: 'completed', reported: true })
  293. })
  294. it('rejects a non-positive or non-finite timeout', async () => {
  295. const ctx = await harness()
  296. const id = ctx.tasks.start(producer().spec)
  297. await expect(ctx.tasks.wait(id, 0)).rejects.toThrow('invalid wait timeout')
  298. await expect(ctx.tasks.wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout')
  299. })
  300. it('an aborted signal rejects the wait only — the task stays alive', async () => {
  301. const ctx = await harness()
  302. const id = ctx.tasks.start(producer().spec)
  303. const controller = new AbortController()
  304. const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
  305. controller.abort()
  306. await expect(wait).rejects.toThrow('wait aborted')
  307. expect(ctx.tasks.list()[0]).toMatchObject({ status: 'running' })
  308. const preAborted = new AbortController()
  309. preAborted.abort()
  310. await expect(ctx.tasks.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted')
  311. })
  312. it('an abort racing settlement in the same tick does not swallow the notice', async () => {
  313. const ctx = await harness()
  314. const seen: TaskSnapshot[] = []
  315. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
  316. const p = producer()
  317. const id = ctx.tasks.start(p.spec)
  318. const controller = new AbortController()
  319. const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
  320. // Settlement is queued first, so abort must remove the waiter synchronously;
  321. // otherwise settlement suppresses the notice for a reader that receives nothing.
  322. p.settle({ status: 'completed', detail: 'exit code: 0' })
  323. controller.abort()
  324. await expect(wait).rejects.toThrow('wait aborted')
  325. expect(seen).toHaveLength(1)
  326. expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false })
  327. })
  328. it('an abort landing after settlement still delivers the terminal snapshot it owes', async () => {
  329. const ctx = await harness()
  330. const controller = new AbortController()
  331. const seen: TaskSnapshot[] = []
  332. // The listener aborts after settlement has assigned delivery to this waiter
  333. // but before its resolve microtask; the waiter must still receive the result.
  334. ctx.tasks.onTaskDone((snapshot) => {
  335. seen.push(snapshot)
  336. controller.abort()
  337. })
  338. const p = producer()
  339. const id = ctx.tasks.start(p.spec)
  340. const wait = ctx.tasks.wait(id, 5_000, undefined, controller.signal)
  341. p.settle({ status: 'completed', detail: 'exit code: 0' })
  342. await expect(wait).resolves.toMatchObject({ status: 'completed', reported: true })
  343. expect(seen[0]).toMatchObject({ id, reported: true }) // suppression stays honest: the wait delivered
  344. })
  345. })
  346. describe('LocalTaskService owner isolation', () => {
  347. it('fences read/kill/wait to the owning session and keeps unowned tasks open', async () => {
  348. const ctx = await harness()
  349. const owner = stubAgent(ctx, 'owner')
  350. ctx.agents.register(owner)
  351. const other = stubAgent(ctx, 'other')
  352. const owned = ctx.tasks.start(producer({ owner }).spec)
  353. const open = ctx.tasks.start(producer().spec)
  354. // The owner and the unowned task are reachable.
  355. expect(ctx.tasks.read(owned, owner).snapshot.id).toBe(owned)
  356. expect(ctx.tasks.read(open, other).snapshot.id).toBe(open)
  357. // A different session and a no-agent caller are rejected.
  358. expect(() => ctx.tasks.read(owned, other)).toThrow(`task ${owned} belongs to another session`)
  359. expect(() => ctx.tasks.kill(owned, other)).toThrow('belongs to another session')
  360. await expect(ctx.tasks.wait(owned, 10, other)).rejects.toThrow('belongs to another session')
  361. expect(() => ctx.tasks.read(owned)).toThrow('belongs to another session')
  362. })
  363. it('list() shows only caller-owned plus unowned tasks', async () => {
  364. const ctx = await harness()
  365. const alice = stubAgent(ctx, 'alice')
  366. const bob = stubAgent(ctx, 'bob')
  367. ctx.agents.register(alice)
  368. ctx.agents.register(bob)
  369. const aliceTask = ctx.tasks.start(producer({ owner: alice }).spec)
  370. const bobTask = ctx.tasks.start(producer({ owner: bob }).spec)
  371. const openTask = ctx.tasks.start(producer({ kind: 'subagent' }).spec)
  372. expect(ctx.tasks.list(alice).map(t => t.id)).toEqual([aliceTask, openTask])
  373. expect(ctx.tasks.list(bob).map(t => t.id)).toEqual([bobTask, openTask])
  374. expect(ctx.tasks.list().map(t => t.id)).toEqual([openTask])
  375. })
  376. it('rejects an owned registration when no agent registry is mounted', async () => {
  377. const ctx = new Context()
  378. await ctx.plugin(LocalTaskService)
  379. ctx.tasks.attachSurface('test-surface')
  380. expect(() => ctx.tasks.start(producer({ owner: stubAgent(ctx, 'a') }).spec))
  381. .toThrow('background task ownership requires the agent registry')
  382. // The failed registration mutated nothing: no stored task, counter untouched.
  383. expect(ctx.tasks.list()).toEqual([])
  384. expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
  385. })
  386. it('a failed owner-cleanup attach leaves the registry unchanged and does not poison the owner', async () => {
  387. const ctx = await harness()
  388. const ghost = stubAgent(ctx, 'ghost') // never registered in ctx.agents
  389. // Exact-instance validation precedes registry mutation and cleanup attachment.
  390. expect(() => ctx.tasks.start(producer({ owner: ghost }).spec))
  391. .toThrow('is not the registered agent instance')
  392. expect(ctx.tasks.list(ghost)).toEqual([])
  393. // A later valid registration must still attach cleanup for the same object.
  394. ctx.agents.register(ghost)
  395. const cancels: (string | undefined)[] = []
  396. let settle!: (outcome: TaskOutcome) => void
  397. const id = ctx.tasks.start({
  398. kind: 'bash',
  399. label: 'after retry',
  400. owner: ghost,
  401. run: () => ({
  402. cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
  403. done: new Promise<TaskOutcome>((res) => { settle = res }),
  404. }),
  405. })
  406. expect(id).toBe('bash-1') // the failed attempt burned no counter
  407. await disposeAgentScope(ghost)
  408. expect(cancels).toEqual(['owner disposed'])
  409. expect(ctx.tasks.list(ghost)).toEqual([])
  410. })
  411. it('rejects a stale owner instance after another agent reuses its id', async () => {
  412. const ctx = await harness()
  413. const staleOwner = stubAgent(ctx, 'owner')
  414. const unregisterStale = ctx.agents.register(staleOwner)
  415. unregisterStale()
  416. const currentOwner = stubAgent(ctx, 'owner')
  417. ctx.agents.register(currentOwner)
  418. const current = producer({ owner: currentOwner })
  419. ctx.tasks.start(current.spec) // Attach the current owner's cleanup first.
  420. const stale = producer({ owner: staleOwner })
  421. const staleRun = vi.fn(() => stale.spec.run())
  422. expect(() => ctx.tasks.start({ ...stale.spec, run: staleRun }))
  423. .toThrow('is not the registered agent instance')
  424. expect(staleRun).not.toHaveBeenCalled()
  425. // Access is keyed by the unified session id, so a reconnect carrying the
  426. // same identity can observe the current task even though stale ownership
  427. // registration is rejected by exact-instance validation.
  428. expect(ctx.tasks.list(staleOwner)).toHaveLength(1)
  429. expect(ctx.tasks.list(currentOwner)).toHaveLength(1)
  430. current.settle({ status: 'completed' })
  431. await tick()
  432. await disposeAgentScope(currentOwner)
  433. })
  434. })
  435. describe('LocalTaskService owner cleanup', () => {
  436. it('drains the owner: cancels live tasks, awaits settlement, drops snapshots', async () => {
  437. const ctx = await harness()
  438. const owner = stubAgent(ctx, 'owner')
  439. ctx.agents.register(owner)
  440. // The producer settles only when cancelled — models a child that stops on request.
  441. let settle!: (outcome: TaskOutcome) => void
  442. const cancels: (string | undefined)[] = []
  443. ctx.tasks.start({
  444. kind: 'subagent',
  445. label: 'long research',
  446. owner,
  447. run: () => ({
  448. cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
  449. done: new Promise<TaskOutcome>((res) => { settle = res }),
  450. }),
  451. })
  452. const terminal = producer({ owner })
  453. ctx.tasks.start(terminal.spec)
  454. terminal.settle({ status: 'completed' })
  455. await tick()
  456. await disposeAgentScope(owner)
  457. expect(cancels).toEqual(['owner disposed'])
  458. // Snapshots dropped: nothing of the owner's remains, listing is empty.
  459. expect(ctx.tasks.list(owner)).toEqual([])
  460. })
  461. it('attaches one cleanup per owner and drains all owned tasks with the scope', async () => {
  462. const ctx = await harness()
  463. const owner = stubAgent(ctx, 'owner')
  464. ctx.agents.register(owner)
  465. const first = producer({ owner })
  466. const second = producer({ owner })
  467. ctx.tasks.start(first.spec)
  468. ctx.tasks.start(second.spec)
  469. first.settle({ status: 'completed' })
  470. second.settle({ status: 'completed' })
  471. await tick()
  472. expect(owner.ctx.fiber.getEffects().filter(effect => effect.label === 'tasks.ownerCleanup()')).toHaveLength(1)
  473. await disposeAgentScope(owner)
  474. expect(ctx.tasks.list(owner)).toEqual([])
  475. })
  476. it('does not let an old scope cleanup cancel a same-id/session replacement task', async () => {
  477. const ctx = await harness()
  478. const oldOwner = stubAgent(ctx, 'owner')
  479. const detachOld = ctx.agents.register(oldOwner)
  480. const cancels: string[] = []
  481. function start(owner: Agent, label: string): TaskId {
  482. let settle!: (outcome: TaskOutcome) => void
  483. return ctx.tasks.start({
  484. kind: 'bash',
  485. label,
  486. owner,
  487. run: () => ({
  488. cancel() { cancels.push(label); settle({ status: 'killed' }) },
  489. done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
  490. }),
  491. })
  492. }
  493. start(oldOwner, 'old task')
  494. detachOld()
  495. const replacement = stubAgent(ctx, 'owner')
  496. ctx.agents.register(replacement)
  497. const replacementId = start(replacement, 'replacement task')
  498. await disposeAgentScope(oldOwner)
  499. expect(cancels).toEqual(['old task'])
  500. expect(ctx.tasks.list(replacement).map(task => task.id)).toEqual([replacementId])
  501. await disposeAgentScope(replacement)
  502. expect(cancels).toEqual(['old task', 'replacement task'])
  503. })
  504. it('registers owner cleanup on the agent scope rather than the tasks fiber', async () => {
  505. const ctx = new Context()
  506. await ctx.plugin(AgentRegistry)
  507. const tasksFiber = await ctx.plugin(LocalTaskService)
  508. ctx.tasks.attachSurface('test-surface')
  509. const owner = stubAgent(ctx, 'owner')
  510. ctx.agents.register(owner)
  511. const ownerCleanupEffects = () => owner.ctx.fiber.getEffects()
  512. .filter(effect => effect.label === 'tasks.ownerCleanup()')
  513. const first = producer({ owner })
  514. ctx.tasks.start(first.spec)
  515. expect(ownerCleanupEffects()).toHaveLength(1)
  516. first.settle({ status: 'completed' })
  517. await tick()
  518. expect(tasksFiber.getEffects().some(effect => effect.label === 'tasks.ownerCleanup()')).toBe(false)
  519. await disposeAgentScope(owner)
  520. // Only the owner registration is released; the long-lived tasks service
  521. // and its own teardown effect remain active.
  522. expect(ownerCleanupEffects()).toHaveLength(0)
  523. expect(ctx.get('tasks')).toBeDefined()
  524. expect(tasksFiber.getEffects().some(effect => effect.label === 'tasks teardown')).toBe(true)
  525. })
  526. it('force-fails a throwing teardown cancel without awaiting producer done, first outcome wins', async () => {
  527. const ctx = await harness()
  528. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  529. const owner = stubAgent(ctx, 'owner')
  530. ctx.agents.register(owner)
  531. const seen: TaskSnapshot[] = []
  532. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
  533. let settle!: (outcome: TaskOutcome) => void
  534. ctx.tasks.start({
  535. kind: 'bash',
  536. label: 'broken producer',
  537. owner,
  538. run: () => ({
  539. cancel() { throw new Error('cancel boom') },
  540. done: new Promise<TaskOutcome>((res) => { settle = res }),
  541. }),
  542. })
  543. const drain = disposeAgentScope(owner)
  544. let drained = false
  545. void drain.then(() => { drained = true })
  546. await tick()
  547. const drainedWithoutProducerDone = drained
  548. if (!drainedWithoutProducerDone) {
  549. // Release the producer if the assertion fails so the test can finish.
  550. settle({ status: 'completed' })
  551. await drain
  552. } else {
  553. // A late producer completion must not replace the failure or notify twice.
  554. settle({ status: 'completed' })
  555. await tick()
  556. }
  557. expect(drainedWithoutProducerDone).toBe(true)
  558. expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
  559. expect(seen).toHaveLength(1)
  560. expect(seen[0]?.status).toBe('failed')
  561. expect(seen[0]?.detail).toContain('cancel threw during teardown')
  562. expect(ctx.tasks.list(owner)).toEqual([])
  563. })
  564. })
  565. describe('LocalTaskService disposal', () => {
  566. it('cancels live tasks, awaits settlement, and silences listeners', async () => {
  567. const ctx = new Context()
  568. await ctx.plugin(AgentRegistry)
  569. const fiber = await ctx.plugin(LocalTaskService)
  570. const surface = await ctx.plugin(Object.assign((inner: Context) => {
  571. inner.tasks.attachSurface('test-surface')
  572. }, { inject: ['tasks'] }))
  573. void surface
  574. const seen: string[] = []
  575. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
  576. let settle!: (outcome: TaskOutcome) => void
  577. const cancels: (string | undefined)[] = []
  578. ctx.tasks.start({
  579. kind: 'bash',
  580. label: 'sleep 600',
  581. run: () => ({
  582. cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) },
  583. done: new Promise<TaskOutcome>((res) => { settle = res }),
  584. }),
  585. })
  586. await fiber.dispose()
  587. expect(cancels).toEqual(['tasks service disposed'])
  588. // The teardown kill settles AFTER the listener registry closed: silent.
  589. expect(seen).toEqual([])
  590. })
  591. it('force-fails a throwing cancel so service disposal does not await producer done', async () => {
  592. const ctx = new Context()
  593. await ctx.plugin(AgentRegistry)
  594. const fiber = await ctx.plugin(LocalTaskService)
  595. ctx.tasks.attachSurface('test-surface')
  596. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  597. const seen: TaskSnapshot[] = []
  598. ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
  599. let settle!: (outcome: TaskOutcome) => void
  600. ctx.tasks.start({
  601. kind: 'bash',
  602. label: 'broken service task',
  603. run: () => ({
  604. cancel() { throw new Error('service cancel boom') },
  605. done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
  606. }),
  607. })
  608. const disposal = fiber.dispose()
  609. let disposed = false
  610. void disposal.then(() => { disposed = true })
  611. await tick()
  612. const disposedWithoutProducerDone = disposed
  613. if (!disposedWithoutProducerDone) {
  614. // Release the producer if the assertion fails so the test can finish.
  615. settle({ status: 'completed' })
  616. await disposal
  617. } else {
  618. settle({ status: 'completed' })
  619. await tick()
  620. }
  621. expect(disposedWithoutProducerDone).toBe(true)
  622. expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
  623. expect(seen).toEqual([])
  624. })
  625. it('detaches owner effects from still-live agent scopes when the service unloads', async () => {
  626. const ctx = new Context()
  627. await ctx.plugin(AgentRegistry)
  628. const tasksFiber = await ctx.plugin(LocalTaskService)
  629. ctx.tasks.attachSurface('test-surface')
  630. const owner = stubAgent(ctx, 'owner')
  631. ctx.agents.register(owner)
  632. let settle!: (outcome: TaskOutcome) => void
  633. ctx.tasks.start({
  634. kind: 'bash',
  635. label: 'owned work',
  636. owner,
  637. run: () => ({
  638. cancel() { settle({ status: 'killed' }) },
  639. done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
  640. }),
  641. })
  642. const ownerEffects = () => owner.ctx.fiber.getEffects()
  643. .filter(effect => effect.label === 'tasks.ownerCleanup()')
  644. expect(ownerEffects()).toHaveLength(1)
  645. await tasksFiber.dispose()
  646. expect(ownerEffects()).toHaveLength(0)
  647. })
  648. it('detaching the last surface re-arms the register fence', async () => {
  649. const ctx = new Context()
  650. await ctx.plugin(LocalTaskService)
  651. const detachA1 = ctx.tasks.attachSurface('a')
  652. const detachA2 = ctx.tasks.attachSurface('a') // duplicate name counts independently
  653. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  654. inner.tasks.attachSurface('b')
  655. }, { inject: ['tasks'] }))
  656. detachA1()
  657. detachA1() // second call of the same disposer is a no-op
  658. expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // a ×1 + b remain
  659. detachA2()
  660. expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains
  661. await fiber.dispose() // detaches b with its fiber (HMR safety)
  662. expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
  663. })
  664. })