index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. /**
  2. * Process-local implementation of the background task registry seam
  3. * (`ctx.tasks`). It keeps every record in memory and hands out fresh
  4. * snapshots, never live state.
  5. *
  6. * Registrations outlive producer and control-surface fibers. Agent or service
  7. * disposal cancels live work and awaits compliant producers; a throwing
  8. * teardown cancel force-fails only the record and reports a possible orphan.
  9. * @module @deepseek-ai/dsh-tasks-local
  10. */
  11. import { Context } from 'cordis'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  14. import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks'
  15. import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks'
  16. /** Timeout code that distinguishes a bounded wait from caller cancellation. */
  17. export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
  18. /** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */
  19. interface TrackedTask {
  20. id: TaskId
  21. kind: TaskKind
  22. label: string
  23. outputLimitBytes: number | undefined
  24. /** Exact lifecycle owner; session-id authorization is derived from it. */
  25. owner: Agent | undefined
  26. cancel: (reason?: string) => void
  27. readOutput: (() => string) | undefined
  28. status: TaskStatus
  29. detail: string | undefined
  30. output: string | undefined
  31. startedAt: number
  32. finishedAt: number | undefined
  33. reported: boolean
  34. /** Resolves once the terminal snapshot is recorded and listeners notified. */
  35. settled: Promise<void>
  36. /** Resolver for {@link settled}, called by the first effective settlement. */
  37. markSettled: () => void
  38. /** Live waits; settlement with a waiter marks the task reported. */
  39. waiters: number
  40. /** Removable resolvers for live waits; timeout/abort unregister before the task settles. */
  41. waitResolvers: Set<() => void>
  42. }
  43. /** True for the three terminal {@link TaskStatus} values. */
  44. function isTerminal(status: TaskStatus): boolean {
  45. return status === 'completed' || status === 'killed' || status === 'failed'
  46. }
  47. /**
  48. * The in-memory `tasks` registry. See the seam contract in
  49. * `@deepseek-ai/dsh-tasks` for the ownership, isolation, and lifecycle
  50. * semantics this implementation honors.
  51. */
  52. export class LocalTaskService extends TaskService {
  53. private store = new Map<TaskId, TrackedTask>()
  54. private counters = new Map<string, number>()
  55. private surfaces = new Set<symbol>()
  56. private listeners = new Set<TaskDoneListener>()
  57. private listenersClosed = false
  58. /** Owner agents with attached scope cleanup, mapped to the exact disposer. */
  59. private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
  60. /** Service context used by detached settlement continuations and teardown. */
  61. private readonly selfCtx: Context
  62. constructor(ctx: Context) {
  63. super(ctx)
  64. this.selfCtx = ctx
  65. ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
  66. }
  67. start(spec: TaskStart): TaskId {
  68. if (this.surfaces.size === 0) {
  69. throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
  70. }
  71. if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
  72. if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
  73. if (spec.outputLimitBytes !== undefined
  74. && (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) {
  75. throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`)
  76. }
  77. if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
  78. const hooks = spec.run()
  79. const count = (this.counters.get(spec.kind) ?? 0) + 1
  80. this.counters.set(spec.kind, count)
  81. const id = TaskId(`${spec.kind}-${count}`)
  82. let markSettled!: () => void
  83. const settled = new Promise<void>((resolve) => { markSettled = resolve })
  84. const task: TrackedTask = {
  85. id,
  86. kind: spec.kind,
  87. label: spec.label,
  88. outputLimitBytes: spec.outputLimitBytes,
  89. owner: spec.owner,
  90. cancel: hooks.cancel.bind(hooks),
  91. readOutput: hooks.readOutput?.bind(hooks),
  92. status: 'running',
  93. detail: undefined,
  94. output: undefined,
  95. startedAt: Date.now(),
  96. finishedAt: undefined,
  97. reported: false,
  98. settled,
  99. markSettled,
  100. waiters: 0,
  101. waitResolvers: new Set(),
  102. }
  103. this.store.set(id, task)
  104. void hooks.done.then(
  105. (outcome) => { this.settle(task, outcome) },
  106. (error: unknown) => {
  107. // Contain a producer contract violation so cleanup and waiters cannot hang.
  108. this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`)
  109. this.settle(task, { status: 'failed', detail: String(error) })
  110. },
  111. )
  112. return id
  113. }
  114. list(caller?: Agent): TaskSnapshot[] {
  115. const session = caller?.id
  116. return [...this.store.values()]
  117. .filter(task => task.owner === undefined || task.owner.id === session)
  118. .map(task => this.snapshot(task))
  119. }
  120. get(id: TaskId, caller?: Agent): TaskSnapshot {
  121. const task = this.expect(id)
  122. this.assertAccess(task, caller)
  123. return this.snapshot(task)
  124. }
  125. read(id: TaskId, caller?: Agent): TaskRead {
  126. const task = this.expect(id)
  127. this.assertAccess(task, caller)
  128. const text = task.readOutput !== undefined
  129. ? task.readOutput()
  130. : isTerminal(task.status) ? task.output ?? '' : ''
  131. if (isTerminal(task.status)) task.reported = true
  132. return { text, snapshot: this.snapshot(task) }
  133. }
  134. kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' {
  135. const task = this.expect(id)
  136. this.assertAccess(task, caller)
  137. if (isTerminal(task.status)) {
  138. task.reported = true
  139. return 'already-finished'
  140. }
  141. // Cancel first so a throw leaves both lifecycle and notice state unchanged.
  142. task.cancel(reason)
  143. task.status = 'stopping'
  144. task.reported = true
  145. return 'requested'
  146. }
  147. async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> {
  148. const task = this.expect(id)
  149. this.assertAccess(task, caller)
  150. if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
  151. throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`)
  152. }
  153. if (!isTerminal(task.status)) {
  154. if (signal?.aborted) throw new Error('wait aborted')
  155. // Abort removes the waiter synchronously so same-tick settlement cannot
  156. // suppress a notice for a wait that will reject.
  157. task.waiters += 1
  158. let counted = true
  159. const uncount = (): void => {
  160. if (!counted) return
  161. counted = false
  162. task.waiters -= 1
  163. }
  164. try {
  165. // The scoped deadline distinguishes a successful wait timeout from
  166. // caller cancellation and clears its timer on every exit.
  167. using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT)
  168. await new Promise<void>((resolve, reject) => {
  169. const onSettled = (): void => {
  170. task.waitResolvers.delete(onSettled)
  171. d.signal.removeEventListener('abort', onAbort)
  172. resolve()
  173. }
  174. const onAbort = (): void => {
  175. task.waitResolvers.delete(onSettled)
  176. if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
  177. resolve()
  178. } else if (isTerminal(task.status)) {
  179. // Settlement suppressed the notice for this waiter; deliver it.
  180. resolve()
  181. } else {
  182. uncount()
  183. reject(new Error('wait aborted'))
  184. }
  185. }
  186. task.waitResolvers.add(onSettled)
  187. d.signal.addEventListener('abort', onAbort, { once: true })
  188. })
  189. } finally {
  190. uncount()
  191. }
  192. }
  193. if (isTerminal(task.status)) task.reported = true
  194. return this.snapshot(task)
  195. }
  196. onTaskDone(listener: TaskDoneListener): () => void {
  197. const dispose = this.ctx.effect(() => {
  198. this.listeners.add(listener)
  199. return () => this.listeners.delete(listener)
  200. }, 'tasks.onTaskDone()')
  201. return () => void dispose()
  202. }
  203. attachSurface(name: string): () => void {
  204. // One token per call keeps duplicate labels independently disposable.
  205. const token = Symbol(name)
  206. const dispose = this.ctx.effect(() => {
  207. this.surfaces.add(token)
  208. return () => this.surfaces.delete(token)
  209. }, 'tasks.attachSurface()')
  210. return () => void dispose()
  211. }
  212. /** Look up a task or fail loud. */
  213. private expect(id: TaskId): TrackedTask {
  214. const task = this.store.get(id)
  215. if (task === undefined) throw new Error(`unknown task ${id}`)
  216. return task
  217. }
  218. /**
  219. * The isolation fence: a task with an owner is reachable only by callers
  220. * whose session id matches (`!== undefined` semantics — an unowned task is
  221. * open, and a no-agent caller can never match an owned one).
  222. */
  223. private assertAccess(task: TrackedTask, caller?: Agent): void {
  224. if (task.owner !== undefined && task.owner.id !== caller?.id) {
  225. throw new Error(`task ${task.id} belongs to another session`)
  226. }
  227. }
  228. /** Project a fresh read-only snapshot from the mutable record. */
  229. private snapshot(task: TrackedTask): TaskSnapshot {
  230. const ownerSession = task.owner?.id
  231. return {
  232. id: task.id,
  233. kind: task.kind,
  234. label: task.label,
  235. ...task.outputLimitBytes !== undefined ? { outputLimitBytes: task.outputLimitBytes } : {},
  236. ...ownerSession !== undefined ? { ownerSession } : {},
  237. status: task.status,
  238. ...task.detail !== undefined ? { detail: task.detail } : {},
  239. startedAt: task.startedAt,
  240. ...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {},
  241. reported: task.reported,
  242. }
  243. }
  244. /**
  245. * Record the first terminal outcome, notify contained listeners, and release
  246. * waiters. First-wins preserves a teardown force-failure against late producer
  247. * settlement. Pending waits mark the task reported before listeners run.
  248. */
  249. private settle(task: TrackedTask, outcome: TaskOutcome): void {
  250. if (isTerminal(task.status)) return
  251. task.status = outcome.status
  252. task.detail = outcome.detail
  253. task.output = outcome.output
  254. task.finishedAt = Date.now()
  255. if (task.waiters > 0) task.reported = true
  256. if (!this.listenersClosed) {
  257. const snapshot = this.snapshot(task)
  258. for (const listener of this.listeners) {
  259. try {
  260. const returned = listener(snapshot, task.owner)
  261. void Promise.resolve(returned).catch((error: unknown) => {
  262. this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
  263. })
  264. } catch (error: unknown) {
  265. this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
  266. }
  267. }
  268. }
  269. const waitResolvers = [...task.waitResolvers]
  270. task.waitResolvers.clear()
  271. for (const resolveWait of waitResolvers) resolveWait()
  272. task.markSettled()
  273. }
  274. /**
  275. * Attach one awaited cleanup through the exact owner's scope. This survives
  276. * producer reloads and joins agent quiescence; the retained disposer lets
  277. * service teardown detach the cross-fiber effect. Fails when the registry is
  278. * absent or the owner is not its currently registered instance.
  279. */
  280. private ensureOwnerCleanup(owner: Agent): void {
  281. const ownerId = owner.id
  282. const agents = this.selfCtx.get('agents')
  283. if (agents === undefined) {
  284. throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
  285. }
  286. if (agents.get(ownerId) !== owner) {
  287. throw new Error(`agent "${ownerId}" is not the registered agent instance (background task owner must be live)`)
  288. }
  289. if (this.ownerCleanups.has(owner)) return
  290. // Record only after attach succeeds; a disposing scope rejects new effects.
  291. const detach = owner.ctx.effect(() => async () => {
  292. this.ownerCleanups.delete(owner)
  293. await this.disposeOwned(owner)
  294. }, 'tasks.ownerCleanup()')
  295. this.ownerCleanups.set(owner, detach)
  296. }
  297. /** Cancel, await terminal records, and drop every task owned by one exact agent lifecycle. */
  298. private async disposeOwned(owner: Agent): Promise<void> {
  299. const owned = [...this.store.values()].filter(task => task.owner === owner)
  300. this.cancelForTeardown(owned, 'owner disposed')
  301. await Promise.all(owned.map(task => task.settled))
  302. for (const task of owned) this.store.delete(task.id)
  303. }
  304. /**
  305. * Close listeners, cancel live tasks, await settlement, and detach owner
  306. * effects. Throwing cancels are force-failed to avoid teardown deadlock.
  307. */
  308. private async disposeAll(): Promise<void> {
  309. this.listenersClosed = true
  310. this.listeners.clear()
  311. const all = [...this.store.values()]
  312. this.cancelForTeardown(all, 'tasks service disposed')
  313. await Promise.all(all.map(task => task.settled))
  314. this.store.clear()
  315. // Detach cross-fiber owner effects after the shared store is quiescent.
  316. const ownerCleanups = [...this.ownerCleanups.values()]
  317. this.ownerCleanups.clear()
  318. await Promise.all(ownerCleanups.map(cleanup => Promise.resolve(cleanup())))
  319. }
  320. /**
  321. * Cancel tasks during teardown with per-task containment. A throwing cancel
  322. * force-fails the record and reports a possible orphan; a cancel that returns
  323. * without settling remains indistinguishable from a slow stop and may stall.
  324. */
  325. private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
  326. for (const task of tasks) {
  327. if (isTerminal(task.status)) continue
  328. try {
  329. task.cancel(reason)
  330. task.status = 'stopping'
  331. } catch (error: unknown) {
  332. const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`
  333. this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`)
  334. this.settle(task, { status: 'failed', detail })
  335. }
  336. }
  337. }
  338. }
  339. export default LocalTaskService