refactor(tasks): split the task registry into seam and local implementation

The tasks/ family now matches the capability-seam shape: @deepseek-ai/dsh-tasks
keeps the abstract TaskService (ctx.tasks contract, vocabulary types, snapshot
invariant companion) and the new @deepseek-ai/dsh-tasks-local carries the
process-local registry (LocalTaskService: in-memory store, settlement,
owner-cleanup effects, teardown, TASK_WAIT_TIMEOUT). Compositions and test
harnesses now load dsh-tasks-local; producers, TaskKindMap merges, and
dsh-tool-tasks keep importing the seam only.

Producer misconfiguration diagnostics name dsh-tasks-local because loading the
implementation is the fix. The registry behavior suite moves to tasks-local;
the seam keeps a stub-subclass registration test and the probe-based invariant
suite.
This commit is contained in:
Tianyi Cui
2026-07-26 05:13:39 +08:00
parent b5bba5adee
commit 698b391bd6
49 changed files with 851 additions and 458 deletions

View File

@@ -1,10 +1,11 @@
# tasks/ — background task capability family
The shared home for background-task ids, owner isolation, reads, cancellation, waiting, and completion notices. Bash, subagents, and future long-running tools use one model-facing protocol. See the [background-task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
The shared home for background-task ids, owner isolation, reads, cancellation, waiting, and completion notices. Bash, subagents, and future long-running tools use one model-facing protocol. See the [background-task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and the [task-registry seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md).
| Package | ctx key | Role |
|---|---|---|
| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry service: branded `<kind>-N` ids, owner-fenced read/kill/wait/list, settlement bookkeeping, the awaited owner-cleanup path, and the `attachSurface` misconfiguration fence |
| [`tasks`](tasks/README.md) (`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | The registry seam: branded `<kind>-N` ids, the owner-fenced read/kill/wait/list contract, snapshot vocabulary, the `attachSurface` misconfiguration fence, and the snapshot invariant companion |
| [`tasks-local`](tasks-local/README.md) (`@deepseek-ai/dsh-tasks-local`) | — | The process-local registry implementation: in-memory records, first-wins settlement bookkeeping, and the awaited owner-cleanup and teardown paths |
| [`tool-tasks`](tool-tasks/README.md) (`@deepseek-ai/dsh-tool-tasks`) | — | The model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection, and the background-habit prompt section |
The registry owns state across producer or surface reloads; the tool package owns presentation. Producers register execution hooks through `ctx.tasks.start` and own whether their config exposes `run_in_background`.

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-tasks-local
Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry seam: `LocalTaskService` keeps every record in memory, issues per-kind `<kind>-N` ids, and hands out fresh snapshots, never live state. It has no config; load it as a plugin and it registers as `ctx.tasks`.
## Lifecycle
Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion surfaces do not duplicate notices.
## Model Experience
Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Tasks are process-local** — records die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam.
- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely.

View File

@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-tasks-local",
"description": "Process-local implementation of the DeepSeek Harness background task registry seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,365 @@
/**
* Process-local implementation of the background task registry seam
* (`ctx.tasks`). It keeps every record in memory and hands out fresh
* snapshots, never live state.
*
* Registrations outlive producer and control-surface fibers. Agent or service
* disposal cancels live work and awaits compliant producers; a throwing
* teardown cancel force-fails only the record and reports a possible orphan.
* @module @deepseek-ai/dsh-tasks-local
*/
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks'
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
/** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */
interface TrackedTask {
id: TaskId
kind: TaskKind
label: string
outputLimitBytes: number | undefined
/** Exact lifecycle owner; session-id authorization is derived from it. */
owner: Agent | undefined
cancel: (reason?: string) => void
readOutput: (() => string) | undefined
status: TaskStatus
detail: string | undefined
output: string | undefined
startedAt: number
finishedAt: number | undefined
reported: boolean
/** Resolves once the terminal snapshot is recorded and listeners notified. */
settled: Promise<void>
/** Resolver for {@link settled}, called by the first effective settlement. */
markSettled: () => void
/** Live waits; settlement with a waiter marks the task reported. */
waiters: number
/** Removable resolvers for live waits; timeout/abort unregister before the task settles. */
waitResolvers: Set<() => void>
}
/** True for the three terminal {@link TaskStatus} values. */
function isTerminal(status: TaskStatus): boolean {
return status === 'completed' || status === 'killed' || status === 'failed'
}
/**
* The in-memory `tasks` registry. See the seam contract in
* `@deepseek-ai/dsh-tasks` for the ownership, isolation, and lifecycle
* semantics this implementation honors.
*/
export class LocalTaskService extends TaskService {
private store = new Map<TaskId, TrackedTask>()
private counters = new Map<string, number>()
private surfaces = new Set<symbol>()
private listeners = new Set<TaskDoneListener>()
private listenersClosed = false
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
/** Service context used by detached settlement continuations and teardown. */
private readonly selfCtx: Context
constructor(ctx: Context) {
super(ctx)
this.selfCtx = ctx
ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
}
start(spec: TaskStart): TaskId {
if (this.surfaces.size === 0) {
throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
}
if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
if (spec.outputLimitBytes !== undefined
&& (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) {
throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`)
}
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
const hooks = spec.run()
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = TaskId(`${spec.kind}-${count}`)
let markSettled!: () => void
const settled = new Promise<void>((resolve) => { markSettled = resolve })
const task: TrackedTask = {
id,
kind: spec.kind,
label: spec.label,
outputLimitBytes: spec.outputLimitBytes,
owner: spec.owner,
cancel: hooks.cancel.bind(hooks),
readOutput: hooks.readOutput?.bind(hooks),
status: 'running',
detail: undefined,
output: undefined,
startedAt: Date.now(),
finishedAt: undefined,
reported: false,
settled,
markSettled,
waiters: 0,
waitResolvers: new Set(),
}
this.store.set(id, task)
void hooks.done.then(
(outcome) => { this.settle(task, outcome) },
(error: unknown) => {
// Contain a producer contract violation so cleanup and waiters cannot hang.
this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`)
this.settle(task, { status: 'failed', detail: String(error) })
},
)
return id
}
list(caller?: Agent): TaskSnapshot[] {
const session = caller?.id
return [...this.store.values()]
.filter(task => task.owner === undefined || task.owner.id === session)
.map(task => this.snapshot(task))
}
get(id: TaskId, caller?: Agent): TaskSnapshot {
const task = this.expect(id)
this.assertAccess(task, caller)
return this.snapshot(task)
}
read(id: TaskId, caller?: Agent): TaskRead {
const task = this.expect(id)
this.assertAccess(task, caller)
const text = task.readOutput !== undefined
? task.readOutput()
: isTerminal(task.status) ? task.output ?? '' : ''
if (isTerminal(task.status)) task.reported = true
return { text, snapshot: this.snapshot(task) }
}
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' {
const task = this.expect(id)
this.assertAccess(task, caller)
if (isTerminal(task.status)) {
task.reported = true
return 'already-finished'
}
// Cancel first so a throw leaves both lifecycle and notice state unchanged.
task.cancel(reason)
task.status = 'stopping'
task.reported = true
return 'requested'
}
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> {
const task = this.expect(id)
this.assertAccess(task, caller)
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`)
}
if (!isTerminal(task.status)) {
if (signal?.aborted) throw new Error('wait aborted')
// Abort removes the waiter synchronously so same-tick settlement cannot
// suppress a notice for a wait that will reject.
task.waiters += 1
let counted = true
const uncount = (): void => {
if (!counted) return
counted = false
task.waiters -= 1
}
try {
// The scoped deadline distinguishes a successful wait timeout from
// caller cancellation and clears its timer on every exit.
using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT)
await new Promise<void>((resolve, reject) => {
const onSettled = (): void => {
task.waitResolvers.delete(onSettled)
d.signal.removeEventListener('abort', onAbort)
resolve()
}
const onAbort = (): void => {
task.waitResolvers.delete(onSettled)
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
resolve()
} else if (isTerminal(task.status)) {
// Settlement suppressed the notice for this waiter; deliver it.
resolve()
} else {
uncount()
reject(new Error('wait aborted'))
}
}
task.waitResolvers.add(onSettled)
d.signal.addEventListener('abort', onAbort, { once: true })
})
} finally {
uncount()
}
}
if (isTerminal(task.status)) task.reported = true
return this.snapshot(task)
}
onTaskDone(listener: TaskDoneListener): () => void {
const dispose = this.ctx.effect(() => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}, 'tasks.onTaskDone()')
return () => void dispose()
}
attachSurface(name: string): () => void {
// One token per call keeps duplicate labels independently disposable.
const token = Symbol(name)
const dispose = this.ctx.effect(() => {
this.surfaces.add(token)
return () => this.surfaces.delete(token)
}, 'tasks.attachSurface()')
return () => void dispose()
}
/** Look up a task or fail loud. */
private expect(id: TaskId): TrackedTask {
const task = this.store.get(id)
if (task === undefined) throw new Error(`unknown task ${id}`)
return task
}
/**
* The isolation fence: a task with an owner is reachable only by callers
* whose session id matches (`!== undefined` semantics — an unowned task is
* open, and a no-agent caller can never match an owned one).
*/
private assertAccess(task: TrackedTask, caller?: Agent): void {
if (task.owner !== undefined && task.owner.id !== caller?.id) {
throw new Error(`task ${task.id} belongs to another session`)
}
}
/** Project a fresh read-only snapshot from the mutable record. */
private snapshot(task: TrackedTask): TaskSnapshot {
const ownerSession = task.owner?.id
return {
id: task.id,
kind: task.kind,
label: task.label,
...task.outputLimitBytes !== undefined ? { outputLimitBytes: task.outputLimitBytes } : {},
...ownerSession !== undefined ? { ownerSession } : {},
status: task.status,
...task.detail !== undefined ? { detail: task.detail } : {},
startedAt: task.startedAt,
...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {},
reported: task.reported,
}
}
/**
* Record the first terminal outcome, notify contained listeners, and release
* waiters. First-wins preserves a teardown force-failure against late producer
* settlement. Pending waits mark the task reported before listeners run.
*/
private settle(task: TrackedTask, outcome: TaskOutcome): void {
if (isTerminal(task.status)) return
task.status = outcome.status
task.detail = outcome.detail
task.output = outcome.output
task.finishedAt = Date.now()
if (task.waiters > 0) task.reported = true
if (!this.listenersClosed) {
const snapshot = this.snapshot(task)
for (const listener of this.listeners) {
try {
const returned = listener(snapshot, task.owner)
void Promise.resolve(returned).catch((error: unknown) => {
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
})
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
}
}
}
const waitResolvers = [...task.waitResolvers]
task.waitResolvers.clear()
for (const resolveWait of waitResolvers) resolveWait()
task.markSettled()
}
/**
* Attach one awaited cleanup through the exact owner's scope. This survives
* producer reloads and joins agent quiescence; the retained disposer lets
* service teardown detach the cross-fiber effect. Fails when the registry is
* absent or the owner is not its currently registered instance.
*/
private ensureOwnerCleanup(owner: Agent): void {
const ownerId = owner.id
const agents = this.selfCtx.get('agents')
if (agents === undefined) {
throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
}
if (agents.get(ownerId) !== owner) {
throw new Error(`agent "${ownerId}" is not the registered agent instance (background task owner must be live)`)
}
if (this.ownerCleanups.has(owner)) return
// Record only after attach succeeds; a disposing scope rejects new effects.
const detach = owner.ctx.effect(() => async () => {
this.ownerCleanups.delete(owner)
await this.disposeOwned(owner)
}, 'tasks.ownerCleanup()')
this.ownerCleanups.set(owner, detach)
}
/** Cancel, await terminal records, and drop every task owned by one exact agent lifecycle. */
private async disposeOwned(owner: Agent): Promise<void> {
const owned = [...this.store.values()].filter(task => task.owner === owner)
this.cancelForTeardown(owned, 'owner disposed')
await Promise.all(owned.map(task => task.settled))
for (const task of owned) this.store.delete(task.id)
}
/**
* Close listeners, cancel live tasks, await settlement, and detach owner
* effects. Throwing cancels are force-failed to avoid teardown deadlock.
*/
private async disposeAll(): Promise<void> {
this.listenersClosed = true
this.listeners.clear()
const all = [...this.store.values()]
this.cancelForTeardown(all, 'tasks service disposed')
await Promise.all(all.map(task => task.settled))
this.store.clear()
// Detach cross-fiber owner effects after the shared store is quiescent.
const ownerCleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
await Promise.all(ownerCleanups.map(cleanup => Promise.resolve(cleanup())))
}
/**
* Cancel tasks during teardown with per-task containment. A throwing cancel
* force-fails the record and reports a possible orphan; a cancel that returns
* without settling remains indistinguishable from a slow stop and may stall.
*/
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
for (const task of tasks) {
if (isTerminal(task.status)) continue
try {
task.cancel(reason)
task.status = 'stopping'
} catch (error: unknown) {
const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`)
this.settle(task, { status: 'failed', detail })
}
}
}
}
export default LocalTaskService

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tasks-local`.
* @module @deepseek-ai/dsh-tasks-local/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tasks-local'
/** Cordis companion plugin name. */
export const name = 'tasks-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the seam companion in `@deepseek-ai/dsh-tasks` already
* validates every registry snapshot this implementation publishes.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -3,8 +3,9 @@ import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
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 {
@@ -65,7 +66,7 @@ function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
async function harness() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
return ctx
}
@@ -81,14 +82,14 @@ function waitResolverCount(ctx: Context, id: TaskId): number {
return task.waitResolvers.size
}
describe('TaskService.start', () => {
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(TaskService)
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)')
})
@@ -109,7 +110,7 @@ describe('TaskService.start', () => {
})
})
describe('TaskService reads and settlement', () => {
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']
@@ -229,7 +230,7 @@ describe('TaskService reads and settlement', () => {
})
})
describe('TaskService.kill', () => {
describe('LocalTaskService.kill', () => {
it('cancels a live task with the forwarded reason and suppresses the notice', async () => {
const ctx = await harness()
const seen: TaskSnapshot[] = []
@@ -284,7 +285,7 @@ describe('TaskService.kill', () => {
})
})
describe('TaskService.wait', () => {
describe('LocalTaskService.wait', () => {
it('resolves with the terminal snapshot when the task settles, marked reported', async () => {
const ctx = await harness()
const seen: TaskSnapshot[] = []
@@ -394,7 +395,7 @@ describe('TaskService.wait', () => {
})
})
describe('TaskService owner isolation', () => {
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')
@@ -433,7 +434,7 @@ describe('TaskService owner isolation', () => {
it('rejects an owned registration when no agent registry is mounted', async () => {
const ctx = new Context()
await ctx.plugin(TaskService)
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')
@@ -498,7 +499,7 @@ describe('TaskService owner isolation', () => {
})
})
describe('TaskService owner cleanup', () => {
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')
@@ -580,7 +581,7 @@ describe('TaskService owner cleanup', () => {
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(TaskService)
const tasksFiber = await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
@@ -646,11 +647,11 @@ describe('TaskService owner cleanup', () => {
})
})
describe('TaskService disposal', () => {
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(TaskService)
const fiber = await ctx.plugin(LocalTaskService)
const surface = await ctx.plugin(Object.assign((inner: Context) => {
inner.tasks.attachSurface('test-surface')
}, { inject: ['tasks'] }))
@@ -678,7 +679,7 @@ describe('TaskService disposal', () => {
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(TaskService)
const fiber = await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: TaskSnapshot[] = []
@@ -716,7 +717,7 @@ describe('TaskService disposal', () => {
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(TaskService)
const tasksFiber = await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
@@ -741,7 +742,7 @@ describe('TaskService disposal', () => {
it('detaching the last surface re-arms the register fence', async () => {
const ctx = new Context()
await ctx.plugin(TaskService)
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) => {

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../util/timeout"
},
{
"path": "../tasks"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-tasks
The process-local background task registry (`ctx.tasks`). It gives long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup. Producer plugins extend `TaskKindMap` with their opaque id namespace.
The background task registry seam (`ctx.tasks`). The abstract `TaskService` and its vocabulary types give long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup under one contract; the process-local registry lives in [`dsh-tasks-local`](../tasks-local/README.md). Producer plugins extend `TaskKindMap` with their opaque id namespace.
## Service API
## Service contract
- `start(spec): TaskId` validates the control surface, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step.
- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks.
@@ -16,13 +16,9 @@ Owned access compares the task's `SessionId` with the caller's. Ids such as `bas
`outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A control surface applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it.
## Lifecycle
Implementations also owe the lifecycle semantics of the contract: registrations outlive producer and control-surface fibers, owner and service disposal cancel live work and await compliant producers, and settlement is first-wins — one terminal record, one round of contained listener notification, released waiters.
Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
See the [task type catalog](../../../docs/core-data-structures/tasks.md) and [runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md).
See the [task type catalog](../../../docs/core-data-structures/tasks.md), the [runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md), and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md).
## Model Experience
@@ -34,8 +30,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Tasks are process-local** — durable or cross-restart execution needs a separate lifecycle.
- **The service and implementation are not split** — a second backend must define the lifecycle that shapes that boundary.
- **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API.
- **Foreground work cannot be promoted** — producers choose foreground or background before starting.
- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely.
- **The contract is in-process** — `TaskStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam.

View File

@@ -31,7 +31,6 @@
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
@@ -39,7 +38,6 @@
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -1,19 +1,14 @@
/**
* The in-process background task registry (`ctx.tasks`). It owns task ids,
* session-scoped access, lifecycle state, completion listeners, and owner
* cleanup while producers retain their execution resources.
*
* Registrations outlive producer and control-surface fibers. Agent or service
* disposal cancels live work and awaits compliant producers; a throwing
* teardown cancel force-fails only the record and reports a possible orphan.
* The background task registry seam (`ctx.tasks`). It owns the contract for
* task ids, session-scoped access, lifecycle state, completion listeners, and
* owner cleanup while producers retain their execution resources. The
* process-local registry lives in `@deepseek-ai/dsh-tasks-local`.
* @module @deepseek-ai/dsh-tasks
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { TaskId } from './types.ts'
import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from './types.ts'
import type { TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart } from './types.ts'
export { TaskId } from './types.ts'
export type {
@@ -34,61 +29,27 @@ declare module 'cordis' {
}
}
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
interface TrackedTask {
id: TaskId
kind: TaskKind
label: string
outputLimitBytes: number | undefined
/** Exact lifecycle owner; session-id authorization is derived from it. */
owner: Agent | undefined
cancel: (reason?: string) => void
readOutput: (() => string) | undefined
status: TaskStatus
detail: string | undefined
output: string | undefined
startedAt: number
finishedAt: number | undefined
reported: boolean
/** Resolves once the terminal snapshot is recorded and listeners notified. */
settled: Promise<void>
/** Resolver for {@link settled}, called by the first effective settlement. */
markSettled: () => void
/** Live waits; settlement with a waiter marks the task reported. */
waiters: number
/** Removable resolvers for live waits; timeout/abort unregister before the task settles. */
waitResolvers: Set<() => void>
}
/** True for the three terminal {@link TaskStatus} values. */
function isTerminal(status: TaskStatus): boolean {
return status === 'completed' || status === 'killed' || status === 'failed'
}
/**
* The `tasks` service: the runtime-global background task registry. See the
* module doc for the ownership, isolation, and lifecycle contracts.
* Abstract background task registry. Subclass, implement the abstract methods,
* and load the subclass as a plugin — it registers as `ctx.tasks` (one
* implementation per context; loading a second throws, which is cordis'
* standard duplicate-service behavior).
*
* Implementations must honor these semantics:
* - Registrations outlive producer and control-surface fibers. Owner and
* service disposal cancel live work and await compliant producers; a
* throwing teardown cancel force-fails only the record.
* - Owned-task access is fenced by the owner's session id. Ids are
* predictable, so authorization — not secrecy — is the boundary.
* - Settlement is first-wins: one terminal record, one round of contained
* listener notification, and released waiters, even against a late
* producer outcome.
* - {@link start} refuses work while no control surface is attached, so a
* producer cannot start work that callers cannot collect or stop.
*/
// TODO(task-service-backend): Separate the service contract from this
// process-local implementation when a second backend defines its lifecycle.
export class TaskService extends Service {
private store = new Map<TaskId, TrackedTask>()
private counters = new Map<string, number>()
private surfaces = new Set<symbol>()
private listeners = new Set<TaskDoneListener>()
private listenersClosed = false
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
/** Service context used by detached settlement continuations and teardown. */
private readonly selfCtx: Context
export abstract class TaskService extends Service {
constructor(ctx: Context) {
super(ctx, 'tasks')
this.selfCtx = ctx
ctx.effect(() => () => this.disposeAll(), 'tasks teardown')
}
/**
@@ -99,56 +60,7 @@ export class TaskService extends Service {
* @param spec - task identity, owner, and synchronous starter.
* @returns the registry-issued `<kind>-N` id.
*/
start(spec: TaskStart): TaskId {
if (this.surfaces.size === 0) {
throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)')
}
if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string')
if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string')
if (spec.outputLimitBytes !== undefined
&& (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) {
throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`)
}
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
const hooks = spec.run()
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = TaskId(`${spec.kind}-${count}`)
let markSettled!: () => void
const settled = new Promise<void>((resolve) => { markSettled = resolve })
const task: TrackedTask = {
id,
kind: spec.kind,
label: spec.label,
outputLimitBytes: spec.outputLimitBytes,
owner: spec.owner,
cancel: hooks.cancel.bind(hooks),
readOutput: hooks.readOutput?.bind(hooks),
status: 'running',
detail: undefined,
output: undefined,
startedAt: Date.now(),
finishedAt: undefined,
reported: false,
settled,
markSettled,
waiters: 0,
waitResolvers: new Set(),
}
this.store.set(id, task)
void hooks.done.then(
(outcome) => { this.settle(task, outcome) },
(error: unknown) => {
// Contain a producer contract violation so cleanup and waiters cannot hang.
this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`)
this.settle(task, { status: 'failed', detail: String(error) })
},
)
return id
}
abstract start(spec: TaskStart): TaskId
/**
* List caller-owned and unowned tasks in registration order without exposing
@@ -156,12 +68,7 @@ export class TaskService extends Service {
* @param caller - reading agent; a non-agent caller sees only unowned tasks.
* @returns fresh snapshots.
*/
list(caller?: Agent): TaskSnapshot[] {
const session = caller?.id
return [...this.store.values()]
.filter(task => task.owner === undefined || task.owner.id === session)
.map(task => this.snapshot(task))
}
abstract list(caller?: Agent): TaskSnapshot[]
/**
* Return a non-consuming snapshot without changing its read cursor or notice
@@ -170,11 +77,7 @@ export class TaskService extends Service {
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
get(id: TaskId, caller?: Agent): TaskSnapshot {
const task = this.expect(id)
this.assertAccess(task, caller)
return this.snapshot(task)
}
abstract get(id: TaskId, caller?: Agent): TaskSnapshot
/**
* Read the next stream delta, or the idempotent final output after settlement.
@@ -184,15 +87,7 @@ export class TaskService extends Service {
* @param caller - reading agent checked against the owner.
* @returns output text and the post-read snapshot.
*/
read(id: TaskId, caller?: Agent): TaskRead {
const task = this.expect(id)
this.assertAccess(task, caller)
const text = task.readOutput !== undefined
? task.readOutput()
: isTerminal(task.status) ? task.output ?? '' : ''
if (isTerminal(task.status)) task.reported = true
return { text, snapshot: this.snapshot(task) }
}
abstract read(id: TaskId, caller?: Agent): TaskRead
/**
* Request cancellation, then mark the task stopping and reported. A producer
@@ -203,81 +98,20 @@ export class TaskService extends Service {
* @param reason - logged reason forwarded to the producer.
* @returns `requested` for live work, otherwise `already-finished`.
*/
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' {
const task = this.expect(id)
this.assertAccess(task, caller)
if (isTerminal(task.status)) {
task.reported = true
return 'already-finished'
}
// Cancel first so a throw leaves both lifecycle and notice state unchanged.
task.cancel(reason)
task.status = 'stopping'
task.reported = true
return 'requested'
}
abstract kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
/**
* Wait for settlement or timeout without cancelling the task. Caller abort
* rejects only while the task is live; after settlement it returns the
* terminal snapshot so a notice suppressed for this waiter is still delivered.
* Timed-out and aborted waits detach their resolvers. Throws for invalid,
* unknown, or foreign input.
* rejects only while the task is live; after settlement the terminal
* snapshot wins so a notice suppressed for this waiter is still delivered.
* Throws for invalid, unknown, or foreign input.
* @param id - task to wait for.
* @param timeoutMs - positive finite wait bound in milliseconds.
* @param caller - waiting agent checked against the owner.
* @param signal - optional cancellation of the wait itself.
* @returns snapshot at settlement or timeout.
*/
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> {
const task = this.expect(id)
this.assertAccess(task, caller)
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`)
}
if (!isTerminal(task.status)) {
if (signal?.aborted) throw new Error('wait aborted')
// Abort removes the waiter synchronously so same-tick settlement cannot
// suppress a notice for a wait that will reject.
task.waiters += 1
let counted = true
const uncount = (): void => {
if (!counted) return
counted = false
task.waiters -= 1
}
try {
// The scoped deadline distinguishes a successful wait timeout from
// caller cancellation and clears its timer on every exit.
using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT)
await new Promise<void>((resolve, reject) => {
const onSettled = (): void => {
task.waitResolvers.delete(onSettled)
d.signal.removeEventListener('abort', onAbort)
resolve()
}
const onAbort = (): void => {
task.waitResolvers.delete(onSettled)
if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) {
resolve()
} else if (isTerminal(task.status)) {
// Settlement suppressed the notice for this waiter; deliver it.
resolve()
} else {
uncount()
reject(new Error('wait aborted'))
}
}
task.waitResolvers.add(onSettled)
d.signal.addEventListener('abort', onAbort, { once: true })
})
} finally {
uncount()
}
}
if (isTerminal(task.status)) task.reported = true
return this.snapshot(task)
}
abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>
/**
* Register an effect-scoped completion listener. Each listener is contained;
@@ -286,13 +120,7 @@ export class TaskService extends Service {
* @param listener - receives each terminal snapshot and its exact owner.
* @returns disposer that unregisters the listener.
*/
onTaskDone(listener: TaskDoneListener): () => void {
const dispose = this.ctx.effect(() => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}, 'tasks.onTaskDone()')
return () => void dispose()
}
abstract onTaskDone(listener: TaskDoneListener): () => void
/**
* Attach an effect-scoped surface that can read and stop tasks. {@link start}
@@ -300,149 +128,7 @@ export class TaskService extends Service {
* @param name - diagnostic label; duplicate names remain independent.
* @returns disposer that detaches this surface.
*/
attachSurface(name: string): () => void {
// One token per call keeps duplicate labels independently disposable.
const token = Symbol(name)
const dispose = this.ctx.effect(() => {
this.surfaces.add(token)
return () => this.surfaces.delete(token)
}, 'tasks.attachSurface()')
return () => void dispose()
}
/** Look up a task or fail loud. */
private expect(id: TaskId): TrackedTask {
const task = this.store.get(id)
if (task === undefined) throw new Error(`unknown task ${id}`)
return task
}
/**
* The isolation fence: a task with an owner is reachable only by callers
* whose session id matches (`!== undefined` semantics — an unowned task is
* open, and a no-agent caller can never match an owned one).
*/
private assertAccess(task: TrackedTask, caller?: Agent): void {
if (task.owner !== undefined && task.owner.id !== caller?.id) {
throw new Error(`task ${task.id} belongs to another session`)
}
}
/** Project a fresh read-only snapshot from the mutable record. */
private snapshot(task: TrackedTask): TaskSnapshot {
const ownerSession = task.owner?.id
return {
id: task.id,
kind: task.kind,
label: task.label,
...task.outputLimitBytes !== undefined ? { outputLimitBytes: task.outputLimitBytes } : {},
...ownerSession !== undefined ? { ownerSession } : {},
status: task.status,
...task.detail !== undefined ? { detail: task.detail } : {},
startedAt: task.startedAt,
...task.finishedAt !== undefined ? { finishedAt: task.finishedAt } : {},
reported: task.reported,
}
}
/**
* Record the first terminal outcome, notify contained listeners, and release
* waiters. First-wins preserves a teardown force-failure against late producer
* settlement. Pending waits mark the task reported before listeners run.
*/
private settle(task: TrackedTask, outcome: TaskOutcome): void {
if (isTerminal(task.status)) return
task.status = outcome.status
task.detail = outcome.detail
task.output = outcome.output
task.finishedAt = Date.now()
if (task.waiters > 0) task.reported = true
if (!this.listenersClosed) {
const snapshot = this.snapshot(task)
for (const listener of this.listeners) {
try {
const returned = listener(snapshot, task.owner)
void Promise.resolve(returned).catch((error: unknown) => {
this.selfCtx.logger.warn(`tasks: onTaskDone listener rejected for ${task.id}: ${String(error)}`)
})
} catch (error: unknown) {
this.selfCtx.logger.warn(`tasks: onTaskDone listener threw for ${task.id}: ${String(error)}`)
}
}
}
const waitResolvers = [...task.waitResolvers]
task.waitResolvers.clear()
for (const resolveWait of waitResolvers) resolveWait()
task.markSettled()
}
/**
* Attach one awaited cleanup through the exact owner's scope. This survives
* producer reloads and joins agent quiescence; the retained disposer lets
* service teardown detach the cross-fiber effect. Fails when the registry is
* absent or the owner is not its currently registered instance.
*/
private ensureOwnerCleanup(owner: Agent): void {
const ownerId = owner.id
const agents = this.selfCtx.get('agents')
if (agents === undefined) {
throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
}
if (agents.get(ownerId) !== owner) {
throw new Error(`agent "${ownerId}" is not the registered agent instance (background task owner must be live)`)
}
if (this.ownerCleanups.has(owner)) return
// Record only after attach succeeds; a disposing scope rejects new effects.
const detach = owner.ctx.effect(() => async () => {
this.ownerCleanups.delete(owner)
await this.disposeOwned(owner)
}, 'tasks.ownerCleanup()')
this.ownerCleanups.set(owner, detach)
}
/** Cancel, await terminal records, and drop every task owned by one exact agent lifecycle. */
private async disposeOwned(owner: Agent): Promise<void> {
const owned = [...this.store.values()].filter(task => task.owner === owner)
this.cancelForTeardown(owned, 'owner disposed')
await Promise.all(owned.map(task => task.settled))
for (const task of owned) this.store.delete(task.id)
}
/**
* Close listeners, cancel live tasks, await settlement, and detach owner
* effects. Throwing cancels are force-failed to avoid teardown deadlock.
*/
private async disposeAll(): Promise<void> {
this.listenersClosed = true
this.listeners.clear()
const all = [...this.store.values()]
this.cancelForTeardown(all, 'tasks service disposed')
await Promise.all(all.map(task => task.settled))
this.store.clear()
// Detach cross-fiber owner effects after the shared store is quiescent.
const ownerCleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
await Promise.all(ownerCleanups.map(cleanup => Promise.resolve(cleanup())))
}
/**
* Cancel tasks during teardown with per-task containment. A throwing cancel
* force-fails the record and reports a possible orphan; a cancel that returns
* without settling remains indistinguishable from a slow stop and may stall.
*/
private cancelForTeardown(tasks: TrackedTask[], reason: string): void {
for (const task of tasks) {
if (isTerminal(task.status)) continue
try {
task.cancel(reason)
task.status = 'stopping'
} catch (error: unknown) {
const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`
this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`)
this.settle(task, { status: 'failed', detail })
}
}
}
abstract attachSurface(name: string): () => void
}
export default TaskService

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { TaskId, TaskService } from '@deepseek-ai/dsh-tasks'
import type { TaskDoneListener, TaskRead, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
/**
* Minimal concrete registry: one canned record. The seam owns the contract
* only (ids, snapshots, authorization-shaped signatures); the registry
* behavior suite lives with `@deepseek-ai/dsh-tasks-local`.
*/
class StubTaskService extends TaskService {
snapshotOf(id: TaskId): TaskSnapshot {
return {
id,
kind: 'bash',
label: 'sleep 60',
status: 'running',
startedAt: 0,
reported: false,
}
}
start(spec: TaskStart): TaskId {
spec.run()
return TaskId(`${spec.kind}-1`)
}
list(): TaskSnapshot[] {
return [this.snapshotOf(TaskId('bash-1'))]
}
get(id: TaskId): TaskSnapshot {
return this.snapshotOf(id)
}
read(id: TaskId): TaskRead {
return { text: '', snapshot: this.snapshotOf(id) }
}
kill(): 'requested' | 'already-finished' {
return 'requested'
}
wait(id: TaskId, _timeoutMs: number, _caller?: Agent, _signal?: AbortSignal): Promise<TaskSnapshot> {
return Promise.resolve(this.snapshotOf(id))
}
onTaskDone(_listener: TaskDoneListener): () => void {
return () => {}
}
attachSurface(_name: string): () => void {
return () => {}
}
}
describe('TaskService seam', () => {
it('a concrete subclass registers as ctx.tasks and serves the abstract API', async () => {
const ctx = new Context()
await ctx.plugin(StubTaskService)
const detachSurface = ctx.tasks.attachSurface('seam-test')
const id = ctx.tasks.start({ kind: 'bash', label: 'sleep 60', run: () => ({ cancel() {}, done: new Promise(() => {}) }) })
expect(id).toBe('bash-1')
expect(ctx.tasks.list()).toHaveLength(1)
expect(ctx.tasks.get(id).status).toBe('running')
expect(ctx.tasks.read(id).text).toBe('')
expect(ctx.tasks.kill(id)).toBe('requested')
await expect(ctx.tasks.wait(id, 5)).resolves.toMatchObject({ id })
const detachListener = ctx.tasks.onTaskDone(() => {})
detachListener()
detachSurface()
})
it('loading a second implementation throws (one tasks service per context — cordis standard)', async () => {
const ctx = new Context()
await ctx.plugin(StubTaskService)
class SecondTaskService extends StubTaskService {}
await expect(ctx.plugin(SecondTaskService)).rejects.toThrow(/service "tasks" has been registered/)
})
})

View File

@@ -23,9 +23,6 @@
{
"path": "../../core/session"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}

View File

@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -6,7 +6,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
import { TaskId } from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
@@ -20,7 +21,7 @@ async function setup(config: ToolTasks.Config = {}) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const agentsFiber = await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
const toolsFiber = await ctx.plugin(ToolTasks, config)
return { ctx, agentsFiber, toolsFiber }
}
@@ -91,7 +92,7 @@ describe('tool-tasks setup', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 }))
.rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
})
@@ -108,7 +109,7 @@ describe('tool-tasks setup', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(LocalTaskService)
ToolTasks.apply(ctx, {})
expect(ctx.tools.get('task_output')).toBeDefined()
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()