fix(tasks): address task API review feedback
The public kill result used the awkward phrase already-terminal. Rename it to already-finished and keep the model-facing response aligned; not-alive would be inaccurate because a force-failed registry record can still correspond to orphaned producer work. Task kinds were open strings even though producer namespaces are an extension point. Add the merge-extensible TaskKindMap and derived TaskKind, cover consumer declarations in task and bundle tests, and retain the runtime non-empty check for untyped callers. With exactOptionalPropertyTypes, owner?: Agent | undefined allowed an explicit undefined value that no caller needs. Tighten the property to owner?: Agent so unowned work is expressed by omitting it. Record the requested task-service/backend split as a follow-up, using a systemd-backed runtime as a concrete candidate without guessing its durability and ownership contract in this PR. Regenerate the type and Cordis catalogs so public docs match the declarations.
This commit is contained in:
@@ -251,7 +251,7 @@ start(spec: TaskStart): TaskId
|
|||||||
list(caller?: Agent): TaskSnapshot[]
|
list(caller?: Agent): TaskSnapshot[]
|
||||||
get(id: TaskId, caller?: Agent): TaskSnapshot
|
get(id: TaskId, caller?: Agent): TaskSnapshot
|
||||||
read(id: TaskId, caller?: Agent): TaskRead
|
read(id: TaskId, caller?: Agent): TaskRead
|
||||||
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal'
|
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
|
||||||
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>
|
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>
|
||||||
onTaskDone(listener: TaskDoneListener): () => void
|
onTaskDone(listener: TaskDoneListener): () => void
|
||||||
attachSurface(name: string): () => void
|
attachSurface(name: string): () => void
|
||||||
@@ -259,7 +259,7 @@ attachSurface(name: string): () => void
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/tasks/tasks/src/index.ts:72`](../../packages/tasks/tasks/src/index.ts)
|
Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/index.ts)
|
||||||
|
|
||||||
## `ctx.tools` — `ToolRegistry`
|
## `ctx.tools` — `ToolRegistry`
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ Types shared by long-running producers, `ctx.tasks`, and task control surfaces.
|
|||||||
|
|
||||||
## Ids and status
|
## Ids and status
|
||||||
|
|
||||||
`TaskId` is a [branded id](core.md#branded-ids) generated as `<kind>-N`. Access control relies on owner authorization, not id secrecy. `TaskStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `TaskSnapshot.detail`.
|
`TaskId` is a [branded id](core.md#branded-ids) generated as `<kind>-N`. Access control relies on owner authorization, not id secrecy. `TaskKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces.
|
||||||
|
|
||||||
|
```ts type-equiv
|
||||||
|
interface TaskKindMap {
|
||||||
|
bash: 'bash'
|
||||||
|
subagent: 'subagent'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`TaskStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `TaskSnapshot.detail`.
|
||||||
|
|
||||||
## Producer contract
|
## Producer contract
|
||||||
|
|
||||||
@@ -12,17 +21,17 @@ Types shared by long-running producers, `ctx.tasks`, and task control surfaces.
|
|||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
interface TaskStart {
|
interface TaskStart {
|
||||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
|
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
|
||||||
kind: string
|
kind: TaskKind
|
||||||
/** One-line model-facing label (the command; the delegation description). */
|
/** One-line model-facing label (the command; the delegation description). */
|
||||||
label: string
|
label: string
|
||||||
/**
|
/**
|
||||||
* Owning live agent. Access is fenced by its session id, and agent disposal
|
* Owning live agent. Access is fenced by its session id, and agent disposal
|
||||||
* cancels and awaits the task. The instance must be the one currently
|
* cancels and awaits the task. The instance must be the one currently
|
||||||
* registered under its agent id. `undefined` creates an unowned task, open to
|
* registered under its agent id. Omitting the owner creates an unowned task,
|
||||||
* any caller until service disposal.
|
* open to any caller until service disposal.
|
||||||
*/
|
*/
|
||||||
owner?: Agent | undefined
|
owner?: Agent
|
||||||
/**
|
/**
|
||||||
* Start the work after preflight and synchronously return its hooks. Called
|
* Start the work after preflight and synchronously return its hooks. Called
|
||||||
* once; a throw leaves nothing registered, and the producer must clean up any
|
* once; a throw leaves nothing registered, and the producer must clean up any
|
||||||
@@ -77,7 +86,7 @@ interface TaskSnapshot {
|
|||||||
/** The registry-issued id (`<kind>-N`). */
|
/** The registry-issued id (`<kind>-N`). */
|
||||||
id: TaskId
|
id: TaskId
|
||||||
/** The producer kind the task was registered with. */
|
/** The producer kind the task was registered with. */
|
||||||
kind: string
|
kind: TaskKind
|
||||||
/** The producer-supplied one-line label. */
|
/** The producer-supplied one-line label. */
|
||||||
label: string
|
label: string
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ The `tasks/` package group owns background-task semantics:
|
|||||||
|
|
||||||
Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into incremental output and process cancellation; `dsh-tool-subagent` adapts a child run into final output and child disposal. The execution seams remain independent of sessions and the task registry.
|
Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into incremental output and process cancellation; `dsh-tool-subagent` adapts a child run into final output and child disposal. The execution seams remain independent of sessions and the task registry.
|
||||||
|
|
||||||
`TaskService` is a concrete service. There is one in-process implementation, so an interface/backend package split would be speculative. A durable or remote implementation can introduce that seam when its lifecycle requirements are known.
|
`TaskService` is a concrete, process-local service. TODO(task-service-backend): separate its public contract from the implementation when a second backend defines the required lifecycle; a systemd-backed runtime is one plausible driver, but this PR does not speculate about its durability, reconnect, ownership, or observation semantics.
|
||||||
|
|
||||||
## Runtime contract
|
## Runtime contract
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ The producer hooks define three responsibilities:
|
|||||||
- `done` never rejects and settles only after the producer has released the task's resources.
|
- `done` never rejects and settles only after the producer has released the task's resources.
|
||||||
- Optional `readOutput()` returns the next consuming output delta. Omitting it declares a final-output task whose terminal result comes from `TaskOutcome.output`.
|
- Optional `readOutput()` returns the next consuming output delta. Omitting it declares a final-output task whose terminal result comes from `TaskOutcome.output`.
|
||||||
|
|
||||||
Statuses are `running`, `stopping`, `completed`, `killed`, and `failed`. Producer-specific information such as an exit code or stop reason belongs in `detail`; the registry does not interpret it. Task ids are branded and generated as `<kind>-N`, with a counter per kind.
|
Statuses are `running`, `stopping`, `completed`, `killed`, and `failed`. Producer-specific information such as an exit code or stop reason belongs in `detail`; the registry does not interpret it. Task kinds form a merge-extensible string union, and task ids are branded and generated as `<kind>-N`, with a counter per kind.
|
||||||
|
|
||||||
The runtime attaches one continuation to `done`, records the first terminal outcome, resolves waiters, and invokes completion listeners with per-listener error containment. First-wins settlement matters during teardown: if `cancel` throws, the runtime force-fails the record and warns that work may be orphaned rather than waiting forever for a promise that may never settle. A later producer outcome cannot overwrite that diagnosis or notify twice. A `cancel` that returns without eventually settling `done` still blocks teardown because the runtime cannot distinguish it from a slow, valid stop.
|
The runtime attaches one continuation to `done`, records the first terminal outcome, resolves waiters, and invokes completion listeners with per-listener error containment. First-wins settlement matters during teardown: if `cancel` throws, the runtime force-fails the record and warns that work may be orphaned rather than waiting forever for a promise that may never settle. A later producer outcome cannot overwrite that diagnosis or notify twice. A `cancel` that returns without eventually settling `done` still blocks teardown because the runtime cannot distinguish it from a slow, valid stop.
|
||||||
|
|
||||||
@@ -95,9 +95,9 @@ For background subagents, `dsh-tool-subagent` creates a task-owned `AbortControl
|
|||||||
|
|
||||||
Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup, notification, and guidance while increasing the model's schema and protocol burden. One runtime keeps execution-specific behavior in producers without cloning the task lifecycle.
|
Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup, notification, and guidance while increasing the model's schema and protocol burden. One runtime keeps execution-specific behavior in producers without cloning the task lifecycle.
|
||||||
|
|
||||||
### An abstract task-runtime backend
|
### An immediate abstract task-runtime backend
|
||||||
|
|
||||||
No second backend exists. Durable work also changes owner and restart semantics, so its design should extract an interface from concrete requirements rather than preserve this implementation speculatively.
|
The current `TaskStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so extracting an interface before a second implementation exists would freeze the wrong boundary.
|
||||||
|
|
||||||
### Consumer-owned authorization or cleanup events
|
### Consumer-owned authorization or cleanup events
|
||||||
|
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
'list(caller?: Agent): TaskSnapshot[]',
|
'list(caller?: Agent): TaskSnapshot[]',
|
||||||
'get(id: TaskId, caller?: Agent): TaskSnapshot',
|
'get(id: TaskId, caller?: Agent): TaskSnapshot',
|
||||||
'read(id: TaskId, caller?: Agent): TaskRead',
|
'read(id: TaskId, caller?: Agent): TaskRead',
|
||||||
'kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-terminal\'',
|
'kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'',
|
||||||
'async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>',
|
'async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>',
|
||||||
'onTaskDone(listener: TaskDoneListener): () => void',
|
'onTaskDone(listener: TaskDoneListener): () => void',
|
||||||
'attachSurface(name: string): () => void',
|
'attachSurface(name: string): () => void',
|
||||||
@@ -933,6 +933,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
name: 'TaskId',
|
name: 'TaskId',
|
||||||
declaration: 'export type TaskId = Branded<\'TaskId\'>;',
|
declaration: 'export type TaskId = Branded<\'TaskId\'>;',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'TaskKind',
|
||||||
|
declaration: 'export type TaskKind = TaskKindMap[keyof TaskKindMap];',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'TaskKindMap',
|
||||||
|
declaration: 'export interface TaskKindMap {\n bash: \'bash\';\n subagent: \'subagent\';\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'TaskOutcome',
|
name: 'TaskOutcome',
|
||||||
declaration: 'export interface TaskOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}',
|
declaration: 'export interface TaskOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}',
|
||||||
@@ -943,11 +951,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'TaskSnapshot',
|
name: 'TaskSnapshot',
|
||||||
declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: string;\n label: string;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}',
|
declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'TaskStart',
|
name: 'TaskStart',
|
||||||
declaration: 'export interface TaskStart {\n kind: string;\n label: string;\n owner?: Agent | undefined;\n run(): TaskHooks;\n}',
|
declaration: 'export interface TaskStart {\n kind: TaskKind;\n label: string;\n owner?: Agent;\n run(): TaskHooks;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'TaskStatus',
|
name: 'TaskStatus',
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import * as agentCore from '../src/index.ts'
|
|||||||
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||||
|
|
||||||
|
declare module '@deepseek-ai/dsh-tasks' {
|
||||||
|
interface TaskKindMap {
|
||||||
|
probe: 'probe'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||||
const agent = { session: { header: { cwd } } } as unknown as Agent
|
const agent = { session: { header: { cwd } } } as unknown as Agent
|
||||||
const empty: Message[] = []
|
const empty: Message[] = []
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-tasks
|
# @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. The service is concrete; a durable backend can introduce an interface when its different lifecycle is specified.
|
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.
|
||||||
|
|
||||||
## Service API
|
## Service API
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README
|
|||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- **Tasks are process-local** — durable or cross-restart execution needs a separate lifecycle.
|
- **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.
|
- **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.
|
- **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.
|
- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely.
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import { Context, Service } from 'cordis'
|
|||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||||
import { TaskId } from './types.ts'
|
import { TaskId } from './types.ts'
|
||||||
import type { TaskDoneListener, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from './types.ts'
|
import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from './types.ts'
|
||||||
|
|
||||||
export { TaskId } from './types.ts'
|
export { TaskId } from './types.ts'
|
||||||
export type {
|
export type {
|
||||||
TaskDoneListener,
|
TaskDoneListener,
|
||||||
TaskHooks,
|
TaskHooks,
|
||||||
|
TaskKind,
|
||||||
|
TaskKindMap,
|
||||||
TaskOutcome,
|
TaskOutcome,
|
||||||
TaskRead,
|
TaskRead,
|
||||||
TaskSnapshot,
|
TaskSnapshot,
|
||||||
@@ -38,7 +40,7 @@ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT'
|
|||||||
/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
|
/** The registry's mutable per-task record (never handed out — see {@link TaskService.snapshot}). */
|
||||||
interface TrackedTask {
|
interface TrackedTask {
|
||||||
id: TaskId
|
id: TaskId
|
||||||
kind: string
|
kind: TaskKind
|
||||||
label: string
|
label: string
|
||||||
/** Exact lifecycle owner; session-id authorization is derived from it. */
|
/** Exact lifecycle owner; session-id authorization is derived from it. */
|
||||||
owner: Agent | undefined
|
owner: Agent | undefined
|
||||||
@@ -69,6 +71,8 @@ function isTerminal(status: TaskStatus): boolean {
|
|||||||
* The `tasks` service: the runtime-global background task registry. See the
|
* The `tasks` service: the runtime-global background task registry. See the
|
||||||
* module doc for the ownership, isolation, and lifecycle contracts.
|
* module doc for the ownership, isolation, and lifecycle contracts.
|
||||||
*/
|
*/
|
||||||
|
// 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 {
|
export class TaskService extends Service {
|
||||||
private store = new Map<TaskId, TrackedTask>()
|
private store = new Map<TaskId, TrackedTask>()
|
||||||
private counters = new Map<string, number>()
|
private counters = new Map<string, number>()
|
||||||
@@ -191,14 +195,14 @@ export class TaskService extends Service {
|
|||||||
* @param id - task to cancel.
|
* @param id - task to cancel.
|
||||||
* @param caller - killing agent checked against the owner.
|
* @param caller - killing agent checked against the owner.
|
||||||
* @param reason - logged reason forwarded to the producer.
|
* @param reason - logged reason forwarded to the producer.
|
||||||
* @returns `requested` for live work, otherwise `already-terminal`.
|
* @returns `requested` for live work, otherwise `already-finished`.
|
||||||
*/
|
*/
|
||||||
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal' {
|
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' {
|
||||||
const task = this.expect(id)
|
const task = this.expect(id)
|
||||||
this.assertAccess(task, caller)
|
this.assertAccess(task, caller)
|
||||||
if (isTerminal(task.status)) {
|
if (isTerminal(task.status)) {
|
||||||
task.reported = true
|
task.reported = true
|
||||||
return 'already-terminal'
|
return 'already-finished'
|
||||||
}
|
}
|
||||||
// Cancel first so a throw leaves both lifecycle and notice state unchanged.
|
// Cancel first so a throw leaves both lifecycle and notice state unchanged.
|
||||||
task.cancel(reason)
|
task.cancel(reason)
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ export function TaskId(id: string): TaskId {
|
|||||||
*/
|
*/
|
||||||
export type TaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
export type TaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Producer-defined task kinds. Plugins extend this map by declaration merging;
|
||||||
|
* the registry treats every value as an opaque id namespace.
|
||||||
|
*/
|
||||||
|
export interface TaskKindMap {
|
||||||
|
bash: 'bash'
|
||||||
|
subagent: 'subagent'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The merge-extensible union of registered producer kind names. */
|
||||||
|
export type TaskKind = TaskKindMap[keyof TaskKindMap]
|
||||||
|
|
||||||
/** Terminal result supplied by a producer through {@link TaskHooks.done}. */
|
/** Terminal result supplied by a producer through {@link TaskHooks.done}. */
|
||||||
export interface TaskOutcome {
|
export interface TaskOutcome {
|
||||||
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
|
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
|
||||||
@@ -45,17 +57,17 @@ export interface TaskOutcome {
|
|||||||
* execution resources while the runtime owns identity and lifecycle state.
|
* execution resources while the runtime owns identity and lifecycle state.
|
||||||
*/
|
*/
|
||||||
export interface TaskStart {
|
export interface TaskStart {
|
||||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
|
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
|
||||||
kind: string
|
kind: TaskKind
|
||||||
/** One-line model-facing label (the command; the delegation description). */
|
/** One-line model-facing label (the command; the delegation description). */
|
||||||
label: string
|
label: string
|
||||||
/**
|
/**
|
||||||
* Owning live agent. Access is fenced by its session id, and agent disposal
|
* Owning live agent. Access is fenced by its session id, and agent disposal
|
||||||
* cancels and awaits the task. The instance must be the one currently
|
* cancels and awaits the task. The instance must be the one currently
|
||||||
* registered under its agent id. `undefined` creates an unowned task, open to
|
* registered under its agent id. Omitting the owner creates an unowned task,
|
||||||
* any caller until service disposal.
|
* open to any caller until service disposal.
|
||||||
*/
|
*/
|
||||||
owner?: Agent | undefined
|
owner?: Agent
|
||||||
/**
|
/**
|
||||||
* Start the work after preflight and synchronously return its hooks. Called
|
* Start the work after preflight and synchronously return its hooks. Called
|
||||||
* once; a throw leaves nothing registered, and the producer must clean up any
|
* once; a throw leaves nothing registered, and the producer must clean up any
|
||||||
@@ -94,7 +106,7 @@ export interface TaskSnapshot {
|
|||||||
/** The registry-issued id (`<kind>-N`). */
|
/** The registry-issued id (`<kind>-N`). */
|
||||||
id: TaskId
|
id: TaskId
|
||||||
/** The producer kind the task was registered with. */
|
/** The producer kind the task was registered with. */
|
||||||
kind: string
|
kind: TaskKind
|
||||||
/** The producer-supplied one-line label. */
|
/** The producer-supplied one-line label. */
|
||||||
label: string
|
label: string
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|||||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
|
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||||
import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
|
import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
|
||||||
|
|
||||||
|
declare module '@deepseek-ai/dsh-tasks' {
|
||||||
|
interface TaskKindMap {
|
||||||
|
workflow: 'workflow'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
|
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
|
||||||
|
|
||||||
@@ -81,7 +87,7 @@ describe('TaskService.start', () => {
|
|||||||
|
|
||||||
it('rejects an empty kind and an empty label', async () => {
|
it('rejects an empty kind and an empty label', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
expect(() => ctx.tasks.start(producer({ kind: '' }).spec)).toThrow('invalid task kind')
|
expect(() => ctx.tasks.start(producer({ kind: '' as TaskKind }).spec)).toThrow('invalid task kind')
|
||||||
expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label')
|
expect(() => ctx.tasks.start(producer({ label: '' }).spec)).toThrow('invalid task label')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,6 +96,7 @@ describe('TaskService.start', () => {
|
|||||||
expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
|
expect(ctx.tasks.start(producer().spec)).toBe('bash-1')
|
||||||
expect(ctx.tasks.start(producer().spec)).toBe('bash-2')
|
expect(ctx.tasks.start(producer().spec)).toBe('bash-2')
|
||||||
expect(ctx.tasks.start(producer({ kind: 'subagent' }).spec)).toBe('subagent-1')
|
expect(ctx.tasks.start(producer({ kind: 'subagent' }).spec)).toBe('subagent-1')
|
||||||
|
expect(ctx.tasks.start(producer({ kind: 'workflow' }).spec)).toBe('workflow-1')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -222,13 +229,13 @@ describe('TaskService.kill', () => {
|
|||||||
expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true })
|
expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports an already-terminal task instead of failing', async () => {
|
it('reports an already-finished task instead of failing', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const p = producer()
|
const p = producer()
|
||||||
const id = ctx.tasks.start(p.spec)
|
const id = ctx.tasks.start(p.spec)
|
||||||
p.settle({ status: 'completed' })
|
p.settle({ status: 'completed' })
|
||||||
await tick()
|
await tick()
|
||||||
expect(ctx.tasks.kill(id)).toBe('already-terminal')
|
expect(ctx.tasks.kill(id)).toBe('already-finished')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('propagates a throwing producer cancel and leaves the task untouched', async () => {
|
it('propagates a throwing producer cancel and leaves the task untouched', async () => {
|
||||||
@@ -254,7 +261,7 @@ describe('TaskService.kill', () => {
|
|||||||
expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire
|
expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire
|
||||||
|
|
||||||
broken = false
|
broken = false
|
||||||
expect(ctx.tasks.kill(id)).toBe('already-terminal')
|
expect(ctx.tasks.kill(id)).toBe('already-finished')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -299,7 +306,7 @@ describe('TaskService.wait', () => {
|
|||||||
expect(ctx.tasks.get(id).status).toBe('running')
|
expect(ctx.tasks.get(id).status).toBe('running')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns immediately for an already-terminal task', async () => {
|
it('returns immediately for an already-finished task', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const p = producer()
|
const p = producer()
|
||||||
const id = ctx.tasks.start(p.spec)
|
const id = ctx.tasks.start(p.spec)
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
execute(args, exec) {
|
execute(args, exec) {
|
||||||
const id = validateTaskId(args.task_id)
|
const id = validateTaskId(args.task_id)
|
||||||
const result = ctx.tasks.kill(id, exec.agent, args.reason)
|
const result = ctx.tasks.kill(id, exec.agent, args.reason)
|
||||||
if (result === 'already-terminal') {
|
if (result === 'already-finished') {
|
||||||
// A snapshot describes terminal state without consuming pending output.
|
// A snapshot describes terminal state without consuming pending output.
|
||||||
const snapshot = ctx.tasks.get(id, exec.agent)
|
const snapshot = ctx.tasks.get(id, exec.agent)
|
||||||
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
|
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ describe('task_kill', () => {
|
|||||||
expect(p.cancels).toEqual(['superseded'])
|
expect(p.cancels).toEqual(['superseded'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports an already-terminal task without consuming its pending delta', async () => {
|
it('reports an already-finished task without consuming its pending delta', async () => {
|
||||||
const { ctx } = await setup()
|
const { ctx } = await setup()
|
||||||
let delta = 'unread tail'
|
let delta = 'unread tail'
|
||||||
const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
|
const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", "source": "packages/bash/bash/src/types.ts" },
|
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", "source": "packages/bash/bash/src/types.ts" },
|
||||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcessRead", "source": "packages/bash/bash/src/types.ts" },
|
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcessRead", "source": "packages/bash/bash/src/types.ts" },
|
||||||
|
|
||||||
|
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskKindMap", "source": "packages/tasks/tasks/src/types.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskStart", "source": "packages/tasks/tasks/src/types.ts" },
|
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskStart", "source": "packages/tasks/tasks/src/types.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskHooks", "source": "packages/tasks/tasks/src/types.ts" },
|
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskHooks", "source": "packages/tasks/tasks/src/types.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" },
|
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" },
|
||||||
|
|||||||
Reference in New Issue
Block a user