Merge remote-tracking branch 'origin/master' into worktree/windows-acl-hardening-followup

This commit is contained in:
Tianyi Cui
2026-08-10 17:16:24 +08:00
39 changed files with 407 additions and 106 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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'`,从不导入提供方类型。

View File

@@ -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

View File

@@ -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: <name>: 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.

View File

@@ -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: <name>: true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。
- 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。

View File

@@ -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 ──────────────────────────────────────────────────────────────────

View File

@@ -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 ───────────────────────────────────────────────────────────────────

View File

@@ -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'

View File

@@ -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 ──────────────────────────────────────────────────────────────────

View File

@@ -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)

View File

@@ -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

View File

@@ -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) |

View File

@@ -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) |

View File

@@ -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

View File

@@ -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<TaskSnapshot>
/**
* 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)
<!-- END GENERATED cordis-surface -->

View File

@@ -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<TaskSnapshot>
/**
* 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)
<!-- END GENERATED cordis-surface -->

View File

@@ -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)
})

View File

@@ -778,7 +778,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)
})

View File

@@ -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

View File

@@ -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 */',
},
],
},

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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.

View File

@@ -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、输出、状态、取消和完成通知。

View File

@@ -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:^",

View File

@@ -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<symbol>()
readonly listeners = new AnonymousEntries<TaskDoneListener>()
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<TaskId, TrackedTask>()
private counters = new Map<string, number>()
private surfaces = new Set<symbol>()
private listeners = new Set<TaskDoneListener>()
/**
* 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<TaskLayer>(() => new TaskLayer(), () => {})
private listenersClosed = false
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
private ownerCleanups = new Map<Agent, () => Promise<void> | 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<TaskDoneListener> {
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<void> {
// 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))

View File

@@ -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<Agent, () => Promise<void>>()
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<void> {
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<void>(r => setTimeout(r, 0))
@@ -89,11 +115,48 @@ describe('LocalTaskService.start', () => {
expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
})
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')
})
})

View File

@@ -17,6 +17,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/scope"
},
{
"path": "../../util/timeout"
},

View File

@@ -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

View File

@@ -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.

View File

@@ -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 的任务向调用方开放,并持续到服务释放。

View File

@@ -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<TaskSnapshot>
/**
* 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.
*/

View File

@@ -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

View File

@@ -20,6 +20,8 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice.
One host registry may carry several mounts of this plugin — one per agent preset. The registry routes each settlement to the listeners the owner's scope chain reaches, so a mount under one preset never sees another preset's agents and an agent reads exactly one notice per completion however many presets are mounted. The same routing decides which agents this mount's control surface serves: an agent whose composition loads no `tool-tasks` cannot start background work at all.
## Config
| key | default | meaning |

View File

@@ -20,6 +20,8 @@
一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。
一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent无论挂载了多少 preset一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制表层服务哪些 agent组合中未加载 `tool-tasks` 的 agent 根本无法启动后台工作。
## 配置
| key | 默认值 | 含义 |

View File

@@ -230,6 +230,10 @@ export function apply(ctx: Context, config: Config): void {
// Delivery targets the exact lifecycle owner. The notice waits in its
// next-step inbox until another step claims it; disposal before that
// boundary discards it with the owner.
//
// The registry routes each settlement to the listeners its owner's scope
// chain reaches, so a mount under one preset never sees another preset's
// agents; this listener owns delivery, not the choice of whom to deliver to.
ctx.tasks.onTaskDone((snapshot, owner) => {
if (snapshot.reported || owner === undefined) return
owner.inject(createUserMessage({

View File

@@ -6,6 +6,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import { TaskId } from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
@@ -85,7 +86,7 @@ describe('tool-tasks setup', () => {
const { ctx, toolsFiber } = await setup()
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
await toolsFiber.dispose()
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')
})
it('rejects a config whose default wait exceeds the cap', async () => {
@@ -445,6 +446,54 @@ describe('tool-owned UI presentation (presentCall)', () => {
})
})
describe('completion notices across scoped mounts', () => {
/**
* Two agent presets mounting `tool-tasks` over ONE host registry: each mount
* registers its own `onTaskDone` listener on the shared service, and
* `settle()` broadcasts one snapshot to every listener with no scope filter.
* Only the mount whose scope the owner belongs to may deliver the notice.
*/
it('delivers one notice from the owning scope when two mounts share the registry', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
const standingA = createScope(ctx, {})
const standingB = createScope(ctx, {})
await standingA.ctx.plugin(ToolTasks)
await standingB.ctx.plugin(ToolTasks)
// The agent joins preset A exactly as `agentPresets.compose` binds it.
const agentKey = {}
const agentScope = createScope(ctx, agentKey)
bindScopeParent(agentKey, scopeOf(standingA.ctx) as object)
const inject = vi.fn()
const owner = {
id: SessionId('sess-scoped'),
ctx: agentScope.ctx,
inject,
session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } },
} as unknown as Agent
const dispose = ctx.agents.register(owner)
try {
// No waiter: `settle()` leaves `reported` false, which is the only path
// that reaches the notice listeners at all.
const p = producer({ owner, label: 'pnpm test' })
ctx.tasks.start(p.spec)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
} finally {
dispose()
}
})
})
describe('completion notices', () => {
it('injects a notice into the owning agent when an unreported task settles', async () => {
const { ctx } = await setup()

3
pnpm-lock.yaml generated
View File

@@ -6942,6 +6942,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../../core/scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session