From 698b391bd6a64548364bcde3af5452d3b00ee747 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 05:13:39 +0800
Subject: [PATCH 1/6] 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.
---
...06-20-generic-long-running-tool-runtime.md | 4 +-
...20-generic-long-running-tool-runtime.zh.md | 4 +-
.../2026-07-26-task-registry-seam.md | 35 ++
.../2026-07-26-task-registry-seam.zh.md | 35 ++
apps/cli/cordis.yml | 2 +-
apps/cli/package.json | 2 +-
docs/capability-seams.md | 4 +-
docs/config-catalog.md | 3 +-
docs/cordis-catalog/services.md | 36 +-
docs/core-data-structures/tasks.md | 2 +-
docs/module-graph.md | 13 +-
.../headless-agent/tests/code-mode.e2e.ts | 4 +-
examples/package.json | 1 +
packages/bash/tool-bash/README.md | 2 +-
packages/bash/tool-bash/package.json | 1 +
packages/bash/tool-bash/src/index.ts | 4 +-
.../bash/tool-bash/tests/integration.spec.ts | 6 +-
packages/bash/tool-bash/tests/tools.spec.ts | 16 +-
.../cordis/tool-cordis/src/api-catalog.ts | 20 +-
packages/examples/agent-spine-demo/README.md | 2 +-
.../examples/agent-spine-demo/package.json | 3 +-
.../examples/agent-spine-demo/src/index.ts | 4 +-
.../examples/agent-spine-demo/tsconfig.json | 3 +
packages/pty/tool-pty/package.json | 1 +
packages/pty/tool-pty/src/index.ts | 2 +-
packages/pty/tool-pty/tests/tools.spec.ts | 4 +-
packages/subagent/tool-subagent/package.json | 1 +
packages/subagent/tool-subagent/src/index.ts | 2 +-
.../tool-subagent/tests/tool-subagent.spec.ts | 8 +-
packages/tasks/README.md | 5 +-
packages/tasks/tasks-local/README.md | 24 ++
packages/tasks/tasks-local/package.json | 45 +++
packages/tasks/tasks-local/src/index.ts | 365 +++++++++++++++++
packages/tasks/tasks-local/src/invariant.ts | 30 ++
.../tests/tasks.spec.ts | 33 +-
packages/tasks/tasks-local/tsconfig.json | 30 ++
packages/tasks/tasks/README.md | 16 +-
packages/tasks/tasks/package.json | 2 -
packages/tasks/tasks/src/index.ts | 380 ++----------------
packages/tasks/tasks/tests/service.spec.ts | 82 ++++
packages/tasks/tasks/tsconfig.json | 3 -
packages/tasks/tool-tasks/package.json | 1 +
.../tasks/tool-tasks/tests/tool-tasks.spec.ts | 9 +-
pnpm-lock.yaml | 48 ++-
python/sdk-runtime/package.json | 1 +
scripts/gen-doc-graphs.ts | 5 +-
scripts/gen-tool-catalog.ts | 4 +-
.../verify-package-readme-model-experience.ts | 1 +
tsconfig.host.json | 1 +
49 files changed, 851 insertions(+), 458 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
create mode 100644 .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
create mode 100644 packages/tasks/tasks-local/README.md
create mode 100644 packages/tasks/tasks-local/package.json
create mode 100644 packages/tasks/tasks-local/src/index.ts
create mode 100644 packages/tasks/tasks-local/src/invariant.ts
rename packages/tasks/{tasks => tasks-local}/tests/tasks.spec.ts (97%)
create mode 100644 packages/tasks/tasks-local/tsconfig.json
create mode 100644 packages/tasks/tasks/tests/service.spec.ts
diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
index 0b901fcf92..313d687b49 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
+++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
@@ -19,7 +19,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.
-`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.
+`TaskService` is the abstract seam in `@deepseek-ai/dsh-tasks`; the process-local registry is `LocalTaskService` in `@deepseek-ai/dsh-tasks-local` (the [task-registry seam Agent Note](2026-07-26-task-registry-seam.md) records that split).
## Runtime contract
@@ -103,7 +103,7 @@ Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup,
### An immediate abstract task-runtime backend
-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.
+The current `TaskStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry seam Agent Note](2026-07-26-task-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics.
### Consumer-owned authorization or cleanup events
diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
index e2860e3a91..39900e24ba 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
@@ -19,7 +19,7 @@ Status: implemented
长时间运行工具是生产方。`dsh-tool-bash` 将 `BashProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。执行 seam 保持独立,不依赖会话或任务注册表。
-`TaskService` 是一个具体的进程内服务。TODO(task-service-backend):当第二个后端明确所需生命周期后,将其公共契约与实现分离;systemd 驱动的运行时是一种可能方案,但本 PR(Pull Request)不臆测其持久性、重连、所有权或观察语义。
+`TaskService` 是 `@deepseek-ai/dsh-tasks` 中的抽象 seam;进程内注册表是 `@deepseek-ai/dsh-tasks-local` 中的 `LocalTaskService`(该拆分记录在[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.zh.md)中)。
## 运行时契约
@@ -103,7 +103,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas
### 立即抽象任务运行时后端
-当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在第二种实现出现前抽取接口,会固化错误的边界。
+当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.zh.md)后来在不改变这些进程内语义的前提下,将契约与进程内实现分离。
### 由消费方负责授权或清理事件
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
new file mode 100644
index 0000000000..b785eb75a6
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
@@ -0,0 +1,35 @@
+# Agent Note: The task registry is a capability seam (`dsh-tasks` / `dsh-tasks-local`)
+
+Status: implemented
+
+English | [中文](2026-07-26-task-registry-seam.zh.md)
+
+## Problem
+
+The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) shipped `TaskService` as one concrete package: `@deepseek-ai/dsh-tasks` owned both the `ctx.tasks` contract every producer and control surface programs against and the process-local implementation (the in-memory store, settlement bookkeeping, owner-cleanup effects, teardown). That bundling recouples the two rates of change the repository's [capability-seam rule](2026-06-13-capability-seams.md) separates: swapping the registry's storage or lifecycle backend would churn the same package whose types and `ctx.tasks` surface producers (`dsh-tool-bash`, `dsh-tool-pty`, `dsh-tool-subagent`), the control surface (`dsh-tool-tasks`), and `TaskKindMap` extenders import. Every other swappable capability in the harness — bash, pty, fs, skill, subagent, web, session persistence — already carries the interface / implementation / consumer split; the task registry was the remaining `core`-mode exception, guarded only by a `TODO(task-service-backend)` comment.
+
+## Decision
+
+`tasks/` is now a three-package capability family in the bash-trio shape:
+
+- **`@deepseek-ai/dsh-tasks` (interface)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every implementation owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no control surface is attached.
+- **`@deepseek-ai/dsh-tasks-local` (implementation)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the seam has no implementation dependencies.
+- **`@deepseek-ai/dsh-tool-tasks` (consumer)** — unchanged; it injects `'tasks'` and never imports implementation types.
+
+Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks-local` because a deployment fixes them by loading the implementation, not the interface. Producers, `TaskKindMap` declaration merges, and the control surface keep importing `@deepseek-ai/dsh-tasks` only.
+
+The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` still passes callbacks and exact `Agent` objects, so a durable or cross-process backend still has design work to do before it can implement this interface (identity, restart, ownership, observation). The split moves that future work out of every consumer's dependency graph; it does not pre-design the backend.
+
+## Alternatives considered
+
+**Keep the concrete service until a second backend exists (status quo).** This was the original runtime note's position: extracting an interface before a second implementation risks freezing the wrong boundary. It lost because the boundary is no longer speculative — the eight service methods and their semantics have been stable across every producer integration since introduction, they are exactly the surface `dsh-tool-tasks` and the producers already program against, and the repository convention treats swappable capabilities as three packages by default. The residual risk (a durable backend needing contract changes) is unchanged by the split: those changes would land in the seam package either way, and today they would also churn every consumer's implementation dependency.
+
+**Interface-only extraction inside one package (export an abstract class beside the concrete one).** Rejected because it separates nothing operationally: consumers still depend on the package that carries the implementation and its dependencies, and a replacement backend still cannot ship without the local one in its graph. The package boundary is the unit of independent evolution here.
+
+**Splitting `types.ts` out but leaving the service concrete.** Rejected for the same reason — the types are not the seam; `ctx.tasks` and its method contract are. Producers need the service key and semantics, not just the shapes.
+
+## Consequences
+
+Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling package implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The seam README states the contract; the implementation README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the seam keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite.
+
+Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package — a boot that loads only `@deepseek-ai/dsh-tasks` gets a pending `ctx.tasks` and producers fail with the standard missing-service behavior rather than a bespoke message. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default.
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
new file mode 100644
index 0000000000..aa4df43b82
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 任务注册表是一个能力 seam(`dsh-tasks` / `dsh-tasks-local`)
+
+Status: implemented
+
+[English](2026-07-26-task-registry-seam.md) | 中文
+
+## 问题
+
+[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有所有生产方和控制接口面向编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除逻辑)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力(bash、pty、fs、skill、subagent、web、会话持久化)都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。
+
+## 决策
+
+`tasks/` 如今是一个 bash 三件套形态的三包能力家族:
+
+- **`@deepseek-ai/dsh-tasks`(接口)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的契约(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个实现都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且在没有附加任何控制接口时 `start` 拒绝启动工作。
+- **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除逻辑。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。
+- **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。
+
+各组合配置在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`(CLI 的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness、工具目录生成器的启动流程)。生产方的配置错误诊断信息("background tasks unavailable: load …")点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。
+
+该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。
+
+## 曾考虑的替代方案
+
+**在第二个后端出现之前保持具体服务(维持现状)。**这正是当初运行时 Agent Note 的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经在面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里,而若维持合并包的现状,它们还会连带搅动每个消费方的实现依赖。
+
+**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决:它在运作层面并未分离任何东西。消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。
+
+**拆出 `types.ts` 但让服务保持具体。**基于同样的理由否决:类型并不是 seam,`ctx.tasks` 及其方法契约才是。生产方需要的是服务键和语义,而不只是类型形状。
+
+## 后果
+
+换来的是:任务注册表如今与全仓库统一的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的后端落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;实现包的 README 拥有生命周期簿记的相关事实。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个基于桩子类的测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
+
+代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合配置必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,得到的将是挂起的 `ctx.tasks`,生产方将按标准的服务缺失行为失败,而不会得到一条专门定制的消息。若推荐的默认后端日后换成其他实现,点名 `dsh-tasks-local` 的配置错误诊断信息会随之陈旧;这一代价已被接受。
diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml
index b89df77c46..1d5c37cff5 100644
--- a/apps/cli/cordis.yml
+++ b/apps/cli/cordis.yml
@@ -52,7 +52,7 @@
name: '@deepseek-ai/dsh-agent'
- id: tasks
- name: '@deepseek-ai/dsh-tasks'
+ name: '@deepseek-ai/dsh-tasks-local'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 8799669e8d..4f3bb5be35 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -57,7 +57,7 @@
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 0f2dfd1610..2da9652c59 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -117,6 +117,7 @@ flowchart LR
pkg_tool_ralph["tool-ralph"]
pkg_tasks["tasks"]
svc_tasks["ctx.tasks
Background task registry"]
+ pkg_tasks_local["tasks-local"]
pkg_tool_tasks["tool-tasks"]
pkg_web["web"]
svc_web["ctx.web
Web access provider registry"]
@@ -192,6 +193,7 @@ flowchart LR
pkg_subagent_spawn --> svc_subagents
pkg_system_prompt --> svc_systemPrompt
pkg_tasks --> svc_tasks
+ pkg_tasks_local --> svc_tasks
pkg_token_meter --> svc_tokenMeter
pkg_tool_bash --> svc_bashEnv
pkg_tools --> svc_tools
@@ -326,7 +328,7 @@ flowchart LR
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. |
-| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. |
+| `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 86331cf22a..d794425e10 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -2058,7 +2058,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
-- `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
+- `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts))
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
@@ -2077,6 +2077,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
+- `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
- `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts))
## Library packages (no plugin entry)
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 4d310a6690..e14d28564a 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1607,9 +1607,16 @@ Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSec
Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts)
-## `ctx.tasks` — `TaskService`
+## `ctx.tasks` — `TaskService` (abstract seam)
-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.
+- start refuses work while no control surface is attached, so a producer cannot start work that callers cannot collect or stop.
```ts cordis-catalog
/**
@@ -1620,7 +1627,7 @@ The `tasks` service: the runtime-global background task registry. See the module
* @param spec - task identity, owner, and synchronous starter.
* @returns the registry-issued `-N` id.
*/
-start(spec: TaskStart): TaskId
+abstract start(spec: TaskStart): TaskId
/**
* List caller-owned and unowned tasks in registration order without exposing
@@ -1628,7 +1635,7 @@ start(spec: TaskStart): TaskId
* @param caller - reading agent; a non-agent caller sees only unowned tasks.
* @returns fresh snapshots.
*/
-list(caller?: Agent): TaskSnapshot[]
+abstract list(caller?: Agent): TaskSnapshot[]
/**
* Return a non-consuming snapshot without changing its read cursor or notice
@@ -1637,7 +1644,7 @@ list(caller?: Agent): TaskSnapshot[]
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
-get(id: TaskId, caller?: Agent): TaskSnapshot
+abstract get(id: TaskId, caller?: Agent): TaskSnapshot
/**
* Read the next stream delta, or the idempotent final output after settlement.
@@ -1647,7 +1654,7 @@ get(id: TaskId, caller?: Agent): TaskSnapshot
* @param caller - reading agent checked against the owner.
* @returns output text and the post-read snapshot.
*/
-read(id: TaskId, caller?: Agent): TaskRead
+abstract read(id: TaskId, caller?: Agent): TaskRead
/**
* Request cancellation, then mark the task stopping and reported. A producer
@@ -1658,21 +1665,20 @@ read(id: TaskId, caller?: Agent): TaskRead
* @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'
+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
+abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise
/**
* Register an effect-scoped completion listener. Each listener is contained;
@@ -1681,7 +1687,7 @@ async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal):
* @param listener - receives each terminal snapshot and its exact owner.
* @returns disposer that unregisters the listener.
*/
-onTaskDone(listener: TaskDoneListener): () => void
+abstract onTaskDone(listener: TaskDoneListener): () => void
/**
* Attach an effect-scoped surface that can read and stop tasks. {@link start}
@@ -1689,12 +1695,12 @@ onTaskDone(listener: TaskDoneListener): () => void
* @param name - diagnostic label; duplicate names remain independent.
* @returns disposer that detaches this surface.
*/
-attachSurface(name: string): () => void
+abstract attachSurface(name: string): () => void
```
Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-data-structures/tasks.md) · [TaskId](../core-data-structures/tasks.md) · [TaskRead](../core-data-structures/tasks.md) · [TaskSnapshot](../core-data-structures/tasks.md) · [TaskStart](../core-data-structures/tasks.md)
-Source: [`packages/tasks/tasks/src/index.ts:77`](../../packages/tasks/tasks/src/index.ts)
+Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts)
## `ctx.tokenMeter` — `TokenMeterService`
diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md
index 2c7555b84d..8d7050be1b 100644
--- a/docs/core-data-structures/tasks.md
+++ b/docs/core-data-structures/tasks.md
@@ -149,4 +149,4 @@ interface TaskRead {
## Service behavior
-[`TaskService`](../../packages/tasks/tasks/src/index.ts) provides atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the package contract and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface.
+The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam defines atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local implementation. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the seam contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index abadead03f..87e0f7c0d5 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -207,6 +207,7 @@ flowchart TD
end
subgraph group_tasks["packages/tasks"]
pkg_tasks["tasks"]
+ pkg_tasks_local["tasks-local"]
pkg_tool_tasks["tool-tasks"]
end
subgraph group_workflow["packages/workflow"]
@@ -433,7 +434,6 @@ flowchart TD
pkg_tasks --> pkg_brand
pkg_tasks --> pkg_invariants
pkg_tasks --> pkg_session
- pkg_tasks --> pkg_timeout
pkg_workflow --> pkg_agent
pkg_workflow --> pkg_brand
pkg_workflow --> pkg_invariants
@@ -508,6 +508,10 @@ flowchart TD
pkg_pty_local --> pkg_sandbox
pkg_pty_local --> pkg_sandbox_policy
pkg_pty_local --> pkg_session
+ pkg_tasks_local --> pkg_agent
+ pkg_tasks_local --> pkg_invariants
+ pkg_tasks_local --> pkg_tasks
+ pkg_tasks_local --> pkg_timeout
pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_invariants
pkg_agent_loop --> pkg_llm
@@ -727,7 +731,7 @@ flowchart TD
pkg_agent_spine_demo --> pkg_skill
pkg_agent_spine_demo --> pkg_skill_local
pkg_agent_spine_demo --> pkg_system_prompt
- pkg_agent_spine_demo --> pkg_tasks
+ pkg_agent_spine_demo --> pkg_tasks_local
pkg_agent_spine_demo --> pkg_tool_bash
pkg_agent_spine_demo --> pkg_tool_goal
pkg_agent_spine_demo --> pkg_tool_skill
@@ -882,7 +886,7 @@ flowchart TD
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
-| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
+| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
@@ -897,6 +901,7 @@ flowchart TD
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) |
+| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
@@ -928,7 +933,7 @@ flowchart TD
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
+| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts
index 86c1559b83..bb8fdfe260 100644
--- a/examples/headless-agent/tests/code-mode.e2e.ts
+++ b/examples/headless-agent/tests/code-mode.e2e.ts
@@ -19,7 +19,7 @@ import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context'
-import TaskService from '@deepseek-ai/dsh-tasks'
+import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -112,7 +112,7 @@ async function typedCodeModeHarness(): Promise {
/** Keyless real-worker harness with the task-owned bash lifecycle. */
async function backgroundCodeModeHarness(cwd: string): Promise {
const harness = await typedCodeModeHarness()
- await harness.plugin(TaskService)
+ await harness.plugin(LocalTaskService)
await harness.plugin(ToolTasks, {})
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await harness.plugin(ToolBash)
diff --git a/examples/package.json b/examples/package.json
index 395c135a2d..8a81d399b8 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -49,6 +49,7 @@
"@deepseek-ai/dsh-subagent-acp": "workspace:*",
"@deepseek-ai/dsh-subagent-fork": "workspace:*",
"@deepseek-ai/dsh-subagent-spawn": "workspace:*",
+ "@deepseek-ai/dsh-tasks-local": "workspace:*",
"@deepseek-ai/dsh-time-context": "workspace:*",
"@deepseek-ai/dsh-timeout-policy": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md
index e58145ee67..0f957e7d89 100644
--- a/packages/bash/tool-bash/README.md
+++ b/packages/bash/tool-bash/README.md
@@ -139,7 +139,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
-Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
+Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
#### Token effect
diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json
index 6fe653fe6f..a89e147e0e 100644
--- a/packages/bash/tool-bash/package.json
+++ b/packages/bash/tool-bash/package.json
@@ -60,6 +60,7 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts
index 81770b1595..b805c7fade 100644
--- a/packages/bash/tool-bash/src/index.ts
+++ b/packages/bash/tool-bash/src/index.ts
@@ -533,9 +533,9 @@ export function apply(ctx: Context, config: Config = {}): void {
}
const tasks = ctx.get('tasks')
if (tasks === undefined) {
- throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
+ throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks')
}
- // The caller owns cancellation until TaskService commits detached ownership.
+ // The caller owns cancellation until ctx.tasks commits detached ownership.
if (exec.signal.aborted) {
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
error.name = 'AbortError'
diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts
index 8adf2165e0..e1315c232a 100644
--- a/packages/bash/tool-bash/tests/integration.spec.ts
+++ b/packages/bash/tool-bash/tests/integration.spec.ts
@@ -8,7 +8,7 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
-import TaskService from '@deepseek-ai/dsh-tasks'
+import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
@@ -27,7 +27,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot, compression: 'none' })
}
await ctx.plugin(AgentLoop, { agents: [] })
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
@@ -169,7 +169,7 @@ describe('bash tool through the agent loop', () => {
})
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
- // The task id is deterministic (a fresh TaskService counts per kind from 1),
+ // The task id is deterministic (a fresh LocalTaskService counts per kind from 1),
// so the script can name `bash-1` without threading a generated id.
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts
index 8811da6ca0..c2b0c3d31b 100644
--- a/packages/bash/tool-bash/tests/tools.spec.ts
+++ b/packages/bash/tool-bash/tests/tools.spec.ts
@@ -12,7 +12,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
-import TaskService from '@deepseek-ai/dsh-tasks'
+import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
@@ -44,7 +44,7 @@ async function setupWithTasks() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
@@ -180,7 +180,7 @@ async function setupSandboxed(withApproval = false) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(SandboxPolicyService, {})
await ctx.plugin(RecordingSandboxExecutor)
@@ -472,10 +472,10 @@ describe('background execution through the task runtime', () => {
})
it('fails loud when the task runtime is not loaded', async () => {
- const ctx = await setup() // no TaskService / ToolTasks
+ const ctx = await setup() // no LocalTaskService / ToolTasks
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
expect(result.isError).toBe(true)
- expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
+ expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks')
})
it('a pre-aborted call is skipped before the process starts', async () => {
@@ -483,7 +483,7 @@ describe('background execution through the task runtime', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(CountingStartExecutor)
await ctx.plugin(ToolBash)
@@ -511,7 +511,7 @@ describe('background execution through the task runtime', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(CountingStartExecutor)
await ctx.plugin(ToolBash)
@@ -1073,7 +1073,7 @@ describe('the model-facing bash tool builds its request from named args only (no
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
}
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(RecordingBashExecutor)
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index d834d3ee21..3681efa015 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -768,38 +768,38 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'tasks',
- summary: 'The `tasks` service: the runtime-global background task registry.',
+ summary: 'Abstract background task registry.',
methods: [
{
- signature: 'start(spec: TaskStart): TaskId',
+ signature: 'abstract start(spec: TaskStart): TaskId',
jsDoc: '/**\n * Preflight access, validation, and owner cleanup before starting and\n * atomically registering work. A throwing starter leaves nothing registered;\n * after it returns, registration cannot fail. Settlement records the outcome,\n * notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `-N` id.\n */',
},
{
- signature: 'list(caller?: Agent): TaskSnapshot[]',
+ signature: 'abstract list(caller?: Agent): TaskSnapshot[]',
jsDoc: '/**\n * List caller-owned and unowned tasks in registration order without exposing\n * another session\'s labels.\n * @param caller - reading agent; a non-agent caller sees only unowned tasks.\n * @returns fresh snapshots.\n */',
},
{
- signature: 'get(id: TaskId, caller?: Agent): TaskSnapshot',
+ signature: 'abstract get(id: TaskId, caller?: Agent): TaskSnapshot',
jsDoc: '/**\n * Return a non-consuming snapshot without changing its read cursor or notice\n * state. Throws for an unknown or foreign task.\n * @param id - task to look up.\n * @param caller - reading agent checked against the owner.\n * @returns a fresh snapshot.\n */',
},
{
- signature: 'read(id: TaskId, caller?: Agent): TaskRead',
+ signature: 'abstract read(id: TaskId, caller?: Agent): TaskRead',
jsDoc: '/**\n * Read the next stream delta, or the idempotent final output after settlement.\n * A terminal read marks the task reported. Throws for an unknown or foreign\n * task.\n * @param id - task to read.\n * @param caller - reading agent checked against the owner.\n * @returns output text and the post-read snapshot.\n */',
},
{
- signature: 'kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'',
+ signature: 'abstract kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'',
jsDoc: '/**\n * Request cancellation, then mark the task stopping and reported. A producer\n * throw propagates without changing task state. Throws for an unknown or\n * foreign task.\n * @param id - task to cancel.\n * @param caller - killing agent checked against the owner.\n * @param reason - logged reason forwarded to the producer.\n * @returns `requested` for live work, otherwise `already-finished`.\n */',
},
{
- signature: 'async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise',
- jsDoc: '/**\n * Wait for settlement or timeout without cancelling the task. Caller abort\n * rejects only while the task is live; after settlement it returns the\n * terminal snapshot so a notice suppressed for this waiter is still delivered.\n * Timed-out and aborted waits detach their resolvers. Throws for invalid,\n * unknown, or foreign input.\n * @param id - task to wait for.\n * @param timeoutMs - positive finite wait bound in milliseconds.\n * @param caller - waiting agent checked against the owner.\n * @param signal - optional cancellation of the wait itself.\n * @returns snapshot at settlement or timeout.\n */',
+ signature: 'abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise',
+ jsDoc: '/**\n * Wait for settlement or timeout without cancelling the task. Caller abort\n * rejects only while the task is live; after settlement the terminal\n * snapshot wins so a notice suppressed for this waiter is still delivered.\n * Throws for invalid, unknown, or foreign input.\n * @param id - task to wait for.\n * @param timeoutMs - positive finite wait bound in milliseconds.\n * @param caller - waiting agent checked against the owner.\n * @param signal - optional cancellation of the wait itself.\n * @returns snapshot at settlement or timeout.\n */',
},
{
- signature: 'onTaskDone(listener: TaskDoneListener): () => void',
+ signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void',
jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */',
},
{
- signature: 'attachSurface(name: string): () => void',
+ signature: 'abstract attachSurface(name: string): () => void',
jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */',
},
],
diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md
index 05ea5c75e2..bfcfef446d 100644
--- a/packages/examples/agent-spine-demo/README.md
+++ b/packages/examples/agent-spine-demo/README.md
@@ -22,7 +22,7 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-tool-goal optional model-facing goal controls
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
-@deepseek-ai/dsh-tasks generic background-task registry
+@deepseek-ai/dsh-tasks-local generic background-task registry
@deepseek-ai/dsh-invariants configurable invariant registry service
@deepseek-ai/dsh-session/invariant
@deepseek-ai/dsh-agent/invariant
diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json
index bf69e27787..923a9aace6 100644
--- a/packages/examples/agent-spine-demo/package.json
+++ b/packages/examples/agent-spine-demo/package.json
@@ -42,7 +42,7 @@
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
- "@deepseek-ai/dsh-tasks": "^0.0.1",
+ "@deepseek-ai/dsh-tasks-local": "^0.0.1",
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tool-goal": "^0.0.1",
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
@@ -74,6 +74,7 @@
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts
index c43ee2ab8d..0ac96aaa85 100644
--- a/packages/examples/agent-spine-demo/src/index.ts
+++ b/packages/examples/agent-spine-demo/src/index.ts
@@ -22,7 +22,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal'
import * as goalSession from '@deepseek-ai/dsh-goal-session'
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
-import TaskService from '@deepseek-ai/dsh-tasks'
+import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants'
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant'
@@ -223,7 +223,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(toolGoal, config.goals.tool ?? {})
ctx.plugin(goalSession)
}
- ctx.plugin(TaskService)
+ ctx.plugin(LocalTaskService)
ctx.plugin(InvariantService, config.invariants ?? {})
ctx.plugin(sessionInvariant)
ctx.plugin(agentInvariant)
diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json
index 0888da5d24..670cd9a629 100644
--- a/packages/examples/agent-spine-demo/tsconfig.json
+++ b/packages/examples/agent-spine-demo/tsconfig.json
@@ -74,6 +74,9 @@
{
"path": "../../tasks/tasks"
},
+ {
+ "path": "../../tasks/tasks-local"
+ },
{
"path": "../../tasks/tool-tasks"
}
diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json
index 2d36fb5c9b..d8b2564736 100644
--- a/packages/pty/tool-pty/package.json
+++ b/packages/pty/tool-pty/package.json
@@ -54,6 +54,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-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts
index fc66d2646e..abd0664893 100644
--- a/packages/pty/tool-pty/src/index.ts
+++ b/packages/pty/tool-pty/src/index.ts
@@ -250,7 +250,7 @@ export function apply(ctx: Context, config: Config = {}): void {
if (args.run_in_background === true) {
if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration')
const tasks = ctx.get('tasks')
- if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
+ if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks')
let cancelRequested = false
const taskId = tasks.start({
kind: 'pty-send',
diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts
index e0b854ee43..dbc05605c6 100644
--- a/packages/pty/tool-pty/tests/tools.spec.ts
+++ b/packages/pty/tool-pty/tests/tools.spec.ts
@@ -9,7 +9,7 @@ import ToolRegistry, { renderToolsSdk } from '@deepseek-ai/dsh-tools'
import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
-import TaskService from '@deepseek-ai/dsh-tasks'
+import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
@@ -106,7 +106,7 @@ async function setupBase(tasks: boolean) {
const stub = stubBackend()
ctx.pty.registerBackend(stub.backend)
if (tasks) {
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
}
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json
index 6ed447dd56..b5c7b5d94f 100644
--- a/packages/subagent/tool-subagent/package.json
+++ b/packages/subagent/tool-subagent/package.json
@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts
index 4eb29d0c6e..cd2eb590ae 100644
--- a/packages/subagent/tool-subagent/src/index.ts
+++ b/packages/subagent/tool-subagent/src/index.ts
@@ -323,7 +323,7 @@ export function apply(ctx: Context, config: Config): void {
}
const tasks = ctx.get('tasks')
if (tasks === undefined) {
- throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
+ throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks')
}
// Task preflight finishes before the starter can spawn a child.
const id = tasks.start({
diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
index 8468216d49..d3409e4604 100644
--- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
+++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
@@ -8,7 +8,7 @@ import { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
-import TaskService from '@deepseek-ai/dsh-tasks'
+import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as mock from './scripted-provider.ts'
import * as tool from '../src/index.ts'
@@ -641,7 +641,7 @@ describe('dsh-tool-subagent background mode', () => {
async function backgroundSetup(toolConfig: tool.Config, mockConfig: Partial = {}) {
const ctx = await setup(toolConfig, mockConfig)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
return ctx
}
@@ -680,7 +680,7 @@ describe('dsh-tool-subagent background mode', () => {
const ctx = await setup({ provider: 'mock' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
expect(result.isError).toBe(true)
- expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
+ expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks-local')
})
it('skips background startup when the tool signal is already aborted', async () => {
@@ -868,7 +868,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
// With no control surface, task preflight fails before the provider can spawn.
const ctx = await setup({ provider: 'mock' })
await ctx.plugin(AgentRegistry)
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
const scopeFiber = ctx.plugin(() => {})
const id = SessionId('sess-p')
const parent = {
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
index 71c68ea250..693a25d38d 100644
--- a/packages/tasks/README.md
+++ b/packages/tasks/README.md
@@ -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 `-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 `-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`.
diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md
new file mode 100644
index 0000000000..5f57d3409d
--- /dev/null
+++ b/packages/tasks/tasks-local/README.md
@@ -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 `-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.
diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json
new file mode 100644
index 0000000000..cdcc823826
--- /dev/null
+++ b/packages/tasks/tasks-local/package.json
@@ -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"
+ }
+}
diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts
new file mode 100644
index 0000000000..f108022b17
--- /dev/null
+++ b/packages/tasks/tasks-local/src/index.ts
@@ -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
+ /** 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()
+ private counters = new Map()
+ private surfaces = new Set()
+ private listeners = new Set()
+ private listenersClosed = false
+ /** Owner agents with attached scope cleanup, mapped to the exact disposer. */
+ private ownerCleanups = new Map Promise | 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((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 {
+ 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((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 {
+ 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 {
+ 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
diff --git a/packages/tasks/tasks-local/src/invariant.ts b/packages/tasks/tasks-local/src/invariant.ts
new file mode 100644
index 0000000000..3447287c08
--- /dev/null
+++ b/packages/tasks/tasks-local/src/invariant.ts
@@ -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 */
diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts
similarity index 97%
rename from packages/tasks/tasks/tests/tasks.spec.ts
rename to packages/tasks/tasks-local/tests/tasks.spec.ts
index 015f1c4f2b..d237dbe094 100644
--- a/packages/tasks/tasks/tests/tasks.spec.ts
+++ b/packages/tasks/tasks-local/tests/tasks.spec.ts
@@ -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 & 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().toEqualTypeOf()
})
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) => {
diff --git a/packages/tasks/tasks-local/tsconfig.json b/packages/tasks/tasks-local/tsconfig.json
new file mode 100644
index 0000000000..147e3915bc
--- /dev/null
+++ b/packages/tasks/tasks-local/tsconfig.json
@@ -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"
+ }
+ ]
+}
diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md
index 1d9ce2b249..f8808f6486 100644
--- a/packages/tasks/tasks/README.md
+++ b/packages/tasks/tasks/README.md
@@ -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.
diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json
index 128a8d2c4e..9bc02879cf 100644
--- a/packages/tasks/tasks/package.json
+++ b/packages/tasks/tasks/package.json
@@ -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"
}
}
diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts
index 16f0807656..17e617e8a7 100644
--- a/packages/tasks/tasks/src/index.ts
+++ b/packages/tasks/tasks/src/index.ts
@@ -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
- /** 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()
- private counters = new Map()
- private surfaces = new Set()
- private listeners = new Set()
- private listenersClosed = false
- /** Owner agents with attached scope cleanup, mapped to the exact disposer. */
- private ownerCleanups = new Map Promise | 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 `-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((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 {
- 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((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
/**
* 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 {
- 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 {
- 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
diff --git a/packages/tasks/tasks/tests/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts
new file mode 100644
index 0000000000..d8d582e410
--- /dev/null
+++ b/packages/tasks/tasks/tests/service.spec.ts
@@ -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 {
+ 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/)
+ })
+})
diff --git a/packages/tasks/tasks/tsconfig.json b/packages/tasks/tasks/tsconfig.json
index e29262ca74..75ade66b8c 100644
--- a/packages/tasks/tasks/tsconfig.json
+++ b/packages/tasks/tasks/tsconfig.json
@@ -23,9 +23,6 @@
{
"path": "../../core/session"
},
- {
- "path": "../../util/timeout"
- },
{
"path": "../../support/invariants"
}
diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json
index 2e03fb26a2..2fd0b464a4 100644
--- a/packages/tasks/tool-tasks/package.json
+++ b/packages/tasks/tool-tasks/package.json
@@ -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"
}
diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
index c41f498472..8494ff7f81 100644
--- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
+++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts
@@ -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()
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7894b009ad..5ed728d64d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -227,9 +227,9 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../packages/core/system-prompt
- '@deepseek-ai/dsh-tasks':
+ '@deepseek-ai/dsh-tasks-local':
specifier: workspace:^
- version: link:../../packages/tasks/tasks
+ version: link:../../packages/tasks/tasks-local
'@deepseek-ai/dsh-timeout-policy':
specifier: workspace:^
version: link:../../packages/timeout/timeout-policy
@@ -472,6 +472,9 @@ importers:
'@deepseek-ai/dsh-subagent-spawn':
specifier: workspace:*
version: link:../packages/subagent/subagent-spawn
+ '@deepseek-ai/dsh-tasks-local':
+ specifier: workspace:*
+ version: link:../packages/tasks/tasks-local
'@deepseek-ai/dsh-time-context':
specifier: workspace:*
version: link:../packages/context/time-context
@@ -686,6 +689,9 @@ importers:
'@deepseek-ai/dsh-tasks':
specifier: workspace:^
version: link:../../tasks/tasks
+ '@deepseek-ai/dsh-tasks-local':
+ specifier: workspace:^
+ version: link:../../tasks/tasks-local
'@deepseek-ai/dsh-tool-tasks':
specifier: workspace:^
version: link:../../tasks/tool-tasks
@@ -1658,6 +1664,9 @@ importers:
'@deepseek-ai/dsh-tasks':
specifier: workspace:^
version: link:../../tasks/tasks
+ '@deepseek-ai/dsh-tasks-local':
+ specifier: workspace:^
+ version: link:../../tasks/tasks-local
'@deepseek-ai/dsh-tool-bash':
specifier: workspace:^
version: link:../../bash/tool-bash
@@ -2698,6 +2707,9 @@ importers:
'@deepseek-ai/dsh-tasks':
specifier: workspace:^
version: link:../../tasks/tasks
+ '@deepseek-ai/dsh-tasks-local':
+ specifier: workspace:^
+ version: link:../../tasks/tasks-local
'@deepseek-ai/dsh-tool-tasks':
specifier: workspace:^
version: link:../../tasks/tool-tasks
@@ -3612,6 +3624,9 @@ importers:
'@deepseek-ai/dsh-tasks':
specifier: workspace:^
version: link:../../tasks/tasks
+ '@deepseek-ai/dsh-tasks-local':
+ specifier: workspace:^
+ version: link:../../tasks/tasks-local
'@deepseek-ai/dsh-tool-tasks':
specifier: workspace:^
version: link:../../tasks/tool-tasks
@@ -3729,11 +3744,32 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ cordis:
+ specifier: ^4.0.0-rc.6
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
+ packages/tasks/tasks-local:
+ devDependencies:
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-brand':
+ specifier: workspace:^
+ version: link:../../util/brand
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-tasks':
+ specifier: workspace:^
+ version: link:../tasks
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
cordis:
- specifier: ^4.0.0-rc.6
+ specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/tasks/tool-tasks:
@@ -3763,6 +3799,9 @@ importers:
'@deepseek-ai/dsh-tasks':
specifier: workspace:^
version: link:../tasks
+ '@deepseek-ai/dsh-tasks-local':
+ specifier: workspace:^
+ version: link:../tasks-local
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
@@ -4615,6 +4654,9 @@ importers:
'@deepseek-ai/dsh-tasks':
specifier: workspace:^
version: link:../../packages/tasks/tasks
+ '@deepseek-ai/dsh-tasks-local':
+ specifier: workspace:^
+ version: link:../../packages/tasks/tasks-local
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../packages/util/timeout
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index 8a8d31c815..abf943793d 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -66,6 +66,7 @@
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 4c8817f318..2551f95df9 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -365,9 +365,10 @@ const SERVICE_ROLES: ServiceRole[] = [
key: 'tasks',
pkg: 'tasks',
title: 'Background task registry',
- mode: 'core',
+ mode: 'seam',
+ implementations: ['tasks-local'],
consumers: ['tool-bash', 'tool-pty', 'tool-subagent', 'tool-tasks'],
- note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.',
+ note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry.',
},
{
key: 'web',
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index 3bbfd5b1ea..45aa58d74e 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -29,7 +29,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
-import TaskService from '@deepseek-ai/dsh-tasks'
+import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -355,7 +355,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result', 'user/message via agent.inject() for background completion notices'],
async mount(ctx) {
- await ctx.plugin(TaskService)
+ await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks)
},
note:
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index d68df7adcd..16803b157f 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -93,6 +93,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
+ 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' },
'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 3a5441120b..1aab67964a 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -135,6 +135,7 @@
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
{ "path": "./packages/tasks/tasks" },
+ { "path": "./packages/tasks/tasks-local" },
{ "path": "./packages/tasks/tool-tasks" },
{ "path": "./packages/workflow/workflow" },
{ "path": "./packages/workflow/workflow-workerthread" },
From b61a5ff5e3240d508cdfb953264ddd32e185ea3e Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 05:35:04 +0800
Subject: [PATCH 2/6] docs(tasks): bilingual pair for the task-registry seam
Agent Note
Adds the Chinese counterpart of the new seam note, records both pairs
(new note + the updated background-task runtime note), and ratchets the
translation-pairing manifest.
---
...-20-generic-long-running-tool-runtime.i18n.yaml | 4 ++--
...6-06-20-generic-long-running-tool-runtime.zh.md | 4 ++--
.../2026-07-26-task-registry-seam.i18n.yaml | 6 ++++++
.../2026-07-26-task-registry-seam.zh.md | 14 +++++++-------
scripts/translation-pairing.manifest.json | 5 +++--
5 files changed, 20 insertions(+), 13 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
index d44e3ffee9..db80fbcfa9 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-06-20-generic-long-running-tool-runtime.md: 0b901fcf928b900bd3a32f911e6e54a6a98076e2
-2026-06-20-generic-long-running-tool-runtime.zh.md: e2860e3a91c06ec5110cd671b288e35c5d117f5d
+2026-06-20-generic-long-running-tool-runtime.md: 313d687b49da0d08b0ec321bcb655b642f7a5af3
+2026-06-20-generic-long-running-tool-runtime.zh.md: 6be129b7b16ff01d73dc94f7ce6d299ee2c10e55
diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
index 39900e24ba..6be129b7b1 100644
--- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
@@ -19,7 +19,7 @@ Status: implemented
长时间运行工具是生产方。`dsh-tool-bash` 将 `BashProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。执行 seam 保持独立,不依赖会话或任务注册表。
-`TaskService` 是 `@deepseek-ai/dsh-tasks` 中的抽象 seam;进程内注册表是 `@deepseek-ai/dsh-tasks-local` 中的 `LocalTaskService`(该拆分记录在[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.zh.md)中)。
+`TaskService` 是 `@deepseek-ai/dsh-tasks` 中的抽象 seam;进程内注册表是 `@deepseek-ai/dsh-tasks-local` 中的 `LocalTaskService`(该拆分记录在[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.md)中)。
## 运行时契约
@@ -103,7 +103,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas
### 立即抽象任务运行时后端
-当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.zh.md)后来在不改变这些进程内语义的前提下,将契约与进程内实现分离。
+当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表 seam Agent Note](2026-07-26-task-registry-seam.md)后来在不改变这些进程内语义的前提下,将契约与进程内实现分离。
### 由消费方负责授权或清理事件
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
new file mode 100644
index 0000000000..e7c39e376a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-26-task-registry-seam.md: b785eb75a632503def10fad583f6a68477cffef6
+2026-07-26-task-registry-seam.zh.md: 3d2426b0208afbbebe51254e43cae64cad12f11a
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
index aa4df43b82..3d2426b020 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
@@ -6,30 +6,30 @@ Status: implemented
## 问题
-[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有所有生产方和控制接口面向编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除逻辑)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力(bash、pty、fs、skill、subagent、web、会话持久化)都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。
+[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向其编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。
## 决策
`tasks/` 如今是一个 bash 三件套形态的三包能力家族:
- **`@deepseek-ai/dsh-tasks`(接口)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的契约(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个实现都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且在没有附加任何控制接口时 `start` 拒绝启动工作。
-- **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除逻辑。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。
+- **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。
- **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。
-各组合配置在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`(CLI 的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness、工具目录生成器的启动流程)。生产方的配置错误诊断信息("background tasks unavailable: load …")点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。
+各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)应用的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息("background tasks unavailable: load …")点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。
该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。
## 曾考虑的替代方案
-**在第二个后端出现之前保持具体服务(维持现状)。**这正是当初运行时 Agent Note 的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经在面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里,而若维持合并包的现状,它们还会连带搅动每个消费方的实现依赖。
+**在第二个后端出现之前保持具体服务(维持现状)。**这正是当初运行时 Agent Note 的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经面向其编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里;而若维持现状,它们今天还会连带搅动每个消费方的实现依赖。
-**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决:它在运作层面并未分离任何东西。消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。
+**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决,因为它在运作层面并未分离任何东西:消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。
**拆出 `types.ts` 但让服务保持具体。**基于同样的理由否决:类型并不是 seam,`ctx.tasks` 及其方法契约才是。生产方需要的是服务键和语义,而不只是类型形状。
## 后果
-换来的是:任务注册表如今与全仓库统一的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的后端落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;实现包的 README 拥有生命周期簿记的相关事实。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个基于桩子类的测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
+换来的是:任务注册表如今与全仓库统一的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的后端落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;实现包的 README 拥有生命周期簿记的相关事实。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个基于桩子类(stub subclass)的测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
-代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合配置必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,得到的将是挂起的 `ctx.tasks`,生产方将按标准的服务缺失行为失败,而不会得到一条专门定制的消息。若推荐的默认后端日后换成其他实现,点名 `dsh-tasks-local` 的配置错误诊断信息会随之陈旧;这一代价已被接受。
+代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这是已接受的代价。
diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json
index 300a213484..cbc39c0bde 100644
--- a/scripts/translation-pairing.manifest.json
+++ b/scripts/translation-pairing.manifest.json
@@ -43,6 +43,7 @@
".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md",
".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md",
".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md",
+ ".agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md",
".agents/notes/implemented/feature/2026-06-14-acp-multi-session.md",
".agents/notes/implemented/feature/2026-06-15-code-mode.md",
".agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md",
@@ -66,6 +67,7 @@
".agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md",
".agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md",
".agents/notes/implemented/feature/2026-07-10-session-query-service.md",
+ ".agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md",
".agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md",
".agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md",
".agents/notes/implemented/process/2026-06-11-quality-gates.md",
@@ -128,8 +130,6 @@
".agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md",
".agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md",
".agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md",
- ".agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md",
- ".agents/notes/rejected/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md",
".agents/notes/proposed/process/2026-06-11-api-extractor-reports.md",
".agents/notes/proposed/process/2026-06-11-architectural-conformance.md",
".agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md",
@@ -139,6 +139,7 @@
".agents/notes/proposed/testing/2026-06-11-mutation-testing.md",
".agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.md",
".agents/notes/rejected/architecture/2026-06-20-providerless-example-base.md",
+ ".agents/notes/rejected/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md",
".agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.md",
".agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md",
".agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.md",
From 71c564d801b977ade24deba1903dad8cd0bfd2a5 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 06:07:35 +0800
Subject: [PATCH 3/6] docs(tasks): final translation pass on the seam note zh
counterpart
---
.../2026-07-26-task-registry-seam.i18n.yaml | 2 +-
.../architecture/2026-07-26-task-registry-seam.zh.md | 12 ++++++------
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
index e7c39e376a..409bc30c12 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-26-task-registry-seam.md: b785eb75a632503def10fad583f6a68477cffef6
-2026-07-26-task-registry-seam.zh.md: 3d2426b0208afbbebe51254e43cae64cad12f11a
+2026-07-26-task-registry-seam.zh.md: bfb733a5e1060c9bfe2acc6c4769aa47443d0c9e
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
index 3d2426b020..bfb733a5e1 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向其编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。
+[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包(package):`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向编程的 `ctx.tasks` 契约,也拥有进程内实现(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备接口/实现/消费方三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。
## 决策
@@ -16,20 +16,20 @@ Status: implemented
- **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。
- **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。
-各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)应用的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息("background tasks unavailable: load …")点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。
+各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。
该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。
## 曾考虑的替代方案
-**在第二个后端出现之前保持具体服务(维持现状)。**这正是当初运行时 Agent Note 的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经面向其编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里;而若维持现状,它们今天还会连带搅动每个消费方的实现依赖。
+**在第二个后端出现之前保持具体服务(维持现状)。**这正是运行时 Agent Note 当初的立场:在第二种实现出现前抽取接口,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更契约)不因这次拆分而改变:无论拆分与否,这类变更都会落在 seam 包里;而若维持现状,它们今天还会连带搅动每个消费方的实现依赖。
-**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决,因为它在运作层面并未分离任何东西:消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。
+**在单个包内仅抽取接口(在具体类旁导出一个抽象类)。**否决,因为它在运作层面并未分离任何东西:消费方依然依赖携带实现及其依赖项的那个包,而替换后端若不把本地实现纳入自身依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。
**拆出 `types.ts` 但让服务保持具体。**基于同样的理由否决:类型并不是 seam,`ctx.tasks` 及其方法契约才是。生产方需要的是服务键和语义,而不只是类型形状。
## 后果
-换来的是:任务注册表如今与全仓库统一的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的后端落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;实现包的 README 拥有生命周期簿记的相关事实。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个基于桩子类(stub subclass)的测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
+换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
-代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这是已接受的代价。
+代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。
From 3b911359232d784ca336c07d54b1bb7c2e893d66 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 07:25:47 +0800
Subject: [PATCH 4/6] fix(tasks): fail loud when the abstract seam is mounted
directly
Review finding (Codex round 1): abstract erases at runtime and
@deepseek-ai/dsh-tasks used to be the mountable registry, so a stale
composition row would register a ctx.tasks with no method implementations
and fail far from the misconfiguration. The seam constructor now rejects
direct mounts with a load-time pointer at dsh-tasks-local; the seam suite
pins the fence, the Agent Note cost paragraph records the actual behavior,
and the stale tool-pty README requirement line names the implementation
package.
---
.../architecture/2026-07-26-task-registry-seam.i18n.yaml | 4 ++--
.../architecture/2026-07-26-task-registry-seam.md | 2 +-
.../architecture/2026-07-26-task-registry-seam.zh.md | 2 +-
packages/pty/tool-pty/README.md | 2 +-
packages/tasks/tasks/src/index.ts | 7 +++++++
packages/tasks/tasks/tests/service.spec.ts | 6 ++++++
6 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
index 409bc30c12..530e12edae 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-26-task-registry-seam.md: b785eb75a632503def10fad583f6a68477cffef6
-2026-07-26-task-registry-seam.zh.md: bfb733a5e1060c9bfe2acc6c4769aa47443d0c9e
+2026-07-26-task-registry-seam.md: d550b5b081a7980cceddd3c1eb65c3a9a175906f
+2026-07-26-task-registry-seam.zh.md: 1088465b908fd905900aa11479a48632fff3fe6f
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
index b785eb75a6..d550b5b081 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
@@ -32,4 +32,4 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st
Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling package implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The seam README states the contract; the implementation README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the seam keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite.
-Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package — a boot that loads only `@deepseek-ai/dsh-tasks` gets a pending `ctx.tasks` and producers fail with the standard missing-service behavior rather than a bespoke message. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default.
+Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package. `abstract` erases at runtime and this package name used to be the mountable registry, so the seam constructor fails loudly when mounted directly — a stale composition row gets "load an implementation such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default.
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
index bfb733a5e1..1088465b90 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
@@ -32,4 +32,4 @@ Status: implemented
换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
-代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。
+代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。
diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md
index f4f1e7af7e..b16cb271f1 100644
--- a/packages/pty/tool-pty/README.md
+++ b/packages/pty/tool-pty/README.md
@@ -66,4 +66,4 @@ Append-only; new results follow the reusable request prefix.
## Known Limitations and Deferred Work
- No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed.
-- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface.
+- Background mode requires both `@deepseek-ai/dsh-tasks-local` and the model-facing control surface from `@deepseek-ai/dsh-tool-tasks`.
diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts
index 17e617e8a7..e237aa0681 100644
--- a/packages/tasks/tasks/src/index.ts
+++ b/packages/tasks/tasks/src/index.ts
@@ -49,6 +49,13 @@ declare module 'cordis' {
*/
export abstract class TaskService extends Service {
constructor(ctx: Context) {
+ // `abstract` erases at runtime, and this package name used to be the
+ // mountable concrete registry — a stale composition row would otherwise
+ // register a ctx.tasks with no method implementations and fail far from
+ // the misconfiguration. Fail loud at load instead.
+ if (new.target === TaskService) {
+ throw new Error('@deepseek-ai/dsh-tasks is the abstract task registry seam; load an implementation such as @deepseek-ai/dsh-tasks-local instead')
+ }
super(ctx, 'tasks')
}
diff --git a/packages/tasks/tasks/tests/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts
index d8d582e410..82fc415f51 100644
--- a/packages/tasks/tasks/tests/service.spec.ts
+++ b/packages/tasks/tasks/tests/service.spec.ts
@@ -79,4 +79,10 @@ describe('TaskService seam', () => {
class SecondTaskService extends StubTaskService {}
await expect(ctx.plugin(SecondTaskService)).rejects.toThrow(/service "tasks" has been registered/)
})
+
+ it('mounting the abstract seam directly fails loudly at load (stale-composition fence)', async () => {
+ const ctx = new Context()
+ await expect(ctx.plugin(TaskService as unknown as typeof StubTaskService))
+ .rejects.toThrow(/abstract task registry seam; load an implementation such as @deepseek-ai\/dsh-tasks-local/)
+ })
})
From 1bc090fe00bc07736925a51505a342194f6b29b4 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 12:17:45 +0800
Subject: [PATCH 5/6] fix(tasks): producer diagnostics name the seam, not one
implementation
Review feedback (tianyicui, PR #657 inline): the missing-service message
should mention dsh-tasks, which defines ctx.tasks, rather than promoting a
specific backend. The seam's own surfaces (README, the direct-mount fence)
keep pointing at implementations, so the pointer chain still lands on
dsh-tasks-local without the producer strings going stale when another
backend becomes the recommended default. Agent Note updated accordingly
(en+zh, re-recorded).
---
.../architecture/2026-07-26-task-registry-seam.i18n.yaml | 4 ++--
.../implemented/architecture/2026-07-26-task-registry-seam.md | 4 ++--
.../architecture/2026-07-26-task-registry-seam.zh.md | 4 ++--
packages/bash/tool-bash/README.md | 2 +-
packages/bash/tool-bash/src/index.ts | 2 +-
packages/bash/tool-bash/tests/tools.spec.ts | 2 +-
packages/pty/tool-pty/README.md | 2 +-
packages/pty/tool-pty/src/index.ts | 2 +-
packages/subagent/tool-subagent/src/index.ts | 2 +-
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts | 2 +-
10 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
index 530e12edae..0187c1ff47 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-26-task-registry-seam.md: d550b5b081a7980cceddd3c1eb65c3a9a175906f
-2026-07-26-task-registry-seam.zh.md: 1088465b908fd905900aa11479a48632fff3fe6f
+2026-07-26-task-registry-seam.md: 57ac176cf6d2b0a50fcbcfacd77f6a26b462b582
+2026-07-26-task-registry-seam.zh.md: 252382ac39ebf1e5077fad87fcee2537ae8a9ab3
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
index d550b5b081..57ac176cf6 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
@@ -16,7 +16,7 @@ The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) s
- **`@deepseek-ai/dsh-tasks-local` (implementation)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the seam has no implementation dependencies.
- **`@deepseek-ai/dsh-tool-tasks` (consumer)** — unchanged; it injects `'tasks'` and never imports implementation types.
-Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks-local` because a deployment fixes them by loading the implementation, not the interface. Producers, `TaskKindMap` declaration merges, and the control surface keep importing `@deepseek-ai/dsh-tasks` only.
+Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks` — the seam that defines the absent `ctx.tasks` service — and the seam's own surfaces (its README and the direct-mount fence) point at implementations, so the producer message stays correct when another backend becomes the recommended default. Producers, `TaskKindMap` declaration merges, and the control surface keep importing `@deepseek-ai/dsh-tasks` only.
The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` still passes callbacks and exact `Agent` objects, so a durable or cross-process backend still has design work to do before it can implement this interface (identity, restart, ownership, observation). The split moves that future work out of every consumer's dependency graph; it does not pre-design the backend.
@@ -32,4 +32,4 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st
Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling package implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The seam README states the contract; the implementation README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the seam keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite.
-Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package. `abstract` erases at runtime and this package name used to be the mountable registry, so the seam constructor fails loudly when mounted directly — a stale composition row gets "load an implementation such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default.
+Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package. `abstract` erases at runtime and this package name used to be the mountable registry, so the seam constructor fails loudly when mounted directly — a stale composition row gets "load an implementation such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration.
diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
index 1088465b90..252382ac39 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
@@ -16,7 +16,7 @@ Status: implemented
- **`@deepseek-ai/dsh-tasks-local`(实现)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;seam 包不含任何实现依赖。
- **`@deepseek-ai/dsh-tool-tasks`(消费方)**——保持不变;它注入 `'tasks'`,从不导入实现类型。
-各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks-local`,因为部署方修复该问题的办法是加载实现包,而非接口包。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。
+各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即定义缺失的 `ctx.tasks` 服务的 seam 包;seam 自身的表面(其 README 与直接挂载防线)会指向各实现,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。
该 seam 保持进程内契约语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能实现此接口之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个消费方的依赖图;它并不预先设计后端。
@@ -32,4 +32,4 @@ Status: implemented
换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
-代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。
+代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。
diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md
index 0f957e7d89..e58145ee67 100644
--- a/packages/bash/tool-bash/README.md
+++ b/packages/bash/tool-bash/README.md
@@ -139,7 +139,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
-Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
+Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`.
#### Token effect
diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts
index b805c7fade..b403c4414e 100644
--- a/packages/bash/tool-bash/src/index.ts
+++ b/packages/bash/tool-bash/src/index.ts
@@ -533,7 +533,7 @@ export function apply(ctx: Context, config: Config = {}): void {
}
const tasks = ctx.get('tasks')
if (tasks === undefined) {
- throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks')
+ throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// The caller owns cancellation until ctx.tasks commits detached ownership.
if (exec.signal.aborted) {
diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts
index c2b0c3d31b..80840fbf75 100644
--- a/packages/bash/tool-bash/tests/tools.spec.ts
+++ b/packages/bash/tool-bash/tests/tools.spec.ts
@@ -475,7 +475,7 @@ describe('background execution through the task runtime', () => {
const ctx = await setup() // no LocalTaskService / ToolTasks
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
expect(result.isError).toBe(true)
- expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks')
+ expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
})
it('a pre-aborted call is skipped before the process starts', async () => {
diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md
index b16cb271f1..f4f1e7af7e 100644
--- a/packages/pty/tool-pty/README.md
+++ b/packages/pty/tool-pty/README.md
@@ -66,4 +66,4 @@ Append-only; new results follow the reusable request prefix.
## Known Limitations and Deferred Work
- No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed.
-- Background mode requires both `@deepseek-ai/dsh-tasks-local` and the model-facing control surface from `@deepseek-ai/dsh-tool-tasks`.
+- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface.
diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts
index abd0664893..fc66d2646e 100644
--- a/packages/pty/tool-pty/src/index.ts
+++ b/packages/pty/tool-pty/src/index.ts
@@ -250,7 +250,7 @@ export function apply(ctx: Context, config: Config = {}): void {
if (args.run_in_background === true) {
if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration')
const tasks = ctx.get('tasks')
- if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks')
+ if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
let cancelRequested = false
const taskId = tasks.start({
kind: 'pty-send',
diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts
index cd2eb590ae..4eb29d0c6e 100644
--- a/packages/subagent/tool-subagent/src/index.ts
+++ b/packages/subagent/tool-subagent/src/index.ts
@@ -323,7 +323,7 @@ export function apply(ctx: Context, config: Config): void {
}
const tasks = ctx.get('tasks')
if (tasks === undefined) {
- throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks-local and @deepseek-ai/dsh-tool-tasks')
+ throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// Task preflight finishes before the starter can spawn a child.
const id = tasks.start({
diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
index d3409e4604..5c27a09e2b 100644
--- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
+++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
@@ -680,7 +680,7 @@ describe('dsh-tool-subagent background mode', () => {
const ctx = await setup({ provider: 'mock' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
expect(result.isError).toBe(true)
- expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks-local')
+ expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
})
it('skips background startup when the tool signal is already aborted', async () => {
From d0aebc9f9270f30fd91666ed4c21f895cb4e4da1 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 20:59:05 +0800
Subject: [PATCH 6/6] docs(tasks): bring the zh side of the tasks pairs along
after the master merge
Master made bilingual pairing mandatory repo-wide; this PR's seam-split
edits to the tasks docs get their zh counterparts: a new pair for the
dsh-tasks-local README and minimal updates to the tasks core-data doc,
agent-spine-demo README, and the tasks family READMEs, with pairing
records re-recorded.
---
docs/core-data-structures/tasks.i18n.yaml | 4 +--
docs/core-data-structures/tasks.zh.md | 2 +-
.../agent-spine-demo/README.i18n.yaml | 4 +--
.../examples/agent-spine-demo/README.zh.md | 2 +-
packages/tasks/README.i18n.yaml | 4 +--
packages/tasks/README.zh.md | 5 ++--
packages/tasks/tasks-local/README.i18n.yaml | 6 +++++
packages/tasks/tasks-local/README.md | 2 ++
packages/tasks/tasks-local/README.zh.md | 26 +++++++++++++++++++
packages/tasks/tasks/README.i18n.yaml | 4 +--
packages/tasks/tasks/README.zh.md | 16 ++++--------
11 files changed, 52 insertions(+), 23 deletions(-)
create mode 100644 packages/tasks/tasks-local/README.i18n.yaml
create mode 100644 packages/tasks/tasks-local/README.zh.md
diff --git a/docs/core-data-structures/tasks.i18n.yaml b/docs/core-data-structures/tasks.i18n.yaml
index f9d14f2163..3a5a45566b 100644
--- a/docs/core-data-structures/tasks.i18n.yaml
+++ b/docs/core-data-structures/tasks.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-tasks.md: d1f5a6d7b369e6113132f60e493cf87757e20599
-tasks.zh.md: 1562d9401f0f55ac6d6260902b8b1c71d9664d48
+tasks.md: a38055d3ef7aa18e62678f92eb5ac5ae2a09c205
+tasks.zh.md: b5dd7f75c7df3e359bc995fce57f1ca2dc7fd017
diff --git a/docs/core-data-structures/tasks.zh.md b/docs/core-data-structures/tasks.zh.md
index 1562d9401f..b5dd7f75c7 100644
--- a/docs/core-data-structures/tasks.zh.md
+++ b/docs/core-data-structures/tasks.zh.md
@@ -151,4 +151,4 @@ interface TaskRead {
## 服务行为
-[`TaskService`](../../packages/tasks/tasks/src/index.ts) 提供原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。包(package)契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。
+抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam 定义原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部实现。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。seam 契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。
diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml
index fe005dcae8..aaf3b492cd 100644
--- a/packages/examples/agent-spine-demo/README.i18n.yaml
+++ b/packages/examples/agent-spine-demo/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-README.md: 736de2ea01e1524854c57f91d128b82a9fe0c9e8
-README.zh.md: 4ffe47ba82539d12c9b74b1690392d58d21a24b1
+README.md: 32874bf2839c194572ddde8c4ed007297f763ccc
+README.zh.md: 57a06a00203b8e67f2f33c87d7450d1a0789d7e6
diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md
index 4ffe47ba82..57a06a0020 100644
--- a/packages/examples/agent-spine-demo/README.zh.md
+++ b/packages/examples/agent-spine-demo/README.zh.md
@@ -24,7 +24,7 @@
@deepseek-ai/dsh-tool-goal optional model-facing goal controls
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
-@deepseek-ai/dsh-tasks generic background-task registry
+@deepseek-ai/dsh-tasks-local generic background-task registry
@deepseek-ai/dsh-invariants configurable invariant registry service
@deepseek-ai/dsh-session/invariant
@deepseek-ai/dsh-agent/invariant
diff --git a/packages/tasks/README.i18n.yaml b/packages/tasks/README.i18n.yaml
index 0cd358369b..79f5e7b8e2 100644
--- a/packages/tasks/README.i18n.yaml
+++ b/packages/tasks/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-README.md: f1c224345c94a833c44cbafb635be7617e8c42bf
-README.zh.md: 610a84a1506b4bb780297322f7827e6f04533bc1
+README.md: 9bafe5633bb7e57a5404ffb41fad04b621832b6d
+README.zh.md: 73c87a2c95ccebf70558a2051149eca4ba41f60e
diff --git a/packages/tasks/README.zh.md b/packages/tasks/README.zh.md
index 610a84a150..73c87a2c95 100644
--- a/packages/tasks/README.zh.md
+++ b/packages/tasks/README.zh.md
@@ -2,11 +2,12 @@
[English](README.md) | 中文
-后台 task id、拥有者隔离、读取、取消、等待和完成通知的共用归属位置。Bash、subagent 及未来的长时间运行工具共用一套面向模型的协议。参见[后台任务运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)。
+后台 task id、拥有者隔离、读取、取消、等待和完成通知的共用归属位置。Bash、subagent 及未来的长时间运行工具共用一套面向模型的协议。参见[后台任务运行时 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和[任务注册表 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md)。
| 包(package) | ctx 键 | 角色 |
|---|---|---|
-| [`tasks`](tasks/README.md)(`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | 注册表服务:品牌化 `-N` id、按拥有者设防的 read/kill/wait/list、结算记账、等待完成的拥有者清理路径,以及防止 `attachSurface` 配置错误的防线 |
+| [`tasks`](tasks/README.md)(`@deepseek-ai/dsh-tasks`) | `ctx.tasks` | 注册表 seam:品牌化 `-N` id、按拥有者设防的 read/kill/wait/list 契约、快照词汇、防止 `attachSurface` 配置错误的防线,以及快照不变式配套插件 |
+| [`tasks-local`](tasks-local/README.md)(`@deepseek-ai/dsh-tasks-local`) | 无 | 进程局部的注册表实现:内存记录、首次结果优先的结算簿记,以及等待完成的拥有者清理与拆卸路径 |
| [`tool-tasks`](tool-tasks/README.md)(`@deepseek-ai/dsh-tool-tasks`) | 无 | 面向模型的控制接口:`task_output`、`task_list`、`task_kill`、完成通知注入和后台工作习惯提示词段落 |
注册表拥有跨生产方或接口重载的状态;工具包拥有呈现。生产方通过 `ctx.tasks.start` 注册执行钩子,并自行决定其配置是否公开 `run_in_background`。
diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml
new file mode 100644
index 0000000000..532331c5be
--- /dev/null
+++ b/packages/tasks/tasks-local/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+README.md: 23ca6fca61ccb59c855e5d6da6b0a2e23e7cb632
+README.zh.md: c5553a76690278f5b6d5ec40a55d213ef7e1e2d9
diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md
index 5f57d3409d..23ca6fca61 100644
--- a/packages/tasks/tasks-local/README.md
+++ b/packages/tasks/tasks-local/README.md
@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-tasks-local
+English | [中文](README.zh.md)
+
Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry seam: `LocalTaskService` keeps every record in memory, issues per-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
diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md
new file mode 100644
index 0000000000..c5553a7669
--- /dev/null
+++ b/packages/tasks/tasks-local/README.zh.md
@@ -0,0 +1,26 @@
+# @deepseek-ai/dsh-tasks-local
+
+[English](README.md) | 中文
+
+[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表 seam 的进程局部实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。它没有配置;作为插件加载后即注册为 `ctx.tasks`。
+
+## 生命周期
+
+任务属于其 owner 和后端,而不是生产方工具 fiber,因此重载生产方或表层不会停止任务。某个 owner 的第一个任务会把一个受等待的 effect 附加到精确的 `Agent` scope。owner 释放会取消该对象的任务,等待生产方完全停稳,并移除其快照;复用 agent 或 Session id 无法重定向旧清理。
+
+服务释放会关闭监听器、取消所有存活任务、等待其记录,并从仍存活的 owner scope 分离 effect。如果拆卸取消抛出异常,服务会强制把记录标为失败,并警告工作可能遗留,而不会死锁。取消已返回但始终不终止 `done` 时,系统无法将其与缓慢停止区分开,拆卸可能因此停滞。
+
+结算遵循首次结果优先:最早出现的终止结果(生产方结算、被隔离为 `failed` 的 `done` 拒绝,或拆卸强制失败)只记录一次,只通知监听器一次并对每个监听器单独隔离故障,然后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此呈现完成情况的表层不会重复发出通知。
+
+## 模型体验
+
+通过生产方插件和 [`dsh-tool-tasks`](../tool-tasks/README.md) 间接影响;它们会渲染 task id、输出、状态、取消和完成通知。
+
+#### KV Cache 影响
+
+不会直接失效;请求前缀变更由命名消费方负责。
+
+## 已知限制与暂缓事项
+
+- **任务只存在于进程本地**:记录随 harness 进程一起消亡;持久或跨重启执行需要一个单独实现该 seam 的后端。
+- **静默无效的取消可能使拆卸停滞**:只有显式抛出异常才能安全地强制标为失败。
diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml
index fc9157bddc..b86c63e859 100644
--- a/packages/tasks/tasks/README.i18n.yaml
+++ b/packages/tasks/tasks/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-README.md: 1a073add0fde8f2e519cc83b087af6a531a6cbb8
-README.zh.md: 795602701f072068f05bbf16ee98bdeea57548af
+README.md: 2f822bad139020f0ebae0165aa4e8893853f635d
+README.zh.md: 4adb249f31241d5c61c3f8cbee638e8243e4a92e
diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md
index 795602701f..4adb249f31 100644
--- a/packages/tasks/tasks/README.zh.md
+++ b/packages/tasks/tasks/README.zh.md
@@ -2,9 +2,9 @@
[English](README.md) | 中文
-进程局部的后台任务注册表(`ctx.tasks`)。它为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理。生产方插件使用其不透明 id namespace 扩展 `TaskKindMap`。
+后台任务注册表 seam(`ctx.tasks`)。抽象的 `TaskService` 及其词汇类型在同一份契约下为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理;进程局部注册表位于 [`dsh-tasks-local`](../tasks-local/README.md)。生产方插件使用其不透明 id namespace 扩展 `TaskKindMap`。
-## 服务 API
+## 服务契约
- `start(spec): TaskId` 验证控制表层、spec、精确的存活 owner,以及可选的正 `outputLimitBytes`,然后只调用生产方的 `run()` 一次。启动方抛出异常时不注册任何内容;成功返回会直接提交,不再执行其他可能失败的步骤。
- `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。
@@ -18,13 +18,9 @@
`outputLimitBytes` 是生产方拥有的模型呈现策略,会原样携带到快照中。控制表层在添加状态或通知元数据后应用它;注册表不会重写生产方输出,也不会为省略此字段的生产方虚构默认值。
-## 生命周期
+实现还必须兑现契约的生命周期语义:注册的存续期长于生产方与控制表层的 fiber,owner 释放和服务释放会取消存活工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮故障隔离的监听器通知,然后释放等待方)。
-任务属于其 owner 和后端,而不是生产方工具 fiber,因此重载生产方或表层不会停止任务。某个 owner 的第一个任务会把一个受等待的 effect 附加到精确的 `Agent` scope。owner 释放会取消该对象的任务,等待生产方完全停稳,并移除其快照;复用 agent 或 Session id 无法重定向旧清理。
-
-服务释放会关闭监听器、取消所有存活任务、等待其记录,并从仍存活的 owner scope 分离 effect。如果拆卸取消抛出异常,服务会强制把记录标为失败,并警告工作可能遗留,而不会死锁。取消已返回但始终不终止 `done` 时,系统无法将其与缓慢停止区分开,拆卸可能因此停滞。
-
-参见[任务类型目录](../../../docs/core-data-structures/tasks.md)和[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)。
+参见[任务类型目录](../../../docs/core-data-structures/tasks.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md)。
## 模型体验
@@ -36,8 +32,6 @@
## 已知限制与暂缓事项
-- **任务只存在于进程本地**:持久或跨重启执行需要独立生命周期。
-- **服务与实现没有拆分**:第二个后端必须先定义塑造该边界的生命周期。
- **流输出只有一个消费游标**:独立观察者需要游标或快照 API。
- **前台工作无法提升**:生产方在启动前选择前台或后台。
-- **静默无效的取消可能使拆卸停滞**:只有显式抛出异常才能安全地强制标为失败。
+- **契约是进程内的**:`TaskStart.run()` 传入回调和确切的 `Agent` 对象;持久或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。