From f32a51b2847eb0703b770c80c462d745afde5363 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 16:23:05 +0800 Subject: [PATCH 1/6] fix(tasks): bound per-session background work --- ...eneric-long-running-tool-runtime.i18n.yaml | 4 +- ...06-20-generic-long-running-tool-runtime.md | 8 +- ...20-generic-long-running-tool-runtime.zh.md | 8 +- .../2026-07-26-task-registry-seam.i18n.yaml | 4 +- .../2026-07-26-task-registry-seam.md | 6 +- .../2026-07-26-task-registry-seam.zh.md | 6 +- ...ounded-background-task-admission.i18n.yaml | 6 + ...08-11-bounded-background-task-admission.md | 53 +++++++++ ...11-bounded-background-task-admission.zh.md | 53 +++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 28 ++++- docs/config-catalog.zh.md | 28 ++++- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 11 +- docs/subsystems/tasks.zh.md | 11 +- ...kground-task-admission.cordis.snapshot.yml | 36 ++++++ .../background-task-admission.cordis.yml | 24 ++++ examples/acp-agent/tests/acp.snapshot.ts | 11 ++ .../background-task-admission/input.json | 10 ++ .../replay.override.json | 52 +++++++++ .../background-task-admission/session.jsonl | 58 ++++++++++ .../stdout.expected.jsonl | 4 + packages/examples/acp-demo/README.i18n.yaml | 4 +- packages/examples/acp-demo/README.md | 1 + packages/examples/acp-demo/README.zh.md | 1 + packages/examples/acp-demo/src/index.ts | 3 + .../examples/acp-demo/tests/acp-agent.spec.ts | 13 +++ .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 4 +- .../examples/agent-spine-demo/README.zh.md | 4 +- .../examples/agent-spine-demo/src/index.ts | 20 +++- .../agent-spine-demo/tests/agent-core.spec.ts | 13 +++ .../tool-cordis/src/api-catalog.ts | 2 +- packages/tasks/tasks-local/README.i18n.yaml | 4 +- packages/tasks/tasks-local/README.md | 8 +- packages/tasks/tasks-local/README.zh.md | 8 +- packages/tasks/tasks-local/package.json | 5 + packages/tasks/tasks-local/src/index.ts | 47 +++++++- .../tests/loader-composition.spec.ts | 52 +++++++++ .../tasks/tasks-local/tests/tasks.spec.ts | 105 +++++++++++++++++- packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 2 +- packages/tasks/tasks/README.zh.md | 2 +- packages/tasks/tasks/src/index.ts | 9 +- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 1 + pnpm-lock.yaml | 10 ++ 46 files changed, 684 insertions(+), 71 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md create mode 100644 examples/acp-agent/background-task-admission.cordis.snapshot.yml create mode 100644 examples/acp-agent/background-task-admission.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/background-task-admission/input.json create mode 100644 examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl create mode 100644 packages/tasks/tasks-local/tests/loader-composition.spec.ts 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 6da549618b..d4b69737f4 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 .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md -2026-06-20-generic-long-running-tool-runtime.md: 0dd49fe60f2c45081973657ff960b780d2d47257 -2026-06-20-generic-long-running-tool-runtime.zh.md: 6e8458aea73536859bf4c81894679d31a5034d3a +2026-06-20-generic-long-running-tool-runtime.md: a7327af87a7e24351923b07eac54e59afbaff614 +2026-06-20-generic-long-running-tool-runtime.zh.md: 86cc5910fda313ce527e76830e301fd4e26daa38 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 0dd49fe60f..a7327af87a 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 @@ -25,6 +25,8 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in The literal types live on the [tasks subsystem page](../../../../docs/subsystems/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, optional positive `outputLimitBytes`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. +The process-local provider also owns bounded admission. Its positive-safe-integer `maxConcurrentTasksPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count. + `outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control surfaces apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing surface behavior, so the runtime does not impose a hidden default on unrelated producer families. A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', taskId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the task runtime: later cancellation of the producing tool call must not kill the published task. `task_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`. @@ -55,7 +57,7 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow `TaskService` provides: -- `start(spec)` for preflighted, atomic registration. +- `start(spec)` for preflighted, provider-admitted, atomic registration. - `get(id, caller?)` and `list(caller?)` for non-consuming snapshots. - `read(id, caller?)` for a consuming stream delta or an idempotent final result. - `kill(id, caller?, reason?)` for cancellation. @@ -125,10 +127,12 @@ Authorization, not unguessability, is the access boundary, and ids do not derive ## Testing -Unit coverage pins preflight atomicity, per-kind ids, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance. +Unit coverage pins preflight atomicity, per-kind ids, per-exact-owner and unowned-bucket admission, `stopping` occupancy, terminal release, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas, prompt guidance, and an assembled ACP path where the configured limit rejects a second real background Bash task with a `task_kill` recovery action. ## Consequences Bash commands and subagents share one id vocabulary, listing, notice format, prompt habit, and set of control tools. New long-running producers implement execution hooks instead of another registry and tool family. The [tool cookbook](../../../../docs/cookbook/adding-a-tool.md) points producers to this contract. +One exact owner cannot grow process-local Task-backed work without bound, and another owner does not consume its allowance. A cancellation request keeps capacity occupied until the producer actually releases its resource, so replacing slow-stopping work cannot exceed the configured live-resource budget. + Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, completion notices do not wake idle agents, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs. 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 6e8458aea7..86cc5910fd 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 @@ -25,6 +25,8 @@ Status: implemented 字面类型见[任务子系统页面](../../../../docs/subsystems/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。 +进程内 Service provider 还拥有有界准入。它的 `maxConcurrentTasksPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service provider 不排队或抢占任务,也不保留第二份可变计数。 + `outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用控制接口添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有接口行为,因此运行时不会向无关的生产方类别施加隐式默认值。 面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', taskId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`task_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。 @@ -55,7 +57,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 `TaskService` 提供: -- `start(spec)`:经过预检的原子注册。 +- `start(spec)`:经过预检与 Service provider 准入的原子注册。 - `get(id, caller?)` 和 `list(caller?)`:非消费式快照。 - `read(id, caller?)`:消费式流增量或幂等的最终结果。 - `kill(id, caller?, reason?)`:取消。 @@ -125,10 +127,12 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas ## 测试 -单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。 +单元覆盖固定预检原子性、按 kind 分配的 id、按确切 owner 与无 owner 桶执行的准入、`stopping` 占位、终态释放、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema、提示词指导,以及一条组合完整的 ACP 路径:配置上限会拒绝第二个真实后台 Bash 任务,并给出 `task_kill` 恢复动作。 ## 后果 bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。 +单个确切 owner 无法再无限增加进程内由 Task 承载的工作,另一个 owner 也不会消耗它的额度。取消请求会继续占用容量,直到生产方真正释放资源,因此用新工作替换缓慢停止的任务不会突破已配置的实时资源预算。 + 有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方,完成通知不会唤醒空闲 agent;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。 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 5236b342a2..550e3e2469 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 .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md -2026-07-26-task-registry-seam.md: 4487bd9c53595fa8b4eed588b294ceafe3ab58dc -2026-07-26-task-registry-seam.zh.md: 6195dc809e84852c7e0f63ac101ba0ed6a46853e +2026-07-26-task-registry-seam.md: 5a0bd1cab7cdf85968ac53a3689011c8c8bd1b53 +2026-07-26-task-registry-seam.zh.md: 039ead1d577fdeaacf1a52fd8cf9611cc84d430e 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 4487bd9c53..5a0bd1cab7 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 @@ -12,8 +12,8 @@ The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) s `tasks/` is now a three-package capability family in the bash-trio shape: -- **`@deepseek-ai/dsh-tasks` (Service Definition)** — 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 Service provider 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 attached control surface serves the spec's owner (surfaces and listeners are scope-layered, so one process-wide registry answers both questions per owner). -- **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `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 Service Definition package has no provider dependencies. +- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the nine-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `onTasksChanged`, `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 Service provider 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 attached control surface serves the spec's owner (surfaces and listeners are scope-layered, so one process-wide registry answers both questions per owner). +- **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `LocalTaskService`, the process-local registry: the in-memory store, per-kind id counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, force-fail teardown, and the default-10 configurable admission policy. Admission derives `running` plus `stopping` capacity from the same records per exact owner, with one unowned bucket; it adds no public count or second state owner. The `dsh-timeout` dependency and Schemastery-owned provider config live here; the Service Definition package has no provider dependencies. - **`@deepseek-ai/dsh-tool-tasks` (Consumer)** — unchanged; it injects `'tasks'` and never imports provider 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` — the Service Definition package that declares the absent `ctx.tasks` service — and the Service Definition package's own surfaces (its README and the direct-mount fence) point at Service providers, 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. @@ -30,6 +30,6 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st ## Consequences -Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling Service provider implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The Service Definition README states the contract; the provider README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the Service Definition package keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. +Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling Service provider implementing nine abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The Service Definition README states the contract; the provider README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the Service Definition package 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 Service provider package. `abstract` erases at runtime and this package name used to be the mountable registry, so the Service Definition constructor fails loudly when mounted directly — a stale composition row gets "load a Service provider 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 6195dc809e..039ead1d57 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 @@ -12,8 +12,8 @@ Status: implemented `tasks/` 如今是一个 bash 三件套形态的三包能力家族: -- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的控制接口服务于 spec 的所有者时 `start` 拒绝启动工作(控制接口与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。 -- **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;Service Definition 包不含任何提供方依赖。 +- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、九个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`onTasksChanged`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的控制接口服务于 spec 的所有者时 `start` 拒绝启动工作(控制接口与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。 +- **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即进程内注册表:内存存储、按 kind 划分的 id 计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect、强制失败的拆除,以及默认值为 10 且可配置的准入策略。准入从同一组记录中按确切 owner 派生 `running` 加 `stopping` 容量,并为无 owner 任务使用一个共享桶;它不新增公开计数或第二个状态 owner。`dsh-timeout` 依赖与由 Schemastery 管理的 Service provider 配置都位于此包;Service Definition 包不含任何提供方依赖。 - **`@deepseek-ai/dsh-tool-tasks`(Consumer)**——保持不变;它注入 `'tasks'`,从不导入提供方类型。 各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即声明缺失的 `ctx.tasks` 服务的 Service Definition 包;Service Definition 包自身的对外呈现(其 README 与直接挂载防线)会指向各 Service provider,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的同级 Service provider,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。Service Definition 的 README 陈述约定;生命周期簿记方面的事实归 Service provider 的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;Service Definition 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 +换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现九个抽象方法的同级 Service provider,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。Service Definition 的 README 陈述约定;生命周期簿记方面的事实归 Service provider 的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;Service Definition 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名 Service provider 包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此直接挂载 Service Definition 时,其构造函数会明确报错——一条陈旧的组合配置行会在加载时得到「load a Service provider such as @deepseek-ai/dsh-tasks-local」,而不是一个未完整注册的 `ctx.tasks` 在远离错误配置处才失败。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml new file mode 100644 index 0000000000..89322f6c54 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.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 .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md +2026-08-11-bounded-background-task-admission.md: 67bf13320a872dc6d50488058beb4f50330fe83b +2026-08-11-bounded-background-task-admission.zh.md: f2f8be77fc2ade531e004cd026c27073242e7700 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md new file mode 100644 index 0000000000..67bf13320a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md @@ -0,0 +1,53 @@ +# Agent Note: Bounded background task admission + +Status: implemented + +English | [中文](2026-08-11-bounded-background-task-admission.zh.md) + +## Problem + +A model can start background Bash, PowerShell, PTY operations, and one-shot subagents in separate tool calls and later turns. The agent loop's `maxParallelToolCalls` limits only calls still executing inside one step; each background producer returns a task id immediately, so repeated starts can grow live processes or child work without bound. + +The process-local task registry already owns the exact task owner and the authoritative lifecycle state, but it retained terminal history beside live records and had no admission policy. Releasing capacity when cancellation was requested would also be incorrect: a `stopping` producer may still own its process, PTY, or child until `TaskHooks.done` settles. + +## Decision + +`LocalTaskService` owns a `maxConcurrentTasksPerOwner` configuration field. It accepts positive safe integers, defaults to `10`, and is available through the provider's Cordis schema, the typed `agent-spine-demo` bundle, and the ACP app configuration. The bundle transports the value; the process-local provider owns its meaning. + +`start()` performs admission after the existing control-surface, task-field, and live-owner checks and before `TaskStart.run()`. It derives the active count from the registry's current records instead of storing another counter: + +| Record | Occupies capacity | Release fact | +|---|---:|---| +| `running` | yes | producer `done` settles | +| `stopping` | yes | producer `done` settles | +| `completed`, `killed`, or `failed` | no | already terminal | + +Owned tasks are bucketed by exact `Agent` object identity, matching owner cleanup. Replacement agents that reuse a session id receive an independent bucket. Tasks without an owner share one service-level bucket, so omitting ownership is not an unlimited bypass. + +When the bucket is full, `start()` throws before producer execution and task-id allocation. The diagnostic includes the current limit and tells the model to use `task_kill`, wait until the task finishes stopping, and retry. Rejection creates no execution resource, queue entry, reservation, or public task record; a later successful start receives the next ordinary per-kind id. + +Owner and service disposal keep their existing order: request cancellation, retain `stopping` occupancy while producers release resources, await settlement, then remove records. The admission policy therefore follows the same lifecycle fact used by reads, notices, and cleanup rather than treating a cancellation request as resource release. + +Continuable background subagents remain outside this budget. They own durable child sessions and live Activations rather than Task records, so limiting them requires a separate result and lifecycle contract. This decision also adds no Task snapshot, session-log, wire, persistence, process-wide CPU or memory budget, queue, priority, preemption, or automatic oldest-task termination. + +## Verification + +The task-provider suite covers the default and explicit limits, producer-before rejection, unchanged id counters, `stopping` occupancy, every terminal release state, exact-owner isolation, same-session replacement objects, the shared unowned bucket, invalid configuration, owner cleanup, and service teardown. Spine and ACP composition tests pin typed forwarding. A keyless ACP replay boots the real Loader composition with a limit of one, starts one real background Bash process, observes the second start's actionable error, stops the first task by its returned id, and verifies that the rejected producer's marker file was never created. + +## Alternatives considered + +**Rely on `maxParallelToolCalls`.** Rejected because a background tool call releases its step slot as soon as it returns a task id; the setting cannot bound work that remains live across later steps and turns. + +**Release capacity when `task_kill` succeeds.** Rejected because successful cancellation only changes the task to `stopping`. The producer may still hold the resource until `done` settles, so admitting a replacement immediately would exceed the configured live-resource bound. + +**Use one global process bucket.** Rejected because one busy agent would deny unrelated sessions, while unowned host work still needs an explicit bounded bucket. Exact owner identity already defines the cleanup lifecycle and supplies the correct partition. + +**Queue, preempt, or terminate the oldest task.** Rejected because each policy adds ordering, ownership, and cancellation behavior beyond the requested fail-closed limit. An explicit rejection lets the model decide which work is no longer needed through the existing `task_kill` control. + +**Maintain a mutable active-count map.** Rejected because the registry already holds the authoritative records and statuses. A second count would require rollback and settlement synchronization while providing no user result that a direct derivation lacks. + +## Consequences + +One exact owner cannot keep creating Task-backed live resources indefinitely, and unrelated owners retain independent allowances. A slow stop can temporarily keep a bucket full, which is deliberate: the configured number bounds work that may still own resources, not cancellation requests. + +Admission scans the process-local registry on each start. The cost grows with retained Task history, accepted in exchange for one state authority and a default limit small enough to bound the common live set. Terminal history remains available to existing reads and listings without consuming capacity. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md new file mode 100644 index 0000000000..f2f8be77fc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 有界后台任务准入 + +Status: implemented + +[English](2026-08-11-bounded-background-task-admission.md) | 中文 + +## 问题 + +模型可以在不同工具调用和后续回合中启动后台 Bash、PowerShell、PTY 操作与一次性 subagent。agent loop 的 `maxParallelToolCalls` 只限制单个步骤中尚未返回的调用;每个后台生产方会立即返回 task id,因此反复启动会让仍存活的进程或子工作无限增长。 + +进程内任务注册表已经拥有确切任务 owner 与权威生命周期状态,但终止历史和实时记录保存在一起,且没有准入策略。在请求取消时立即释放容量也不正确:处于 `stopping` 的生产方仍可能拥有进程、PTY 或子任务,直到 `TaskHooks.done` 结算。 + +## 决策 + +`LocalTaskService` 拥有 `maxConcurrentTasksPerOwner` 配置字段。它只接受正的安全整数,默认值为 `10`,并通过 Service provider 的 Cordis schema、typed `agent-spine-demo` 组合包与 ACP 应用配置提供。组合包只传输该值;其含义归进程内 Service provider 所有。 + +`start()` 在现有控制表层、任务字段与存活 owner 检查之后、`TaskStart.run()` 之前执行准入。它从注册表当前记录派生活动数量,而不保存另一份计数: + +| 记录 | 占用容量 | 释放事实 | +|---|---:|---| +| `running` | 是 | 生产方 `done` 结算 | +| `stopping` | 是 | 生产方 `done` 结算 | +| `completed`、`killed` 或 `failed` | 否 | 已经终止 | + +有 owner 的任务按确切 `Agent` 对象身份分桶,与 owner 清理保持一致。复用同一会话 id 的替代 agent 获得独立桶。无 owner 的任务共享一个服务级桶,因此省略 owner 不会成为无界旁路。 + +桶已满时,`start()` 会在生产方执行和 task id 分配前抛出异常。诊断包含当前上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。拒绝不会创建执行资源、排队项、预留或公开任务记录;后续成功启动仍会取得按 kind 正常递增的下一个 id。 + +owner 与服务释放保留现有顺序:请求取消,在生产方释放资源期间继续让 `stopping` 占位,等待结算,然后移除记录。因此,准入策略遵循读取、通知与清理共同使用的同一生命周期事实,而不会把取消请求误当成资源释放。 + +可继续后台 subagent 仍不纳入此预算。它们拥有持久 child session 与实时 Activation,而不是 Task 记录;限制它们需要独立的用户结果与生命周期约定。本决策也不会新增 Task 快照、会话日志、wire、持久化、进程级 CPU 或内存预算、队列、优先级、抢占或自动终止最旧任务。 + +## 验证 + +任务 Service provider 测试覆盖默认与显式上限、生产方执行前拒绝、id 计数器不变、`stopping` 占位、每种终态释放、确切 owner 隔离、同会话替代对象、共享无 owner 桶、非法配置、owner 清理和服务拆除。spine 与 ACP 组合测试固定 typed 转发。一条 keyless ACP 回放以 1 为上限启动真实 Loader 组合,启动一个真实后台 Bash 进程,观察第二次启动返回可操作错误,按返回的 task id 停止第一个任务,并验证被拒绝生产方的标记文件从未生成。 + +## 曾考虑的替代方案 + +**依赖 `maxParallelToolCalls`。**否决,因为后台工具调用一返回 task id 就会释放其步骤槽位;该设置无法限制在后续步骤和回合中继续存活的工作。 + +**在 `task_kill` 成功时释放容量。**否决,因为取消成功只会把任务改为 `stopping`。生产方在 `done` 结算前仍可能持有资源,立即准入替代任务会突破已配置的实时资源上限。 + +**使用一个全局进程桶。**否决,因为一个繁忙 agent 会拒绝无关会话,而无 owner 的宿主工作仍需要一个明确的有界桶。确切 owner 身份已经定义清理生命周期,并提供正确分区。 + +**排队、抢占或终止最旧任务。**否决,因为每种策略都会增加超出 fail-closed 上限要求的顺序、所有权和取消行为。显式拒绝让模型通过现有 `task_kill` 控制自行决定哪些工作不再需要。 + +**维护一张可变活动计数表。**否决,因为注册表已经保存权威记录与状态。第二份计数需要回滚和结算同步,却无法提供直接派生所缺少的用户结果。 + +## 后果 + +单个确切 owner 无法再无限创建由 Task 承载的实时资源,无关 owner 则保留独立额度。缓慢停止可能暂时让桶保持满载,这是有意行为:配置值限制的是仍可能拥有资源的工作,而不是取消请求。 + +每次启动都会扫描进程内注册表。成本随保留的 Task 历史增长;为了保持单一状态权威,并利用足以约束常见实时集合的较小默认值,接受这一代价。终止历史仍可供现有读取与列表使用,但不消耗容量。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 75e5d33e09..1e515c7699 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 1e2416d479daaef0f9b6b03846406fab4c30f21a -config-catalog.zh.md: 5a9ecc5b30c55f70b2685fe9633430e68f30f6c8 +config-catalog.md: 75cc0f13e17de61cf618e45cc85dab50717bb686 +config-catalog.zh.md: a84967967def721ef7fc4834b31f688a6f70bba1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1e2416d479..75cc0f13e1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -69,6 +69,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -167,9 +169,10 @@ Source: [`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/age * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -206,6 +209,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -235,9 +240,9 @@ export interface GoalConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`TasksConfig`](#deepseek-aidsh-tasks-local) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:91`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-tool-mode` @@ -2023,6 +2028,18 @@ export interface Config { Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) +## `@deepseek-ai/dsh-tasks-local` + +```ts config-catalog +/** Configuration for the process-local task registry. */ +export interface Config { + /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + maxConcurrentTasksPerOwner?: number +} +``` + +Source: [`packages/tasks/tasks-local/src/index.ts:31`](../packages/tasks/tasks-local/src/index.ts) + ## `@deepseek-ai/dsh-time-context` Requires: `agents` @@ -2776,7 +2793,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@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-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/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/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5a9ecc5b30..a84967967d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -71,6 +71,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -169,9 +171,10 @@ export type PresetTrust = 'system' | 'user' * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -208,6 +211,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -237,9 +242,9 @@ export interface GoalConfig { } ``` -依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`TasksConfig`](#deepseek-aidsh-tasks-local) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:91`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-tool-mode` @@ -2025,6 +2030,18 @@ export interface Config { 来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) +## `@deepseek-ai/dsh-tasks-local` + +```ts config-catalog +/** Configuration for the process-local task registry. */ +export interface Config { + /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + maxConcurrentTasksPerOwner?: number +} +``` + +来源:[`packages/tasks/tasks-local/src/index.ts:31`](../packages/tasks/tasks-local/src/index.ts) + ## `@deepseek-ai/dsh-time-context` 需要:`agents` @@ -2777,7 +2794,6 @@ export interface Config { - `@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-subprocess-local`([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/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` — 需要 `tools`([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — 需要 `tools` · `userInteraction`([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — 需要 `tools` · `subagents`([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index 75b47a572b..74a38d9c4d 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/tasks.md -tasks.md: 6d205d9a7840aef1c115c97836886f51bed829b9 -tasks.zh.md: 59b7c03d240c45e633f55583b3e08776ec470d52 +tasks.md: 213f7f9c50297e214a699ca1ae34b388707e5958 +tasks.zh.md: 28f66e523c2f23c43949f41832a356624ea9b3d2 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index 6d205d9a78..213f7f9c50 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -151,7 +151,7 @@ interface TaskRead { ## Service behavior -The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` listeners, and when `attachSurface` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition 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 Consumer. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` listeners, and when `attachSurface` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentTasksPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned tasks; terminal producer settlement releases capacity. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. @@ -176,10 +176,11 @@ Implementations must honor these semantics: ```ts cordis-catalog /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 59b7c03d24..28f66e523c 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -151,7 +151,7 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理与准入会使用确切的已注册 `Agent` 实例。本地 Service provider 的 `maxConcurrentTasksPerOwner` 配置必须是正的安全整数,默认值为 `10`;它按确切 owner 统计 `running` 与 `stopping` 记录,所有无 owner 任务共享一个服务级桶,并在生产方终止结算后释放容量。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期与准入策略见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 @@ -176,10 +176,11 @@ Implementations must honor these semantics: ```ts cordis-catalog /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/examples/acp-agent/background-task-admission.cordis.snapshot.yml b/examples/acp-agent/background-task-admission.cordis.snapshot.yml new file mode 100644 index 0000000000..ec174f1f51 --- /dev/null +++ b/examples/acp-agent/background-task-admission.cordis.snapshot.yml @@ -0,0 +1,36 @@ +# Keyless counterpart to background-task-admission.cordis.yml: replace the +# DeepSeek adapter with replay while preserving the app's one-task admission +# config and the recorded flash route. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + tasks: + maxConcurrentTasksPerOwner: 1 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/background-task-admission.cordis.yml b/examples/acp-agent/background-task-admission.cordis.yml new file mode 100644 index 0000000000..374e8449ea --- /dev/null +++ b/examples/acp-agent/background-task-admission.cordis.yml @@ -0,0 +1,24 @@ +# Bounded-task admission overlay: keep the ordinary ACP composition while +# configuring its task provider to allow one active task per exact owner. The +# scenario starts a real background Bash process, observes the second producer +# rejection, and cleans up the first task by its returned id. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + tasks: + maxConcurrentTasksPerOwner: 1 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3ef3e71000..5765674b4f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -57,6 +57,9 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( + new URL('../background-task-admission.cordis.yml', import.meta.url), +) const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) @@ -215,6 +218,14 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + { + name: 'background-task-admission', + hasModelTurn: true, + recorded: false, + overridden: true, + configPath: BACKGROUND_TASK_ADMISSION_CONFIG, + posixOnly: true, + }, // The pwsh overlay (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the // bundle's bash tool for the PowerShell twin, so its header class pins its // own prompt/tool sidecars and a recorded transcript. diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/input.json b/examples/acp-agent/tests/snapshots/background-task-admission/input.json new file mode 100644 index 0000000000..38b1ab6dde --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/input.json @@ -0,0 +1,10 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "prompt", + "text": "Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json b/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json new file mode 100644 index 0000000000..45c6a4acda --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json @@ -0,0 +1,52 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-first", "name": "bash", "argumentsDelta": "{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-first", "name": "bash", "arguments": "{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-second", "name": "bash", "argumentsDelta": "{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-second", "name": "bash", "arguments": "{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-kill", "name": "task_kill", "argumentsDelta": "{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-kill", "name": "task_kill", "arguments": "{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-side-effect-check", "name": "bash", "argumentsDelta": "{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-side-effect-check", "name": "bash", "arguments": "{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "BOUNDED_BACKGROUND_TASKS" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "BOUNDED_BACKGROUND_TASKS" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl new file mode 100644 index 0000000000..0a303e1866 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl @@ -0,0 +1,58 @@ +{"type":"session","version":0,"id":"77777777-7777-4777-8777-777777777777","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786434813544,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop."}],"source":{"kind":"user"},"role":"user","id":"fca9abcd-66a9-4c79-ab34-7e25e65e01af"}]}} +{"type":"turn/start","seq":1,"time":1786434813545,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786434813546,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786434813574,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786434813574,"data":{"content":[{"type":"text","text":"Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop."}],"source":{"kind":"user"},"role":"user","id":"fca9abcd-66a9-4c79-ab34-7e25e65e01af"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786434813575,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f7801581-b729-4cbc-b205-1eabd5b96de7"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786434813575,"data":{"title":"Start one background Bash task","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786434813576,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786434813576,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-first","name":"bash","argumentsDelta":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":11,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":12,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786434813582,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f25e0e7c-76a4-45a6-a825-64d1bd42fe59"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786434813582,"data":{"turn":1,"step":1,"callId":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":16,"time":1786434813594,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bounded-task-first"},"content":[{"type":"tool-result","toolCallId":"bounded-task-first","content":[{"type":"text","text":"started background task bash-1"}],"isError":false}],"role":"user","id":"0e19086f-2a9a-4e78-b5eb-5a117cad9416"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1786434813594,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1786434813600,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-second","name":"bash","argumentsDelta":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":21,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":22,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1786434813605,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"48c909b3-5651-462f-b0d3-09198d119a2f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1786434813606,"data":{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":26,"time":1786434813609,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bounded-task-second"},"content":[{"type":"tool-result","toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background task limit reached for this owner (1/1 active); use task_kill to stop an unneeded task, wait for it to finish, then retry"}],"isError":true}],"role":"user","id":"982adb5c-13ef-44c5-a53e-b726c986ac68"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1786434813609,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":28,"time":1786434813614,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":29,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-kill","name":"task_kill","argumentsDelta":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}}} +{"type":"assistant/chunk","seq":31,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}}}} +{"type":"assistant/chunk","seq":32,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":33,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":1786434813618,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6dc2d854-59f7-4c70-8a0f-64416b324055"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":1786434813618,"data":{"turn":1,"step":3,"callId":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}} +{"type":"tool/result","seq":36,"time":1786434813623,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"bounded-task-kill"},"content":[{"type":"tool-result","toolCallId":"bounded-task-kill","content":[{"type":"text","text":"requested cancellation of task bash-1"}],"isError":false}],"role":"user","id":"5abf87b2-3e10-448f-a529-dfc40dce2f08"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"step/end","seq":37,"time":1786434813623,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":38,"time":1786434813628,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":39,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":40,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-side-effect-check","name":"bash","argumentsDelta":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}}} +{"type":"assistant/chunk","seq":41,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}}}} +{"type":"assistant/chunk","seq":42,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":43,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":44,"time":1786434813632,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"85ebd1ec-c3b2-4bd2-87cb-135089efc440"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"tool/call","seq":45,"time":1786436340879,"data":{"turn":1,"step":4,"callId":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}} +{"type":"tool/result","seq":46,"time":1786436340886,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"bounded-task-side-effect-check"},"content":[{"type":"tool-result","toolCallId":"bounded-task-side-effect-check","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"49f76b57-92a2-4ae2-9711-11ad7cbd4e4c"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":1786436340886,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":48,"time":1786436340891,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":49,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":50,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"BOUNDED_BACKGROUND_TASKS"}}} +{"type":"assistant/chunk","seq":51,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}}}} +{"type":"assistant/chunk","seq":52,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":53,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":54,"time":1786436340897,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de775b06-2bb8-4bc0-8716-4fc31b9685c6"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"step/end","seq":55,"time":1786436340898,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":56,"time":1786436340898,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl new file mode 100644 index 0000000000..7f71f1b79b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index af16e2eacd..4d32289fc7 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-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 packages/examples/acp-demo/README.md -README.md: edc45c9857a631cef72eb41b1a98c390f112291e -README.zh.md: c2946aa3d1feaed558408cf0921e2480c031187d +README.md: c1a15a424d9d66b90bbec451e220198bfe0a45df +README.zh.md: 16a8d782c8fa38fe9e2ae1e634dbc406be1cbd12 diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index edc45c9857..c1a15a424d 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -34,6 +34,7 @@ The app does not install commands, user interaction, session navigation, configu | `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. | | `skills` | owner defaults | Skill registry, local provider, and model-facing skill tool. | | `toolBash` | owner defaults | Model-facing bash tool config. | +| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | Process-local per-owner active-task admission. | | `toolTasks` | owner defaults | Generic background-task control config, or `false`. | | `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. | diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index c2946aa3d1..16a8d782c8 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -34,6 +34,7 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 | `workspaceContext` | 必填 | 工作区指令字节预算/配置,或 `false`。 | | `skills` | 拥有者默认值 | skill 注册表、本地提供方和面向模型的 skill 工具。 | | `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置。 | +| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | 进程内按 owner 限制活动任务的准入配置。 | | `toolTasks` | 拥有者默认值 | 通用后台任务控制配置,或 `false`。 | | `goals` | 拥有者默认值 | 持久化的同会话目标领域与模型工具,或 `false`。 | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 1bd0c44459..bd1ecca437 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -65,6 +65,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -92,6 +94,7 @@ export const Config: z = z.object({ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, + tasks: agentCore.TasksConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), }) diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index af467660da..65479f0489 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -182,6 +182,19 @@ describe('dsh-acp-demo composition', () => { await ctx.fiber.dispose() }) + it('forwards task admission config to the bundled task provider', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + tasks: { maxConcurrentTasksPerOwner: 2 }, + skills: await isolatedSkillsConfig(), + workspaceContext: false, + }) + expect((ctx.tasks as unknown as { config: { maxConcurrentTasksPerOwner: number } }) + .config.maxConcurrentTasksPerOwner).toBe(2) + await ctx.fiber.dispose() + }) + it('forwards bundled tool config into agent-core', async () => { const ctx = await mount({ provider: 'mock', diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 87c1dc1562..9aa87434a4 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 packages/examples/agent-spine-demo/README.md -README.md: 5957d9a8e9218e18d5d7d0f620b6be811f2c230f -README.zh.md: 78372240764ff3c779ea0805aedd26a00baf1768 +README.md: 789715e53038f610d1e2db79cf56f9aabd681fac +README.zh.md: 6561dd8948d9a4763123f9aae31e6dde64c54b27 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 5957d9a8e9..789715e530 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,11 +55,11 @@ This applies the [Service Definition / Service provider / Consumer separation](. ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `tasks.maxConcurrentTasksPerOwner` configures the local provider independently of the model-facing `toolTasks` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 7837224076..6561dd8948 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`tasks.maxConcurrentTasksPerOwner` 配置本地 Service provider,并与面向模型的 `toolTasks` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 383fc8c212..2b7644e3cc 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 LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import LocalTaskService, { type Config as TasksConfig } 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' @@ -75,9 +75,10 @@ export interface GoalConfig { * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -114,6 +115,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -138,6 +141,9 @@ export const SessionTitleConfigSchema: z = SessionTitleServi export const ToolBashConfigSchema: z = z.union([z.const(false), toolBash.Config]) +/** The process-local task registry schema exported for app packages that forward `tasks`. */ +export const TasksConfigSchema: z = LocalTaskService.Config + /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config @@ -158,10 +164,11 @@ export const Config = z.intersect([ skills: SkillConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, + tasks: TasksConfigSchema, toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -181,6 +188,7 @@ export function pickSpineConfig(config: Omit): Omit { expect(ctx.get('skills')).toBeDefined() expect(ctx.get('agents')).toBeDefined() expect(ctx.get('tasks')).toBeDefined() + expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(10) expect(ctx.get('invariants')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('goals')).toBeUndefined() @@ -300,6 +302,15 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('forwards task admission config to the process-local provider', async () => { + const ctx = await mount({ + tasks: { maxConcurrentTasksPerOwner: 3 }, + workspaceContext: false, + }) + expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(3) + await ctx.fiber.dispose() + }) + it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. @@ -716,6 +727,7 @@ describe('dsh-agent-spine-demo bundle', () => { workspaceContext: false as const, skills: { enabled: false }, toolBash: { enableRunInBackground: false }, + tasks: { maxConcurrentTasksPerOwner: 4 }, toolTasks: false as const, invariants: { enabled: false }, } @@ -730,6 +742,7 @@ describe('dsh-agent-spine-demo bundle', () => { workspaceContext: false, skills: appConfig.skills, toolBash: appConfig.toolBash, + tasks: appConfig.tasks, toolTasks: appConfig.toolTasks, invariants: appConfig.invariants, }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 1c310ab8e0..79585369c0 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1150,7 +1150,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { 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 */', + jsDoc: '/**\n * Preflight access, validation, owner cleanup, and implementation-owned\n * admission before starting and atomically registering work. Any preflight\n * rejection leaves no task id or execution resource. A throwing starter\n * leaves nothing registered; after it returns, registration cannot fail.\n * Settlement records the outcome, notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `-N` id.\n */', }, { signature: 'abstract list(caller?: Agent): TaskSnapshot[]', diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml index 3a43cba8f8..7378b1cd05 100644 --- a/packages/tasks/tasks-local/README.i18n.yaml +++ b/packages/tasks/tasks-local/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 packages/tasks/tasks-local/README.md -README.md: 80d7932466188955ade1c14e4968d51b739ba818 -README.zh.md: 5e4263e4685be64f91ec2a7c74edbf89e1148866 +README.md: dcfaf397ccfb61be72c2846cb3f678469645a2c7 +README.zh.md: 60ddb2901a51974cfeaccd8c820bc084b9ca0949 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index 80d7932466..dcfaf397cc 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -2,7 +2,13 @@ English | [中文](README.zh.md) -Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `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`. +Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `-N` ids, and hands out fresh snapshots, never live state. Load it as a plugin and it registers as `ctx.tasks`. + +## Admission + +`maxConcurrentTasksPerOwner` is a positive safe integer and defaults to `10`. Before invoking a producer, `start()` counts the exact owner's `running` and `stopping` records; all unowned tasks share one separate service bucket. Terminal history does not occupy capacity, and only producer `done` settlement releases a stopping task's place. + +At capacity, `start()` fails before producer execution and id allocation with an error that names the limit and tells the model to use `task_kill`, wait for the task to finish stopping, and retry. The registry does not queue, preempt, or maintain a second mutable counter. ## Lifecycle diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md index 5e4263e468..60ddb2901a 100644 --- a/packages/tasks/tasks-local/README.zh.md +++ b/packages/tasks/tasks-local/README.zh.md @@ -2,7 +2,13 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。它没有配置;作为插件加载后即注册为 `ctx.tasks`。 +[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.tasks`。 + +## 准入 + +`maxConcurrentTasksPerOwner` 必须是正的安全整数,默认值为 `10`。调用生产方之前,`start()` 会统计确切 owner 的 `running` 与 `stopping` 记录;所有无 owner 任务共享另一个独立的服务级桶。终止历史不占用容量,处于 `stopping` 的任务只有在生产方 `done` 结算后才释放名额。 + +达到容量时,`start()` 会在生产方执行和 id 分配前失败;错误会给出上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。注册表不会排队或抢占任务,也不会维护第二份可变计数。 ## 生命周期 diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index cd40629948..b61ece6cb7 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -39,7 +39,12 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 6366024f74..81fc5bd363 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -10,6 +10,7 @@ */ import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeLayer } from '@deepseek-ai/dsh-scope' @@ -23,6 +24,18 @@ import type { /** Timeout code that distinguishes a bounded wait from caller cancellation. */ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' +/** Default maximum number of active tasks in one exact-owner bucket. */ +const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10 + +/** Configuration for the process-local task registry. */ +export interface Config { + /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + maxConcurrentTasksPerOwner?: number +} + +/** Configuration after defaults and load-time validation. */ +type ResolvedConfig = Required + /** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */ interface TrackedTask { id: TaskId @@ -76,6 +89,16 @@ class TaskLayer implements ScopeLayer { * semantics this implementation honors. */ export class LocalTaskService extends TaskService { + static Config: z = z.object({ + maxConcurrentTasksPerOwner: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER), + }) + + /** Validated registry configuration. */ + readonly config: ResolvedConfig private store = new Map() private counters = new Map() /** @@ -97,8 +120,14 @@ export class LocalTaskService extends TaskService { /** Service context used by detached settlement continuations and teardown. */ private readonly selfCtx: Context - constructor(ctx: Context) { + constructor(ctx: Context, config: Config = {}) { super(ctx) + const maxConcurrentTasksPerOwner = config.maxConcurrentTasksPerOwner + ?? DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER + if (!Number.isSafeInteger(maxConcurrentTasksPerOwner) || maxConcurrentTasksPerOwner <= 0) { + throw new TypeError('tasks-local: maxConcurrentTasksPerOwner must be a positive safe integer') + } + this.config = { maxConcurrentTasksPerOwner } this.selfCtx = ctx ctx.effect(() => () => this.disposeAll(), 'tasks teardown') } @@ -115,6 +144,13 @@ export class LocalTaskService extends TaskService { } if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + const active = this.activeTaskCount(spec.owner) + if (active >= this.config.maxConcurrentTasksPerOwner) { + throw new Error( + `background task limit reached for this owner (${active}/${this.config.maxConcurrentTasksPerOwner} active); use task_kill to stop an unneeded task, wait for it to finish, then retry`, + ) + } + const hooks = spec.run() const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) @@ -285,6 +321,15 @@ export class LocalTaskService extends TaskService { .some(layer => !layer.surfaces.isEmpty()) } + /** Count authoritative active records for one exact owner or the shared unowned bucket. */ + private activeTaskCount(owner: Agent | undefined): number { + let count = 0 + for (const task of this.store.values()) { + if (task.owner === owner && (task.status === 'running' || task.status === 'stopping')) count += 1 + } + return count + } + /** * The completion listeners that own `owner`'s notices: the global layer's * first, then each scoped layer along the owner's chain. A listener outside diff --git a/packages/tasks/tasks-local/tests/loader-composition.spec.ts b/packages/tasks/tasks-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..0cd0cac575 --- /dev/null +++ b/packages/tasks/tasks-local/tests/loader-composition.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('tasks-local through a real Loader composition', () => { + it('applies the provider-owned admission config from a Cordis row', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-tasks-local-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-tasks-local'", + ' config:', + ' maxConcurrentTasksPerOwner: 2', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-tasks-local') return LocalTaskService + throw new Error(`unexpected Loader import: ${specifier}`) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect(context.tasks).toBeInstanceOf(LocalTaskService) + expect((context.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(2) + }) +}) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index b4f0ced798..cd96ecdda4 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -7,7 +7,7 @@ import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' 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' +import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -76,10 +76,10 @@ function producer(overrides: Partial & TaskHooks> = {}) { return { spec, settle, reject, cancels } } -async function harness() { +async function harness(config: TasksConfig = {}) { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalTaskService) + await ctx.plugin(LocalTaskService, config) ctx.tasks.attachSurface('test-surface') return ctx } @@ -166,6 +166,105 @@ describe('LocalTaskService.start', () => { expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes') }) + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid maxConcurrentTasksPerOwner config: %s', + async (maxConcurrentTasksPerOwner) => { + const ctx = new Context() + await expect(ctx.plugin(LocalTaskService, { maxConcurrentTasksPerOwner })) + .rejects.toThrow() + expect(() => new LocalTaskService(new Context(), { maxConcurrentTasksPerOwner })) + .toThrow('maxConcurrentTasksPerOwner must be a positive safe integer') + }, + ) + + it('accepts the largest safe integer limit', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: Number.MAX_SAFE_INTEGER }) + expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner) + .toBe(Number.MAX_SAFE_INTEGER) + }) + + it('defaults each owner bucket to ten active tasks', async () => { + const ctx = await harness() + expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(10) + const live = Array.from({ length: 10 }, () => producer()) + for (const task of live) ctx.tasks.start(task.spec) + + const blocked = producer() + const run = vi.fn(() => blocked.spec.run()) + expect(() => ctx.tasks.start({ ...blocked.spec, run })) + .toThrow('background task limit reached for this owner (10/10 active)') + expect(run).not.toHaveBeenCalled() + for (const task of live) task.settle({ status: 'completed' }) + }) + + it('rejects before producer start and id allocation, then admits immediately after settlement', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + expect(ctx.tasks.start(first.spec)).toBe('bash-1') + + const blocked = producer() + const run = vi.fn(() => blocked.spec.run()) + expect(() => ctx.tasks.start({ ...blocked.spec, run })) + .toThrow('use task_kill to stop an unneeded task, wait for it to finish, then retry') + expect(run).not.toHaveBeenCalled() + + first.settle({ status: 'completed' }) + await tick() + expect(ctx.tasks.start(blocked.spec)).toBe('bash-2') + }) + + it('keeps a stopping task in the bucket until producer settlement', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + const id = ctx.tasks.start(first.spec) + expect(ctx.tasks.kill(id)).toBe('requested') + + const replacement = producer() + expect(() => ctx.tasks.start(replacement.spec)).toThrow('(1/1 active)') + + first.settle({ status: 'killed' }) + await tick() + expect(ctx.tasks.start(replacement.spec)).toBe('bash-2') + }) + + it.each(['completed', 'killed', 'failed'] as const)( + 'releases the bucket after a %s terminal outcome', + async (status) => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + ctx.tasks.start(first.spec) + first.settle({ status }) + await tick() + expect(() => ctx.tasks.start(producer().spec)).not.toThrow() + }, + ) + + it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const oldOwner = stubAgent(ctx, 'shared-session') + const detachOld = ctx.agents.register(oldOwner) + const oldTask = producer({ owner: oldOwner }) + ctx.tasks.start(oldTask.spec) + + const otherOwner = stubAgent(ctx, 'other-session') + ctx.agents.register(otherOwner) + expect(() => ctx.tasks.start(producer({ owner: otherOwner }).spec)).not.toThrow() + + detachOld() + const replacement = stubAgent(ctx, 'shared-session') + ctx.agents.register(replacement) + expect(() => ctx.tasks.start(producer({ owner: replacement }).spec)).not.toThrow() + + ctx.tasks.start(producer().spec) + expect(() => ctx.tasks.start(producer().spec)).toThrow('(1/1 active)') + expect(() => ctx.tasks.start(producer({ owner: oldOwner }).spec)) + .toThrow('is not the registered agent instance') + + oldTask.settle({ status: 'completed' }) + await tick() + await disposeAgentScope(oldOwner) + }) + it('issues kind-prefixed ids from per-kind counters', async () => { const ctx = await harness() expect(ctx.tasks.start(producer().spec)).toBe('bash-1') diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 7c7ceba672..d7dd146996 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 packages/tasks/tasks/README.md -README.md: 053e407d2e28cb175ebe9de15c7e16ef04e95cb8 -README.zh.md: dd39b1098cd01d7db9db6e49210c2b1149b37ecb +README.md: 17579e417690dd34641e59f66cc8196465d08d68 +README.zh.md: 8f6ed21bb8ebcdcda4e0e3799aba5b88e5179393 diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 053e407d2e..17579e4176 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -6,7 +6,7 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService` ## 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. +- `start(spec): TaskId` validates the control surface, spec, exact live owner, optional positive `outputLimitBytes`, and any provider-owned admission policy before calling the producer's `run()` once. A preflight rejection or starter throw leaves no task id or registered work; 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. - `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks. - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index dd39b1098c..8f6ed21bb8 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -6,7 +6,7 @@ ## 服务约定 -- `start(spec): TaskId` 验证控制表层、spec、确切且仍存活的 owner,以及可选的 `outputLimitBytes`(如提供则须为正数),然后只调用生产方的 `run()` 一次。启动方抛出异常时不注册任何内容;成功返回会直接提交,不再执行其他可能失败的步骤。 +- `start(spec): TaskId` 验证控制表层、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 task id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。 - `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。 - `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。 - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 06bb2e38fb..a8df67558a 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -67,10 +67,11 @@ export abstract class TaskService extends Service { } /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 3221c20246..cf3b66e67e 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -572,6 +572,7 @@ describe('completion notices', () => { const prior = producer({ kind: 'pty-send' }) ctx.tasks.start(prior.spec) prior.settle({ status: 'completed' }) + await tick() } const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', inject) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5612f5bc13..a209a4ee6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7129,10 +7129,20 @@ importers: version: link:../../core/session packages/tasks/tasks-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent From 9d173f8333da6013f6db1862c9093251eeb41b71 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 16:57:04 +0800 Subject: [PATCH 2/6] test(tasks): cover direct admission default --- packages/tasks/tasks-local/tests/tasks.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 73c502cd46..12cf6f917c 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -197,6 +197,10 @@ describe('LocalTaskService.start', () => { for (const task of live) task.settle({ status: 'completed' }) }) + it('defaults direct construction when the config schema is bypassed', () => { + expect(new LocalTaskService(new Context()).config.maxConcurrentTasksPerOwner).toBe(10) + }) + it('rejects before producer start and id allocation, then admits immediately after settlement', async () => { const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) const first = producer() From c98a4d09493350b546f8449295c15f5938aecda2 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 17:12:53 +0800 Subject: [PATCH 3/6] refactor(tasks): simplify admission configuration --- .../background-task-admission/session.jsonl | 2 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 18 +++++++++++++--- .../agent-spine-demo/tests/agent-core.spec.ts | 19 +++++++++++++---- packages/tasks/tasks-local/src/index.ts | 21 +++++++------------ .../tests/loader-composition.spec.ts | 18 ++++++++++++++-- .../tasks/tasks-local/tests/tasks.spec.ts | 16 ++++---------- 6 files changed, 58 insertions(+), 36 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl index 0a303e1866..d9d40b2539 100644 --- a/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl +++ b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl @@ -25,7 +25,7 @@ {"type":"assistant/chunk","seq":23,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":24,"time":1786434813605,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"48c909b3-5651-462f-b0d3-09198d119a2f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1786434813606,"data":{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}} -{"type":"tool/result","seq":26,"time":1786434813609,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bounded-task-second"},"content":[{"type":"tool-result","toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background task limit reached for this owner (1/1 active); use task_kill to stop an unneeded task, wait for it to finish, then retry"}],"isError":true}],"role":"user","id":"982adb5c-13ef-44c5-a53e-b726c986ac68"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","seq":26,"time":1786434813609,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bounded-task-second"},"content":[{"type":"tool-result","toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background task limit reached for this owner (limit: 1); use task_kill to stop an unneeded task, wait for it to finish, then retry"}],"isError":true}],"role":"user","id":"c0386bdf-df3c-4d2b-af8e-04ee28682214"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":27,"time":1786434813609,"data":{"turn":1,"step":2}} {"type":"step/start","seq":28,"time":1786434813614,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":29,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 65479f0489..3a64404704 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -186,12 +186,24 @@ describe('dsh-acp-demo composition', () => { const ctx = await mount({ provider: 'mock', model: 'mock', - tasks: { maxConcurrentTasksPerOwner: 2 }, + tasks: { maxConcurrentTasksPerOwner: 1 }, skills: await isolatedSkillsConfig(), workspaceContext: false, }) - expect((ctx.tasks as unknown as { config: { maxConcurrentTasksPerOwner: number } }) - .config.maxConcurrentTasksPerOwner).toBe(2) + let settle!: (outcome: { status: 'killed' }) => void + ctx.tasks.start({ + kind: 'bash', + label: 'hold configured slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => ctx.tasks.start({ + kind: 'bash', + label: 'blocked configured task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') await ctx.fiber.dispose() }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index d3b02ae582..9f738e3b56 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -10,7 +10,6 @@ import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { @@ -158,7 +157,6 @@ describe('dsh-agent-spine-demo bundle', () => { expect(ctx.get('skills')).toBeDefined() expect(ctx.get('agents')).toBeDefined() expect(ctx.get('tasks')).toBeDefined() - expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(10) expect(ctx.get('invariants')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('goals')).toBeUndefined() @@ -304,10 +302,23 @@ describe('dsh-agent-spine-demo bundle', () => { it('forwards task admission config to the process-local provider', async () => { const ctx = await mount({ - tasks: { maxConcurrentTasksPerOwner: 3 }, + tasks: { maxConcurrentTasksPerOwner: 1 }, workspaceContext: false, }) - expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(3) + let settle!: (outcome: { status: 'killed' }) => void + ctx.tasks.start({ + kind: 'probe', + label: 'hold configured slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => ctx.tasks.start({ + kind: 'probe', + label: 'blocked configured task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') await ctx.fiber.dispose() }) diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index c4f90808a6..cf60c416fc 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -33,9 +33,6 @@ export interface Config { maxConcurrentTasksPerOwner?: number } -/** Configuration after defaults and load-time validation. */ -type ResolvedConfig = Required - /** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */ interface TrackedTask { id: TaskId @@ -97,8 +94,8 @@ export class LocalTaskService extends TaskService { .default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER), }) - /** Validated registry configuration. */ - readonly config: ResolvedConfig + /** Schemastery-defaulted active-task limit. */ + private readonly maxConcurrentTasksPerOwner: number private store = new Map() private counters = new Map() /** @@ -120,14 +117,10 @@ export class LocalTaskService extends TaskService { /** Service context used by detached settlement continuations and teardown. */ private readonly selfCtx: Context - constructor(ctx: Context, config: Config = {}) { + constructor(ctx: Context, config: Config) { super(ctx) - const maxConcurrentTasksPerOwner = config.maxConcurrentTasksPerOwner - ?? DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER - if (!Number.isSafeInteger(maxConcurrentTasksPerOwner) || maxConcurrentTasksPerOwner <= 0) { - throw new TypeError('tasks-local: maxConcurrentTasksPerOwner must be a positive safe integer') - } - this.config = { maxConcurrentTasksPerOwner } + // Schemastery validates and fills the default before constructing the service. + this.maxConcurrentTasksPerOwner = (config as Required).maxConcurrentTasksPerOwner this.selfCtx = ctx ctx.effect(() => () => this.disposeAll(), 'tasks teardown') } @@ -145,9 +138,9 @@ export class LocalTaskService extends TaskService { if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) const active = this.activeTaskCount(spec.owner) - if (active >= this.config.maxConcurrentTasksPerOwner) { + if (active >= this.maxConcurrentTasksPerOwner) { throw new Error( - `background task limit reached for this owner (${active}/${this.config.maxConcurrentTasksPerOwner} active); use task_kill to stop an unneeded task, wait for it to finish, then retry`, + `background task limit reached for this owner (limit: ${this.maxConcurrentTasksPerOwner}); use task_kill to stop an unneeded task, wait for it to finish, then retry`, ) } diff --git a/packages/tasks/tasks-local/tests/loader-composition.spec.ts b/packages/tasks/tasks-local/tests/loader-composition.spec.ts index 0cd0cac575..9d4b09b6ff 100644 --- a/packages/tasks/tasks-local/tests/loader-composition.spec.ts +++ b/packages/tasks/tasks-local/tests/loader-composition.spec.ts @@ -25,7 +25,7 @@ describe('tasks-local through a real Loader composition', () => { await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-tasks-local'", ' config:', - ' maxConcurrentTasksPerOwner: 2', + ' maxConcurrentTasksPerOwner: 1', '', ].join('\n')) @@ -47,6 +47,20 @@ describe('tasks-local through a real Loader composition', () => { await context.loader.await() expect(context.tasks).toBeInstanceOf(LocalTaskService) - expect((context.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(2) + context.tasks.attachController('loader-test') + let settle!: (outcome: { status: 'killed' }) => void + context.tasks.start({ + kind: 'bash', + label: 'hold loader slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => context!.tasks.start({ + kind: 'bash', + label: 'blocked loader task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') }) }) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 12cf6f917c..239fca4fb3 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -172,35 +172,27 @@ describe('LocalTaskService.start', () => { const ctx = new Context() await expect(ctx.plugin(LocalTaskService, { maxConcurrentTasksPerOwner })) .rejects.toThrow() - expect(() => new LocalTaskService(new Context(), { maxConcurrentTasksPerOwner })) - .toThrow('maxConcurrentTasksPerOwner must be a positive safe integer') }, ) it('accepts the largest safe integer limit', async () => { const ctx = await harness({ maxConcurrentTasksPerOwner: Number.MAX_SAFE_INTEGER }) - expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner) - .toBe(Number.MAX_SAFE_INTEGER) + expect(ctx.tasks).toBeInstanceOf(LocalTaskService) }) it('defaults each owner bucket to ten active tasks', async () => { const ctx = await harness() - expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(10) const live = Array.from({ length: 10 }, () => producer()) for (const task of live) ctx.tasks.start(task.spec) const blocked = producer() const run = vi.fn(() => blocked.spec.run()) expect(() => ctx.tasks.start({ ...blocked.spec, run })) - .toThrow('background task limit reached for this owner (10/10 active)') + .toThrow('background task limit reached for this owner (limit: 10)') expect(run).not.toHaveBeenCalled() for (const task of live) task.settle({ status: 'completed' }) }) - it('defaults direct construction when the config schema is bypassed', () => { - expect(new LocalTaskService(new Context()).config.maxConcurrentTasksPerOwner).toBe(10) - }) - it('rejects before producer start and id allocation, then admits immediately after settlement', async () => { const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) const first = producer() @@ -224,7 +216,7 @@ describe('LocalTaskService.start', () => { expect(ctx.tasks.kill(id)).toBe('requested') const replacement = producer() - expect(() => ctx.tasks.start(replacement.spec)).toThrow('(1/1 active)') + expect(() => ctx.tasks.start(replacement.spec)).toThrow('(limit: 1)') first.settle({ status: 'killed' }) await tick() @@ -260,7 +252,7 @@ describe('LocalTaskService.start', () => { expect(() => ctx.tasks.start(producer({ owner: replacement }).spec)).not.toThrow() ctx.tasks.start(producer().spec) - expect(() => ctx.tasks.start(producer().spec)).toThrow('(1/1 active)') + expect(() => ctx.tasks.start(producer().spec)).toThrow('(limit: 1)') expect(() => ctx.tasks.start(producer({ owner: oldOwner }).spec)) .toThrow('is not the registered agent instance') From 938f08752dcf84933af46e4955e9257afd0d9742 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 17:54:57 +0800 Subject: [PATCH 4/6] fix(tasks): reference Schemastery compiler project --- packages/tasks/tasks-local/tsconfig.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/tasks/tasks-local/tsconfig.json b/packages/tasks/tasks-local/tsconfig.json index 4e9a3e20bf..ef21585d7c 100644 --- a/packages/tasks/tasks-local/tsconfig.json +++ b/packages/tasks/tasks-local/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../core/agent" }, From 38922cbbb92646a88bb6141f3e5d72c6673db41a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 23:13:35 +0800 Subject: [PATCH 5/6] docs(tasks): clarify admission lifecycle --- .../2026-06-20-generic-long-running-tool-runtime.i18n.yaml | 4 ++-- .../2026-06-20-generic-long-running-tool-runtime.md | 2 +- .../2026-06-20-generic-long-running-tool-runtime.zh.md | 2 +- .../2026-08-11-bounded-background-task-admission.i18n.yaml | 4 ++-- .../bug-fix/2026-08-11-bounded-background-task-admission.md | 4 +++- .../2026-08-11-bounded-background-task-admission.zh.md | 4 +++- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 5 ++++- docs/config-catalog.zh.md | 5 ++++- packages/tasks/tasks-local/README.i18n.yaml | 4 ++-- packages/tasks/tasks-local/README.md | 2 +- packages/tasks/tasks-local/README.zh.md | 2 +- packages/tasks/tasks-local/src/index.ts | 5 ++++- packages/tasks/tasks-local/src/invariant.ts | 6 ++++-- 14 files changed, 34 insertions(+), 19 deletions(-) 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 e2a4a7d7b2..3964e59f77 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 .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md -2026-06-20-generic-long-running-tool-runtime.md: 12e9b794e7e31c0dcbb45fac2f1f3d6852573b5a -2026-06-20-generic-long-running-tool-runtime.zh.md: 2d85b9ba824f9f1620a31c181e5d9a573c52875f +2026-06-20-generic-long-running-tool-runtime.md: 1edc3422c253e06178a5c8ebf68dfd4ef1289e31 +2026-06-20-generic-long-running-tool-runtime.zh.md: 8cfc2b2ec7ecce1a358b0be63ca52e9319bf362a 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 12e9b794e7..1edc3422c2 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 @@ -25,7 +25,7 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in The literal types live on the [tasks subsystem page](../../../../docs/subsystems/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, optional positive `outputLimitBytes`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. -The process-local provider also owns bounded admission. Its positive-safe-integer `maxConcurrentTasksPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count. +The process-local provider also owns bounded admission, whose rationale is recorded in the [bounded background task admission decision](../bug-fix/2026-08-11-bounded-background-task-admission.md). Its positive-safe-integer `maxConcurrentTasksPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count. `outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families. 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 2d85b9ba82..8cfc2b2ec7 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 @@ -25,7 +25,7 @@ Status: implemented 字面类型见[任务子系统页面](../../../../docs/subsystems/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。 -进程内 Service provider 还拥有有界准入。它的 `maxConcurrentTasksPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service provider 不排队或抢占任务,也不保留第二份可变计数。 +进程内 Service provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-task-admission.md)中。它的 `maxConcurrentTasksPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service provider 不排队或抢占任务,也不保留第二份可变计数。 `outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml index 682b53e94e..42ea8361e2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.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 .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md -2026-08-11-bounded-background-task-admission.md: a7df21e5e981d80635220af4d50fa596967659bd -2026-08-11-bounded-background-task-admission.zh.md: 9d0041af05899a164b79b83c77d35fc8f25edff4 +2026-08-11-bounded-background-task-admission.md: 24512a87f554cd2d775fe76c5a6e5a700a51f2e4 +2026-08-11-bounded-background-task-admission.zh.md: dc3abaf4a64a4dc5fe5cacaabd3c29e278874646 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md index a7df21e5e9..24512a87f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md @@ -14,6 +14,8 @@ The process-local task registry already owns the exact task owner and the author `LocalTaskService` owns a `maxConcurrentTasksPerOwner` configuration field. It accepts positive safe integers, defaults to `10`, and is available through the provider's Cordis schema, the typed `agent-spine-demo` bundle, and the ACP app configuration. The bundle transports the value; the process-local provider owns its meaning. +The [generic task runtime decision](../architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the shared Task lifecycle and control API; this note owns the process-local admission policy. + `start()` performs admission after the existing task-controller, task-field, and live-owner checks and before `TaskStart.run()`. It derives the active count from the registry's current records instead of storing another counter: | Record | Occupies capacity | Release fact | @@ -48,6 +50,6 @@ The task-provider suite covers the default and explicit limits, producer-before ## Consequences -One exact owner cannot keep creating Task-backed live resources indefinitely, and unrelated owners retain independent allowances. A slow stop can temporarily keep a bucket full, which is deliberate: the configured number bounds work that may still own resources, not cancellation requests. +One exact owner cannot keep creating Task-backed live resources indefinitely, and unrelated owners retain independent allowances. A slow stop keeps a bucket full until `done` settles, which is deliberate: the configured number bounds work that may still own resources, not cancellation requests. A producer whose `cancel` returns but whose `done` never settles holds one slot for the rest of the service lifetime and can stall teardown because the registry cannot safely infer resource release. Admission scans the process-local registry on each start. The cost grows with retained Task history, accepted in exchange for one state authority and a default limit small enough to bound the common live set. Terminal history remains available to existing reads and listings without consuming capacity. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md index 9d0041af05..dc3abaf4a6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md @@ -14,6 +14,8 @@ Status: implemented `LocalTaskService` 拥有 `maxConcurrentTasksPerOwner` 配置字段。它只接受正的安全整数,默认值为 `10`,并通过 Service provider 的 Cordis schema、typed `agent-spine-demo` 组合包与 ACP 应用配置提供。组合包只传输该值;其含义归进程内 Service provider 所有。 +[通用任务运行时决策](../architecture/2026-06-20-generic-long-running-tool-runtime.md)拥有共享 Task 生命周期与控制 API;本记录只拥有进程内准入策略。 + `start()` 在现有任务控制器、任务字段与存活 owner 检查之后、`TaskStart.run()` 之前执行准入。它从注册表当前记录派生活动数量,而不保存另一份计数: | 记录 | 占用容量 | 释放事实 | @@ -48,6 +50,6 @@ owner 与服务释放保留现有顺序:请求取消,在生产方释放资 ## 后果 -单个确切 owner 无法再无限创建由 Task 承载的实时资源,无关 owner 则保留独立额度。缓慢停止可能暂时让桶保持满载,这是有意行为:配置值限制的是仍可能拥有资源的工作,而不是取消请求。 +单个确切 owner 无法再无限创建由 Task 承载的实时资源,无关 owner 则保留独立额度。缓慢停止会让桶保持满载直到 `done` 结算,这是有意行为:配置值限制的是仍可能拥有资源的工作,而不是取消请求。如果生产方的 `cancel` 返回后始终不结算 `done`,它会在服务剩余生命周期内持续占用一个名额并阻塞销毁,因为注册表无法安全推断资源已经释放。 每次启动都会扫描进程内注册表。成本随保留的 Task 历史增长;为了保持单一状态权威,并利用足以约束常见实时集合的较小默认值,接受这一代价。终止历史仍可供现有读取与列表使用,但不消耗容量。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 52a8c284be..154e14cecb 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: f88bb888367e73188e20a1e2fbefdf3c4a926798 -config-catalog.zh.md: 5f3cbd274ec8704581642b74e7583249a0db6172 +config-catalog.md: 02abc64cfaf677e904ec585c28cbe6053444535c +config-catalog.zh.md: 8542403f68098b9e3939fce6ca033c39b1ecaf27 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f88bb88836..02abc64cfa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2039,7 +2039,10 @@ Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system ```ts config-catalog /** Configuration for the process-local task registry. */ export interface Config { - /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + /** + * Maximum `running` plus `stopping` tasks per exact owner or in the shared unowned bucket; + * omission defaults to 10. + */ maxConcurrentTasksPerOwner?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5f3cbd274e..8542403f68 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2041,7 +2041,10 @@ export interface Config { ```ts config-catalog /** Configuration for the process-local task registry. */ export interface Config { - /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + /** + * Maximum `running` plus `stopping` tasks per exact owner or in the shared unowned bucket; + * omission defaults to 10. + */ maxConcurrentTasksPerOwner?: number } ``` diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml index ee3158db1c..7901d983b7 100644 --- a/packages/tasks/tasks-local/README.i18n.yaml +++ b/packages/tasks/tasks-local/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 packages/tasks/tasks-local/README.md -README.md: b486438eb2f73728361a6140fdec33603a2ace45 -README.zh.md: ecc9104e4145add9448d43e1eb2565a3fc7e09c3 +README.md: f558676b36bb5462453bde553eac27b458e1268e +README.zh.md: ed8f2b1220692a00c09b6605b18231f5263f00e5 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index b486438eb2..f558676b36 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -31,4 +31,4 @@ 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. +- **A silently ineffective cancel can stall teardown and hold capacity** — if `cancel` returns without settling `done`, the registry cannot distinguish it from a slow stop; the task keeps one bucket slot for the rest of the service lifetime, and only an explicit throw can be force-failed safely. diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md index ecc9104e41..ed8f2b1220 100644 --- a/packages/tasks/tasks-local/README.zh.md +++ b/packages/tasks/tasks-local/README.zh.md @@ -31,4 +31,4 @@ ## 已知限制与暂缓事项 - **任务只存在于进程本地**:记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。 -- **静默无效的取消可能使销毁过程停滞**:只有显式抛出异常才能安全地强制标为失败。 +- **静默无效的取消可能使销毁过程停滞并持续占用容量**:如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。 diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 53ef9b2450..3d75f8dca4 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -29,7 +29,10 @@ const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10 /** Configuration for the process-local task registry. */ export interface Config { - /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + /** + * Maximum `running` plus `stopping` tasks per exact owner or in the shared unowned bucket; + * omission defaults to 10. + */ maxConcurrentTasksPerOwner?: number } diff --git a/packages/tasks/tasks-local/src/invariant.ts b/packages/tasks/tasks-local/src/invariant.ts index 21d00ed155..2c96c5338a 100644 --- a/packages/tasks/tasks-local/src/invariant.ts +++ b/packages/tasks/tasks-local/src/invariant.ts @@ -15,8 +15,10 @@ export const name = 'tasks-local-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the Service Definition companion in `@deepseek-ai/dsh-tasks` already - * validates every registry snapshot this implementation publishes. + * No runtime invariant: the Service Definition companion in `@deepseek-ai/dsh-tasks` validates + * every published snapshot. `LocalTaskService.start()` enforces this provider's configured + * admission limit synchronously before producer execution; repeating that aggregate check here + * would expose provider-private configuration solely to this companion. */ const install: InvariantInstaller = () => {} From 26891ce5ca26d385ab2bde721d023b5fb990f2fb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 23:25:12 +0800 Subject: [PATCH 6/6] docs(tasks): narrow invariant rationale --- packages/tasks/tasks-local/src/invariant.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/tasks/tasks-local/src/invariant.ts b/packages/tasks/tasks-local/src/invariant.ts index 2c96c5338a..aa9cb98673 100644 --- a/packages/tasks/tasks-local/src/invariant.ts +++ b/packages/tasks/tasks-local/src/invariant.ts @@ -15,10 +15,11 @@ export const name = 'tasks-local-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the Service Definition companion in `@deepseek-ai/dsh-tasks` validates - * every published snapshot. `LocalTaskService.start()` enforces this provider's configured - * admission limit synchronously before producer execution; repeating that aggregate check here - * would expose provider-private configuration solely to this companion. + * No runtime invariant: `@deepseek-ai/dsh-tasks/invariant` owns per-snapshot identity, status, + * timestamp, and owner checks. This provider's admission decision uses private configuration and + * must fail before a backend starter runs; `LocalTaskService.start()` enforces it synchronously + * for current producers. Repeating an aggregate after publication would expose private + * configuration solely to this companion and would not verify the fail-closed pre-start guarantee. */ const install: InvariantInstaller = () => {}