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/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index d8c55c9f0a..410e209e28 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.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-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 -2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 +2026-08-08-per-preset-standing-mounts.md: c2792454f90a88cd6fba36eed8e36104e5fffea4 +2026-08-08-per-preset-standing-mounts.zh.md: 47668c8c2c424eb188aa14bf55986d27bcfb8ee0 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 834d645f5f..c2792454f9 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -16,7 +16,7 @@ A preset is one composition per PROCESS, not one per session. The roster mounts Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap -- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. +- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. `tasks-local` shared that property and has since left the preset plane entirely: producers outside its realm (`tool-bash`, `tool-pty`, a non-continuable `tool-subagent`) resolve the registry with `ctx.get`, which an entry-local realm hides from them, so it is composed on the host plane and only the model-facing `tool-tasks` row stays per preset. - Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: : true`) keep two presets' same-named services apart exactly as they kept two sessions' apart. - A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 45ce0f4e7d..47668c8c2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -16,7 +16,7 @@ Status: implemented 常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因: -- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。 +- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。`tasks-local` 同样具备该性质,且此后已完全离开 preset 平面:realm 之外的生产方(`tool-bash`、`tool-pty`、非 continuable 的 `tool-subagent`)以 `ctx.get` 解析该注册表,而 entry-local realm 对它们不可见,因此它组合在宿主平面,只有面向模型的 `tool-tasks` 行仍留在各 preset 中。 - preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: : true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 - 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。 diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index d61c4c545f..5e273bb955 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -70,17 +70,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index a6212af84e..1bc3d3bcf0 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -64,17 +64,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── goals ─────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 8ca6f0dcdf..6ae88b9339 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -22,8 +22,16 @@ # never reached the model's shell at all. `tool-bash` consumes the host registry # 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 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: + enableRunInBackground: false - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index f2f0122948..6442b12306 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -63,17 +63,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 25d1b2c0dc..faf01ede62 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -5,6 +5,7 @@ // surface itself. import { tmpdir } from 'node:os' import { afterEach, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' // Empty type imports carry the tools/sandboxPolicy/approval Context merges. @@ -114,3 +115,64 @@ it('assembles the shipped Web catalog with the confined access default', async ( await commandHandle.dispose() } }, 120_000) + +it('lets a preset producer reach the background-task registry', async () => { + scaffold = await launchWebScaffold() + const ctx = scaffold.ctx + const handle = await ctx.agents.create({ + sessionId: SessionId('shipped-background-task'), + meta: { cwd: scaffold.workspaceCwd }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const signal = new AbortController().signal + // `tool-bash` is a preset row and `tasks` is a host registry; the producer + // resolves it with `ctx.get`, so a registry hidden behind a preset realm + // fails here — with every task control still listed in the catalog above. + const started = await ctx.tools.execute({ + signal, + callId: CallId('shipped-bash-background'), + name: 'bash', + arguments: { + command: 'printf SHIPPED_BACKGROUND_OK', + description: 'shipped background probe', + run_in_background: true, + }, + agent: handle.agent, + }) + expect({ isError: started.isError, content: started.content }).toEqual({ + isError: false, + content: [{ type: 'text', text: 'started background task bash-1' }], + }) + + // The control surface reads what the producer started: same registry, one + // owner. A per-preset registry would list nothing here even on success. + const listed = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-list'), + name: 'task_list', + arguments: {}, + agent: handle.agent, + }) + expect(listed.isError).toBe(false) + expect(listed.content).toEqual([ + { type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string }, + ]) + + // The full round trip: the output a host-plane producer wrote is collected + // through a preset-plane control, which is the linkage the realm severed. + const collected = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-output'), + name: 'task_output', + arguments: { task_id: 'bash-1', wait: true }, + agent: handle.agent, + }) + expect(collected.isError).toBe(false) + expect(collected.content).toEqual([ + { type: 'text', text: expect.stringContaining('SHIPPED_BACKGROUND_OK') as unknown as string }, + ]) + } finally { + await handle.dispose() + } +}, 120_000) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 66486e97b7..d478f79cd1 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: a2407f5d394020834172288e3d03518d1e8045db -module-graph.zh.md: 364033c29d773a764ce3f8f0036edeac7c9e0b21 +module-graph.md: 6763ca6e84a5cc2e5e776a56e5cfc20b710017aa +module-graph.zh.md: 339345b39a8225c34ecf0ba73efff34d4b940046 diff --git a/docs/module-graph.md b/docs/module-graph.md index a2407f5d39..6763ca6e84 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -746,6 +746,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 @@ -1372,7 +1373,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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 364033c29d..339345b39a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -748,6 +748,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 @@ -1374,7 +1375,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) | diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index 5956c1fc4f..6a95ccf73e 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: de331045d6cbec64c1305b0a20f0c821e9578469 -tasks.zh.md: f33da2a7d0e09d094110c89f88b6f7508400da9b +tasks.md: 2da8212b3edf7111180b60162108af0aebb249c1 +tasks.zh.md: 825a69029ae931f9668cca863c6ebb2324634a9a diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index de331045d6..2da8212b3e 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 f33da2a7d0..825a69029a 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/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d75971516e..216eb13199 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -218,10 +218,17 @@ - id: tool-bash disabled: true -- id: tool-tasks - disabled: true +# The background-task REGISTRY stays on the host plane; only the model-facing +# `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a +# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it +# with `ctx.get`, and an entry-local realm around the registry is invisible to +# every sibling row outside that realm, so `run_in_background` answered +# "background tasks unavailable" while the controls sat in the catalog. That is +# the `goals` criterion read from inside the preset: a Service a row outside its +# realm READS belongs to the plane both can see. The registry is keyed by owning +# agent, so one host instance serves every session exactly as before presets. -- id: tasks +- id: tool-tasks disabled: true - id: tool-fs diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 929bddece9..9e63aa3ea3 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1140,11 +1140,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 807bf66cf1..c85c3af713 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,21 +238,49 @@ 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()') - return () => void dispose() + return this.layers.effect( + this.ctx, + layer => layer.listeners.append(listener), + { label: 'tasks.onTaskDone()' }, + ) } attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) - const dispose = this.ctx.effect(() => { - this.surfaces.add(token) - return () => this.surfaces.delete(token) - }, 'tasks.attachSurface()') - return () => void dispose() + return this.layers.effect( + this.ctx, + layer => layer.surfaces.append(token), + { label: 'tasks.attachSurface()' }, + ) + } + + /** + * 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. */ @@ -276,7 +332,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 +386,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 35d7e77ff2..2c3fd11ef2 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 () => { @@ -742,6 +805,30 @@ describe('LocalTaskService disposal', () => { expect(ownerEffects()).toHaveLength(0) }) + it('drops a scoped layer when its registrations dispose', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + const standing = createScope(ctx, {}) + // One mount contributes both kinds into the same layer, as `tool-tasks` + // does; unloading it must leave nothing serving the agents that joined it. + const mount = await standing.ctx.plugin({ + inject: ['tasks'], + apply(pluginCtx: Context) { + pluginCtx.tasks.attachSurface('tool-tasks') + pluginCtx.tasks.onTaskDone(() => {}) + }, + }) + const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx)) + ctx.agents.register(owner) + expect(() => ctx.tasks.start(producer({ owner }).spec)).not.toThrow() + + await mount.dispose() + + expect(() => ctx.tasks.start(producer({ owner }).spec)) + .toThrow('no control surface serves this agent') + }) + it('detaching the last surface re-arms the register fence', async () => { const ctx = new Context() await ctx.plugin(LocalTaskService) @@ -757,6 +844,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 24e6b6892a..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: 6e8e889c2330d6991cb384674b011e4d2e988268 -README.zh.md: 355b6736b476fb2434f17ea3857544f32c40adb3 +README.md: 4e8872b087bac6576a3b48acbf079d3f06f3a131 +README.zh.md: 3beeb1b70c3a757f5935b0621c9648e0a02c7aa5 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 6e8e889c23..4e8872b087 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -20,6 +20,8 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` An unreported completion injects `background task (: