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 fc8cf85334..5236b342a2 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: 45801505f1729ec6094900acf94b4c17ed92c3b1 -2026-07-26-task-registry-seam.zh.md: 8dd90b34d2da1d22caba13fe8c388dab4a29be0d +2026-07-26-task-registry-seam.md: 4487bd9c53595fa8b4eed588b294ceafe3ab58dc +2026-07-26-task-registry-seam.zh.md: 6195dc809e84852c7e0f63ac101ba0ed6a46853e 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 45801505f1..4487bd9c53 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,7 +12,7 @@ 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 control surface is attached. +- **`@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-tool-tasks` (Consumer)** — unchanged; it injects `'tasks'` and never imports provider types. 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 8dd90b34d2..6195dc809e 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,7 +12,7 @@ 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,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且在没有附加任何控制接口时 `start` 拒绝启动工作。 +- **`@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-tool-tasks`(Consumer)**——保持不变;它注入 `'tasks'`,从不导入提供方类型。 diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index cbccafe160..6ae88b9339 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -23,11 +23,11 @@ # from here; the executor behind it (`bash-sandbox`) is host-plane too, where the # sandbox policy owns it. # -# `run_in_background` is off because this preset mounts no `tool-tasks`: the -# host task registry gates starts on SOME control surface being attached, and -# that set is process-wide, so another preset's controls would let this agent -# start work it has no `task_output` to collect. Disabling drops the parameter -# from the schema too, which is the honest surface for a two-tool benchmark. +# `run_in_background` is off because this preset mounts no `tool-tasks`. The +# host registry already refuses a start for an owner no attached control +# surface serves, so this is not the safety boundary — it is the model-facing +# one: an agent that could never collect a task should not be offered the +# parameter at all, and disabling it drops the parameter from the schema. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' config: diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 680f6afa42..60c6e85cca 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: f4d275393dd918f1391d33db19222e4e62e80b96 -config-catalog.zh.md: 9b3cf689c8d4c23d1cca1260b7e74c911558d678 +config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4 +config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4d275393d..18980d22c6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2332,7 +2332,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9b3cf689c8..a43c561806 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2333,7 +2333,7 @@ export interface Config { } ``` -来源:[`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) +来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 229c8d1cd6..e973aeacf7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: 0f76bfd2dd700d81e2c6fb6faec3d2c0c9655e98 -module-graph.zh.md: 7fa0eb72666e63e72109a272cdc9ce323c60d2fd +module-graph.md: 9cb734066beb6c8f721c57d8d2cae29ff299ae66 +module-graph.zh.md: 3005ce3143d2ba89e2808e048b18224c438ece60 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0f76bfd2dd..9cb734066b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -730,6 +730,7 @@ flowchart TD pkg_session_title_llm --> pkg_timeout pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants + pkg_tasks_local --> pkg_scope pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout pkg_token_meter --> pkg_compact @@ -947,7 +948,6 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention - pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1353,7 +1353,7 @@ flowchart TD | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1391,7 +1391,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 7fa0eb7266..3005ce3143 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -732,6 +732,7 @@ flowchart TD pkg_session_title_llm --> pkg_timeout pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants + pkg_tasks_local --> pkg_scope pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout pkg_token_meter --> pkg_compact @@ -949,7 +950,6 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention - pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1355,7 +1355,7 @@ flowchart TD | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1393,7 +1393,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index c229d0a5c0..37b776d609 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: d3e92891a6736a85519b97775ebe7aa6f12a5ae2 -tasks.zh.md: fe54d7a6482743a8f2ef31b143edb25afe0a478b +tasks.md: 51b21b6d81905f1dede2a1c748a417425d2aaf4e +tasks.zh.md: 045f4e438b2b384439826ecca0782349d7aaae27 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index d3e92891a6..51b21b6d81 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -172,7 +172,7 @@ Implementations must honor these semantics: - Registrations outlive producer and control-surface fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. - Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome. -- start refuses work while no control surface is attached, so a producer cannot start work that callers cannot collect or stop. +- start refuses work while no attached control surface serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. ```ts cordis-catalog /** @@ -237,17 +237,19 @@ abstract kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'alrea abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ @@ -256,5 +258,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index fe54d7a648..045f4e438b 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -172,7 +172,7 @@ Implementations must honor these semantics: - Registrations outlive producer and control-surface fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. - Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome. -- start refuses work while no control surface is attached, so a producer cannot start work that callers cannot collect or stop. +- start refuses work while no attached control surface serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. ```ts cordis-catalog /** @@ -237,17 +237,19 @@ abstract kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'alrea abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ @@ -256,5 +258,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 4f913d78ee..9a1698ac1d 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -536,7 +536,7 @@ describe('background execution through the task runtime', () => { const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no process ever ran. expect((ctx.bash as CountingStartExecutor).starts).toBe(0) }) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 91ad796ce5..7ecdbcd5f2 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -777,7 +777,7 @@ describe('background execution through the task runtime', () => { const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no process ever ran. expect(bash.startCalls).toBe(0) }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 86def75601..de53d4ae47 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1122,11 +1122,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void', - jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', + jsDoc: '/**\n * Register an effect-scoped completion listener. It receives the settlements\n * of the owners its registering context\'s scope covers; each listener is\n * contained; returned promises are observed but not awaited. No listener runs\n * after service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', }, { signature: 'abstract attachSurface(name: string): () => void', - jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', + jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. It serves the\n * owners its registering context\'s scope covers, and {@link start} refuses an\n * owner no attached surface serves.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', }, ], }, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 91dc423cd4..04b127a856 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -1027,7 +1027,7 @@ describe('background preflight failure (no orphaned child, by construction)', () agent: parent, }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no child ever existed. expect(starts).toBe(0) }) diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml index d4fa5d09bf..3a43cba8f8 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: 663ce0d333c6df0f84900a2570d5487d8d7abe55 -README.zh.md: a5d1acaa50f63dc95b7607657b157272c15a4f11 +README.md: 80d7932466188955ade1c14e4968d51b739ba818 +README.zh.md: 5e4263e4685be64f91ec2a7c74edbf89e1148866 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index 663ce0d333..80d7932466 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -12,6 +12,8 @@ Service disposal closes listeners, cancels all live tasks, awaits their records, Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion surfaces do not duplicate notices. +Surfaces and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered. + ## Model Experience Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices. diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md index a5d1acaa50..5e4263e468 100644 --- a/packages/tasks/tasks-local/README.zh.md +++ b/packages/tasks/tasks-local/README.zh.md @@ -12,6 +12,8 @@ 结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,也只通知监听器一次;各监听器的故障会单独隔离,随后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此呈现完成情况的表层不会重复发出通知。 +表层与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何表层的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。 + ## 模型体验 通过生产方插件和 [`dsh-tool-tasks`](../tool-tasks/README.md) 间接影响;它们会呈现任务 id、输出、状态、取消和完成通知。 diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index 446b5825d3..92e095ffa9 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -35,6 +36,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 60a7beb012..146d3e4ca7 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -11,6 +11,8 @@ import { Context } from 'cordis' 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' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks' @@ -49,6 +51,21 @@ function isTerminal(status: TaskStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' } +/** + * One scope's contributions: the control surfaces attached from it and the + * completion listeners registered there. Both tables are anonymous because a + * contribution is identified by its own disposer, never by a name a second + * registrant could shadow. + */ +class TaskLayer implements ScopeLayer { + readonly surfaces = new AnonymousEntries() + readonly listeners = new AnonymousEntries() + + isEmpty(): boolean { + return this.surfaces.isEmpty() && this.listeners.isEmpty() + } +} + /** * The in-memory `tasks` registry. See the Service Definition contract in * `@deepseek-ai/dsh-tasks` for the ownership, isolation, and lifecycle @@ -57,8 +74,19 @@ function isTerminal(status: TaskStatus): boolean { export class LocalTaskService extends TaskService { private store = new Map() private counters = new Map() - private surfaces = new Set() - private listeners = new Set() + /** + * Surfaces and listeners layered by the scope that registered them, in the + * tools-registry shape: a contribution files into its registering context's + * scope, and a read unions the global layer with the reader's scope chain. + * + * The registry is one process-wide instance serving every composition, so a + * flat table would answer a per-owner question process-wide: one preset's + * task controls would hold `start()` open for an agent whose own composition + * loads none, and one settlement would reach every preset's notice listener. + * Layers make both reads owner-relative. Nothing derives a cache from a + * layer, so change notification is a no-op. + */ + private readonly layers = new ScopedLayers(() => new TaskLayer(), () => {}) private listenersClosed = false /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ private ownerCleanups = new Map Promise | void>() @@ -72,8 +100,8 @@ export class LocalTaskService extends TaskService { } start(spec: TaskStart): TaskId { - if (this.surfaces.size === 0) { - throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') + if (!this.servesOwner(spec.owner)) { + throw new Error('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)') } if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string') if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string') @@ -210,23 +238,53 @@ export class LocalTaskService extends TaskService { } onTaskDone(listener: TaskDoneListener): () => void { - const dispose = this.ctx.effect(() => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - }, 'tasks.onTaskDone()') + const dispose = this.layers.effect( + this.ctx, + layer => layer.listeners.append(listener), + { label: 'tasks.onTaskDone()' }, + ) return () => void dispose() } attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) - const dispose = this.ctx.effect(() => { - this.surfaces.add(token) - return () => this.surfaces.delete(token) - }, 'tasks.attachSurface()') + const dispose = this.layers.effect( + this.ctx, + layer => layer.surfaces.append(token), + { label: 'tasks.attachSurface()' }, + ) return () => void dispose() } + /** + * Whether an attached control surface can collect and stop work owned by + * `owner`. The global layer holds every surface attached from an unscoped + * context — a host composition's own controls — and therefore serves every + * owner; a scoped surface serves exactly the agents composed under it. + * @param owner - the task's owner, or undefined for unowned work. + * @returns whether some reachable surface serves the owner. + */ + private servesOwner(owner?: Agent): boolean { + if (!this.layers.global.surfaces.isEmpty()) return true + return this.layers.chainLayers(owner === undefined ? undefined : scopeOf(owner.ctx)) + .some(layer => !layer.surfaces.isEmpty()) + } + + /** + * 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 + * that chain belongs to another composition and must not deliver, or the + * owner reads one notice per mounted preset. + * @param owner - the settled task's owner, or undefined for unowned work. + * @returns the listeners to notify, in registration order per layer. + */ + private *listenersFor(owner?: Agent): IterableIterator { + yield* this.layers.global.listeners.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.listeners.values() + } + /** Look up a task or fail loud. */ private expect(id: TaskId): TrackedTask { const task = this.store.get(id) @@ -276,7 +334,7 @@ export class LocalTaskService extends TaskService { if (task.waiters > 0) task.reported = true if (!this.listenersClosed) { const snapshot = this.snapshot(task) - for (const listener of this.listeners) { + for (const listener of this.listenersFor(task.owner)) { try { const returned = listener(snapshot, task.owner) void Promise.resolve(returned).catch((error: unknown) => { @@ -330,8 +388,9 @@ export class LocalTaskService extends TaskService { * effects. Throwing cancels are force-failed to avoid teardown deadlock. */ private async disposeAll(): Promise { + // The flag is the whole guard: each layer entry's undo belongs to the fiber + // that registered it, so this service may not drop them on its own way out. this.listenersClosed = true - this.listeners.clear() const all = [...this.store.values()] this.cancelForTeardown(all, 'tasks service disposed') await Promise.all(all.map(task => task.settled)) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 29d859760f..bdaa31d975 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -3,6 +3,8 @@ import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +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' @@ -15,9 +17,18 @@ declare module '@deepseek-ai/dsh-tasks' { const agentScopeDisposers = new WeakMap Promise>() -function stubAgent(ctx: Context, rawId: string): Agent { +function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { const id = SessionId(rawId) const scopeFiber = ctx.plugin(() => {}) + // `presetScope` reproduces what `agentPresets.compose` does: the agent gets + // its own key parented to the standing mount's, so the registry's chain walk + // reaches that preset's layer. + let agentCtx = scopeFiber.ctx + if (presetScope !== undefined) { + const key = {} + bindScopeParent(key, presetScope) + agentCtx = createScope(scopeFiber.ctx, key).ctx + } const session = Session.create(id) const agent = { id, @@ -25,7 +36,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle' as const, - ctx: scopeFiber.ctx, + ctx: agentCtx, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), @@ -73,6 +84,21 @@ async function harness() { return ctx } +/** + * Attach a control surface the way `tool-tasks` does: from a plugin whose own + * `inject` resolves `ctx.tasks`, so the service method binds to the REGISTERING + * context and the surface files into that context's scope layer. Reading the + * service off a bare scoped context instead throws `cannot get property "tasks" + * without inject`, which is the same rule the shipped plugin obeys. + * @param ctx - the context whose scope should own the surface. + */ +async function attachSurfaceIn(ctx: Context): Promise { + await ctx.plugin({ + inject: ['tasks'], + apply(pluginCtx: Context) { pluginCtx.tasks.attachSurface('tool-tasks') }, + }) +} + /** Let the settlement continuation (a `done.then`) run. */ const tick = () => new Promise(r => setTimeout(r, 0)) @@ -89,11 +115,48 @@ describe('LocalTaskService.start', () => { expectTypeOf().toEqualTypeOf() }) - it('refuses to register while no control surface is attached', async () => { + it('refuses to register while no control surface serves the owner', async () => { const ctx = new Context() await ctx.plugin(LocalTaskService) expect(() => ctx.tasks.start(producer().spec)) - .toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') + .toThrow('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)') + }) + + it('refuses an owner whose own composition attaches no surface', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + // Two standing preset mounts over one registry; only the first loads the + // task controls. The second must not inherit the first's open gate. + const withControls = createScope(ctx, {}) + const withoutControls = createScope(ctx, {}) + await attachSurfaceIn(withControls.ctx) + + const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx)) + const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx)) + ctx.agents.register(served) + ctx.agents.register(unserved) + + expect(() => ctx.tasks.start(producer({ owner: served }).spec)).not.toThrow() + expect(() => ctx.tasks.start(producer({ owner: unserved }).spec)) + .toThrow('no control surface serves this agent') + // An unowned producer has no chain to walk, so only a global surface serves it. + expect(() => ctx.tasks.start(producer().spec)) + .toThrow('no control surface serves this agent') + }) + + it('lets a surface attached without a scope serve every owner', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + // The host-plane composition's own controls: no scope, so the global layer + // holds them and every owner's read includes it. + await attachSurfaceIn(ctx) + const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx)) + ctx.agents.register(scoped) + + expect(() => ctx.tasks.start(producer({ owner: scoped }).spec)).not.toThrow() + expect(() => ctx.tasks.start(producer().spec)).not.toThrow() }) it('rejects an empty kind, empty label, and invalid output limit', async () => { @@ -757,6 +820,6 @@ describe('LocalTaskService disposal', () => { detachA2() expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains await fiber.dispose() // detaches b with its fiber (HMR safety) - expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached') + expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent') }) }) diff --git a/packages/tasks/tasks-local/tsconfig.json b/packages/tasks/tasks-local/tsconfig.json index 147e3915bc..4e9a3e20bf 100644 --- a/packages/tasks/tasks-local/tsconfig.json +++ b/packages/tasks/tasks-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/scope" + }, { "path": "../../util/timeout" }, diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 94f8993c2c..95bb5a3889 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: 04ca125580104d0cb5ab0ce8cdd20d104c4a1668 -README.zh.md: b0b079566246549c7acab7a83fcc3c21c44ef868 +README.md: f23a93e3cb1fc5aad5bad053f832bb66baa8eb64 +README.zh.md: c3ea9125c4db9f2d86722cbf490b4f0eecfbe25a diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 04ca125580..f23a93e3cb 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,7 +12,9 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService` - `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. - `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter. - `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited. -- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached. +- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when no attached surface serves the spec's owner. + +Both registrations are owner-relative, because one registry serves every composition in the process. A surface or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no control surface cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered. Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index b0b0795662..c3ea9125c4 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,7 +12,9 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 -- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。 +- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。当没有任何已附加的表层服务于 spec 的所有者时,`start()` 会在生产方执行前失败。 + +这两类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的表层或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制表层的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 5bfbe4e5a8..8c49ec8445 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -44,8 +44,13 @@ declare module 'cordis' { * - Settlement is first-wins: one terminal record, one round of contained * listener notification, and released waiters, even against a late * producer outcome. - * - {@link start} refuses work while no control surface is attached, so a - * producer cannot start work that callers cannot collect or stop. + * - {@link start} refuses work while no attached control surface serves the + * spec's owner, so a producer cannot start work that owner cannot collect + * or stop. One registry serves every composition in the process, so this + * question — and completion-listener delivery — is owner-relative rather + * than process-wide: registrations made from an unscoped context serve + * every owner, and registrations made under an agent composition's scope + * serve exactly the agents composed under it. */ export abstract class TaskService extends Service { constructor(ctx: Context) { @@ -120,17 +125,19 @@ export abstract class TaskService extends Service { abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 8c97357246..aba9cf38d6 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-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/tool-tasks/README.md -README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8 -README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86 +README.md: 4e8872b087bac6576a3b48acbf079d3f06f3a131 +README.zh.md: 3beeb1b70c3a757f5935b0621c9648e0a02c7aa5 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 1b63ba7124..4e8872b087 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -20,7 +20,7 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` An unreported completion injects `background task (: