Merge commit 'f1a9ab14fe882f9b855ea9cc3f16ddc9a3b0284d' into codex/bounded-background-tasks-v2

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
#	.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
#	.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
#	.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/subsystems/tasks.i18n.yaml
#	docs/subsystems/tasks.md
#	docs/subsystems/tasks.zh.md
#	packages/examples/acp-demo/src/index.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/tasks/tasks/README.i18n.yaml
#	packages/tasks/tasks/README.md
#	packages/tasks/tasks/README.zh.md
This commit is contained in:
pku-xht
2026-08-11 16:44:05 +08:00
778 changed files with 4933 additions and 2062 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 packages/tasks/tasks-local/README.md
README.md: dcfaf397ccfb61be72c2846cb3f678469645a2c7
README.zh.md: 60ddb2901a51974cfeaccd8c820bc084b9ca0949
README.md: c1fa91cd837ef9ad6ef36b672c874bc9d64edc67
README.zh.md: f521b2714c3848756628510df85f5cf98731b162

View File

@@ -12,13 +12,13 @@ At capacity, `start()` fails before producer execution and id allocation with an
## Lifecycle
Tasks belong to their owner and backend, not the producer tool fiber, so producer and surface reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
Tasks belong to their owner and backend, not the producer tool fiber, so producer and controller reloads do not stop them. The first task for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's tasks, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
Service disposal closes listeners, cancels all live tasks, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion surfaces do not duplicate notices.
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 reporters 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.
Controllers 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 task controller 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

View File

@@ -12,13 +12,13 @@
## 生命周期
任务属于其所有者和后端,而不是生产方工具 fiber因此重载生产方或接口不会停止任务。某个所有者的第一个任务会把一个会被等待的 effect 附加到对应 `Agent` 对象的 scope 上。所有者的 dispose资源释放会取消该对象的任务等待生产方完全停稳并移除其快照复用的 agent智能体id 或会话 id 无法重定向旧的清理操作。
任务属于其所有者和后端,而不是生产方工具 fiber因此重载生产方或控制器不会停止任务。某个所有者的第一个任务会把一个会被等待的 effect 附加到对应 `Agent` 对象的 scope 上。所有者的 dispose资源释放会取消该对象的任务等待生产方完全停稳并移除其快照复用的 agent智能体id 或会话 id 无法重定向旧的清理操作。
服务 dispose 会关闭监听器、取消所有存活任务、等待其记录完成,并从仍存活的所有者 scope 中分离 effect。如果销毁期间的取消操作抛出异常服务会强制将记录标为失败并警告工作可能成为孤立工作而不会死锁。取消操作已返回但 `done` 始终未结算时,系统无法将其与缓慢停止区分开,销毁过程可能因此停滞。
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,也只通知监听器一次;各监听器的故障会单独隔离,随后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此呈现完成情况的表层不会重复发出通知。
结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `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)`;一次结算也只会抵达其所有者所属组合注册的监听器。
控制器与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何控制器的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background tasks unavailable: no task controller serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。
## 模型体验

View File

@@ -3,7 +3,7 @@
* (`ctx.tasks`). It keeps every record in memory and hands out fresh
* snapshots, never live state.
*
* Registrations outlive producer and control-surface fibers. Agent or service
* Registrations outlive producer and controller fibers. Agent or service
* disposal cancels live work and awaits compliant producers; a throwing
* teardown cancel force-fails only the record and reports a possible orphan.
* @module @deepseek-ai/dsh-tasks-local
@@ -68,18 +68,18 @@ function isTerminal(status: TaskStatus): boolean {
}
/**
* One scope's contributions: the control surfaces attached from it and the
* One scope's contributions: the task controllers 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 controllers = new AnonymousEntries<symbol>()
readonly listeners = new AnonymousEntries<TaskDoneListener>()
readonly changed = new AnonymousEntries<TasksChangedListener>()
isEmpty(): boolean {
return this.surfaces.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty()
return this.controllers.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty()
}
}
@@ -134,7 +134,7 @@ export class LocalTaskService extends TaskService {
start(spec: TaskStart): TaskId {
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)')
throw new Error('background tasks unavailable: no task controller 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')
@@ -297,28 +297,28 @@ export class LocalTaskService extends TaskService {
)
}
attachSurface(name: string): () => void {
attachController(name: string): () => void {
// One token per call keeps duplicate labels independently disposable.
const token = Symbol(name)
return this.layers.effect(
this.ctx,
layer => layer.surfaces.append(token),
{ label: 'tasks.attachSurface()' },
layer => layer.controllers.append(token),
{ label: 'tasks.attachController()' },
)
}
/**
* Whether an attached control surface can collect and stop work owned by
* `owner`. The global layer holds every surface attached from an unscoped
* Whether an attached task controller can collect and stop work owned by
* `owner`. The global layer holds every controller 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.
* owner; a scoped controller 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.
* @returns whether some reachable controller serves the owner.
*/
private servesOwner(owner?: Agent): boolean {
if (!this.layers.global.surfaces.isEmpty()) return true
if (!this.layers.global.controllers.isEmpty()) return true
return this.layers.chainLayers(owner === undefined ? undefined : scopeOf(owner.ctx))
.some(layer => !layer.surfaces.isEmpty())
.some(layer => !layer.controllers.isEmpty())
}
/** Count authoritative active records for one exact owner or the shared unowned bucket. */

View File

@@ -80,22 +80,22 @@ async function harness(config: TasksConfig = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService, config)
ctx.tasks.attachSurface('test-surface')
ctx.tasks.attachController('test-controller')
return ctx
}
/**
* Attach a control surface the way `tool-tasks` does: from a plugin whose own
* Attach a task controller 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
* context and the controller 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.
* @param ctx - the context whose scope should own the controller.
*/
async function attachSurfaceIn(ctx: Context): Promise<void> {
async function attachControllerIn(ctx: Context): Promise<void> {
await ctx.plugin({
inject: ['tasks'],
apply(pluginCtx: Context) { pluginCtx.tasks.attachSurface('tool-tasks') },
apply(pluginCtx: Context) { pluginCtx.tasks.attachController('tool-tasks') },
})
}
@@ -115,14 +115,14 @@ describe('LocalTaskService.start', () => {
expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
})
it('refuses to register while no control surface serves the owner', async () => {
it('refuses to register while no task controller 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 serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)')
.toThrow('background tasks unavailable: no task controller serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)')
})
it('refuses an owner whose own composition attaches no surface', async () => {
it('refuses an owner whose own composition attaches no controller', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalTaskService)
@@ -130,7 +130,7 @@ describe('LocalTaskService.start', () => {
// task controls. The second must not inherit the first's open gate.
const withControls = createScope(ctx, {})
const withoutControls = createScope(ctx, {})
await attachSurfaceIn(withControls.ctx)
await attachControllerIn(withControls.ctx)
const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx))
const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx))
@@ -139,19 +139,19 @@ describe('LocalTaskService.start', () => {
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.
.toThrow('no task controller serves this agent')
// An unowned producer has no chain to walk, so only a global controller serves it.
expect(() => ctx.tasks.start(producer().spec))
.toThrow('no control surface serves this agent')
.toThrow('no task controller serves this agent')
})
it('lets a surface attached without a scope serve every owner', async () => {
it('lets a controller 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)
await attachControllerIn(ctx)
const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx))
ctx.agents.register(scoped)
@@ -409,7 +409,7 @@ describe('LocalTaskService.kill', () => {
p.settle({ status: 'killed' })
await tick()
// The listener still fires (telemetry may care), but carries reported: true
// so the notice surface suppresses its redundant "finished".
// so the notice path suppresses its redundant "finished".
expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true })
})
@@ -599,7 +599,7 @@ describe('LocalTaskService owner isolation', () => {
it('rejects an owned registration when no agent registry is mounted', async () => {
const ctx = new Context()
await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
ctx.tasks.attachController('test-controller')
expect(() => ctx.tasks.start(producer({ owner: stubAgent(ctx, 'a') }).spec))
.toThrow('background task ownership requires the agent registry')
// The failed registration mutated nothing: no stored task, counter untouched.
@@ -746,7 +746,7 @@ describe('LocalTaskService owner cleanup', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const tasksFiber = await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
ctx.tasks.attachController('test-controller')
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const ownerCleanupEffects = () => owner.ctx.fiber.getEffects()
@@ -816,10 +816,10 @@ describe('LocalTaskService disposal', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(LocalTaskService)
const surface = await ctx.plugin(Object.assign((inner: Context) => {
inner.tasks.attachSurface('test-surface')
const controller = await ctx.plugin(Object.assign((inner: Context) => {
inner.tasks.attachController('test-controller')
}, { inject: ['tasks'] }))
void surface
void controller
const seen: string[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot.id))
@@ -844,7 +844,7 @@ describe('LocalTaskService disposal', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
ctx.tasks.attachController('test-controller')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: TaskSnapshot[] = []
ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot))
@@ -882,7 +882,7 @@ describe('LocalTaskService disposal', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const tasksFiber = await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
ctx.tasks.attachController('test-controller')
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
let settle!: (outcome: TaskOutcome) => void
@@ -914,7 +914,7 @@ describe('LocalTaskService disposal', () => {
const mount = await standing.ctx.plugin({
inject: ['tasks'],
apply(pluginCtx: Context) {
pluginCtx.tasks.attachSurface('tool-tasks')
pluginCtx.tasks.attachController('tool-tasks')
pluginCtx.tasks.onTaskDone(() => {})
},
})
@@ -925,16 +925,16 @@ describe('LocalTaskService disposal', () => {
await mount.dispose()
expect(() => ctx.tasks.start(producer({ owner }).spec))
.toThrow('no control surface serves this agent')
.toThrow('no task controller serves this agent')
})
it('detaching the last surface re-arms the register fence', async () => {
it('detaching the last controller re-arms the register fence', async () => {
const ctx = new Context()
await ctx.plugin(LocalTaskService)
const detachA1 = ctx.tasks.attachSurface('a')
const detachA2 = ctx.tasks.attachSurface('a') // duplicate name counts independently
const detachA1 = ctx.tasks.attachController('a')
const detachA2 = ctx.tasks.attachController('a') // duplicate name counts independently
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.tasks.attachSurface('b')
inner.tasks.attachController('b')
}, { inject: ['tasks'] }))
detachA1()
@@ -943,7 +943,7 @@ 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 serves this agent')
expect(() => ctx.tasks.start(producer().spec)).toThrow('no task controller serves this agent')
})
})
@@ -1071,7 +1071,7 @@ describe('LocalTaskService teardown change notifications', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(LocalTaskService)
ctx.tasks.attachSurface('test-surface')
ctx.tasks.attachController('test-controller')
// The api-proxy carrier registers from its own stream context, not the
// registry's fiber, so it is still listening when the registry unloads.

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: 17579e417690dd34641e59f66cc8196465d08d68
README.zh.md: 8f6ed21bb8ebcdcda4e0e3799aba5b88e5179393
README.md: 60898e8de8ffa29a823c537ba5f5876b63a03c88
README.zh.md: b726ca85374de68514473130bb3732c45f352cf7

View File

@@ -6,22 +6,22 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService`
## Service contract
- `start(spec): TaskId` validates the control surface, spec, exact live owner, optional positive `outputLimitBytes`, and any provider-owned admission policy before calling the producer's `run()` once. A preflight rejection or starter throw leaves no task id or registered work; successful return commits without another failable step.
- `start(spec): TaskId` validates the attached controller, spec, exact live owner, optional positive `outputLimitBytes`, and any provider-owned admission policy before calling the producer's `run()` once. A preflight rejection or starter throw leaves no task id or registered work; successful return commits without another failable step.
- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks.
- `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks.
- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported.
- `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.
- `onTasksChanged(listener)` observes visible-set changes — registration, every stopping transition (teardown's included, before it awaits a slow producer), settlement, owner-disposal removal, and the emptying service disposal commits — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported. The registration binds to the calling fiber, so an observer mounted outside the registry still sees the disposal emptying.
- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when no attached surface serves the spec's owner.
- `attachController(name)` declares a task controller for its effect lifetime. `start()` fails before producer execution when no attached controller serves the spec's owner.
All three 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.
All three registrations are owner-relative, because one registry serves every composition in the process. A controller 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 controller 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.
`outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A control surface applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it.
`outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A controller applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it.
Implementations also owe the lifecycle semantics of the contract: registrations outlive producer and control-surface fibers, owner and service disposal cancel live work and await compliant producers, and settlement is first-wins — one terminal record, one round of contained listener notification, released waiters.
Implementations also owe the lifecycle semantics of the contract: registrations outlive producer and controller fibers, owner and service disposal cancel live work and await compliant producers, and settlement is first-wins — one terminal record, one round of contained listener notification, released waiters.
See the [task type catalog](../../../docs/subsystems/tasks.md), the [runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md), and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md).

View File

@@ -6,22 +6,22 @@
## 服务约定
- `start(spec): TaskId` 验证控制表层、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 task id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。
- `start(spec): TaskId` 验证已附加的任务控制器、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 task id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。
- `get(id, caller?)``list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。
- `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。
- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。
- `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。
- `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离系统不会等待监听器工作。
- `onTasksChanged(listener)` 观察可见集合的变化——注册、每一次转入 stopping包括 teardown 在等待缓慢生产者之前的那一次、结算、owner 销毁时的移除,以及服务销毁提交的清空——只携带集合发生变化的那个 owner或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。注册绑定的是调用方 fiber因此挂在注册表之外的观察者仍能收到销毁时的清空。
- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。当没有任何已附加的表层服务于 spec 的所有者时,`start()` 会在生产方执行前失败。
- `attachController(name)` 在其 effect 生命周期内声明任务控制器。当没有任何已附加的控制器服务于 spec 的所有者时,`start()` 会在生产方执行前失败。
这三类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的表层或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此未加载任何控制表层的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。
这三类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的控制器或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此未加载任何控制的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。
有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。
`outputLimitBytes` 是生产方拥有的模型呈现策略,会原样携带到快照中。控制表层在添加状态或通知元数据后应用它;注册表不会重写生产方输出,也不会为省略此字段的生产方虚构默认值。
`outputLimitBytes` 是生产方拥有的模型呈现策略,会原样携带到快照中。控制在添加状态或通知元数据后应用它;注册表不会重写生产方输出,也不会为省略此字段的生产方虚构默认值。
实现还必须兑现约定的生命周期语义:注册的存续期长于生产方 fiber 与控制表层 fiberowner 释放和服务释放会取消仍在运行的工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮异常受到隔离的监听器通知,然后释放等待方)。
实现还必须兑现约定的生命周期语义:注册的存续期长于生产方 fiber 与控制 fiberowner 释放和服务释放会取消仍在运行的工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮异常受到隔离的监听器通知,然后释放等待方)。
参见[任务类型目录](../../../docs/subsystems/tasks.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md)。

View File

@@ -39,7 +39,7 @@ declare module '@deepseek-ai/cordis' {
* standard duplicate-service behavior).
*
* Implementations must honor these semantics:
* - Registrations outlive producer and control-surface fibers. Owner and
* - Registrations outlive producer and controller 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
@@ -47,7 +47,7 @@ declare module '@deepseek-ai/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 attached control surface serves the
* - {@link start} refuses work while no attached task controller 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
@@ -153,7 +153,7 @@ export abstract class TaskService extends Service {
* composition's scope sees exactly the agents composed under it.
*
* This is not a superset of {@link onTaskDone}: that one delivers the terminal
* record under first-wins semantics a control surface couples to notice
* record under first-wins semantics a task controller couples to notice
* delivery, while this one carries no delivery meaning and marks nothing
* reported. Listeners are contained and never awaited.
* @param listener - receives the owner whose visible set changed, or
@@ -163,13 +163,13 @@ export abstract class TaskService extends Service {
abstract onTasksChanged(listener: TasksChangedListener): () => void
/**
* Attach an effect-scoped surface that can read and stop tasks. It serves the
* Attach an effect-scoped controller 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.
* owner no attached controller serves.
* @param name - diagnostic label; duplicate names remain independent.
* @returns disposer that detaches this surface.
* @returns disposer that detaches this controller.
*/
abstract attachSurface(name: string): () => void
abstract attachController(name: string): () => void
}
export default TaskService

View File

@@ -1,5 +1,5 @@
/**
* Types shared by task producers, the registry, and control surfaces. The
* Types shared by task producers, the registry, and controllers. The
* service implementation lives in `./index.ts`.
* @module @deepseek-ai/dsh-tasks/types
*/
@@ -50,7 +50,7 @@ export interface TaskStart {
label: string
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including control-surface status metadata.
* output read, including controller status metadata.
*/
outputLimitBytes?: number
/**
@@ -119,7 +119,7 @@ export interface TaskSnapshot {
finishedAt?: number
/**
* True when a kill, read, or wait has reported or committed to report the
* terminal state. Completion surfaces suppress redundant notices when set.
* terminal state. Completion reporters suppress redundant notices when set.
*/
reported: boolean
}

View File

@@ -56,7 +56,7 @@ class StubTaskService extends TaskService {
return () => {}
}
attachSurface(_name: string): () => void {
attachController(_name: string): () => void {
return () => {}
}
}
@@ -66,7 +66,7 @@ describe('TaskService seam', () => {
const ctx = new Context()
await ctx.plugin(StubTaskService)
const detachSurface = ctx.tasks.attachSurface('seam-test')
const detachController = ctx.tasks.attachController('seam-test')
const id = ctx.tasks.start({ kind: 'bash', label: 'sleep 60', run: () => ({ cancel() {}, done: new Promise(() => {}) }) })
expect(id).toBe('bash-1')
expect(ctx.tasks.list()).toHaveLength(1)
@@ -78,7 +78,7 @@ describe('TaskService seam', () => {
detachListener()
const detachChanges = ctx.tasks.onTasksChanged(() => {})
detachChanges()
detachSurface()
detachController()
})
it('loading a second implementation throws (one tasks service per context — cordis standard)', async () => {

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: 4e8872b087bac6576a3b48acbf079d3f06f3a131
README.zh.md: 3beeb1b70c3a757f5935b0621c9648e0a02c7aa5
README.md: 76607351aba6b482817b890056254da737e7b9ed
README.zh.md: 8d27510aa3be3b87ca167d88d85fc374fb1eb4f1

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The model-facing control surface for `ctx.tasks`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the surface required by `ctx.tasks.start()`.
The model-facing controller for `ctx.tasks`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the controller required by `ctx.tasks.start()`.
## Tools
@@ -14,13 +14,13 @@ All three use generic UI cards: `read` for output and list, `execute` for kill.
Their canonical values are `{ text, task }`, `PublicTaskSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', task }`. A public snapshot carries id, kind, label, status/detail, and start/finish times; it deliberately omits `ownerSession` and the internal `reported` notice bit. Native renderers preserve the status and acknowledgement text above.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete Native UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background task <id>` and the `task_output` collection instruction before spending remaining bytes on its variable kind, label, status, detail, and truncation marker. A prepended pre-execute listener captures the caller-visible task before policy, and each task-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks; structured multi-block policy results retain their shape. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete Native UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background task <id>` and the `task_output` collection instruction before spending remaining bytes on its variable kind, label, status, detail, and truncation marker. A prepended pre-execute listener captures the caller-visible task before policy, and each task-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks; structured multi-block policy results retain their shape. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded controller behavior.
## Completion notices
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.
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 controller serves: an agent whose composition loads no `tool-tasks` cannot start background work at all.
## Config
@@ -57,7 +57,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Activation
#### What the model sees
The generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this surface is visible.
The generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this tool set is visible.
#### Token effect
@@ -85,4 +85,4 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Completion notices do not wake idle agents** — callers needing an immediate result must use `task_output`.
- **Stream reads are single-consumer** — independent observers need another runtime API.
- **Unowned tasks have no session fence** — external surfaces must supply caller policy or avoid them.
- **Unowned tasks have no session fence** — external callers must supply policy or avoid them.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
`ctx.tasks` 的面向模型控制表层:三个与 kind 无关的工具、完成通知和一个后台工作提示词区段。加载该插件会附加 `ctx.tasks.start()` 所要求的表层
`ctx.tasks` 的面向模型控制:三个与 kind 无关的工具、完成通知和一个后台工作提示词区段。加载该插件会附加 `ctx.tasks.start()` 所要求的控制器
## 工具
@@ -14,13 +14,13 @@
它们的规范值依次为 `{ text, task }``PublicTaskSnapshot[]``{ outcome: 'cancellation-requested' | 'already-finished', task }`。公共快照携带 id、kind、label、status/detail 及开始/结束时间;它有意省略 `ownerSession` 和内部 `reported` 通知位。原生 renderer 保留上述状态与确认文本。
当生产方提供 `outputLimitBytes` 时,`task_output`、针对已终止任务的 `task_kill` 和完成通知会在添加状态或通知文本后,对完整的原生 UTF-8 结果施加上限。只要能够容纳,读取就会保留输出尾部与控制后缀;有界完成通知则先为 `background task <id>``task_output` 收集指令预留空间,再把剩余字节用于可变的 kind、label、status、detail 与截断标记。一个前置 pre-execute 监听器会在策略运行前捕获调用方可见任务;每个任务控制定义的 final-content 回调会把其生产方上限应用到单文本拒绝、短路、规范化工具或流水线失败、替换和阻止;结构化多块策略结果保持自身形状。已有的生产方截断标记会复用,不会重复添加。省略该字段的生产方保留现有的无界控制表层行为。
当生产方提供 `outputLimitBytes` 时,`task_output`、针对已终止任务的 `task_kill` 和完成通知会在添加状态或通知文本后,对完整的原生 UTF-8 结果施加上限。只要能够容纳,读取就会保留输出尾部与控制后缀;有界完成通知则先为 `background task <id>``task_output` 收集指令预留空间,再把剩余字节用于可变的 kind、label、status、detail 与截断标记。一个前置 pre-execute 监听器会在策略运行前捕获调用方可见任务;每个任务控制定义的 final-content 回调会把其生产方上限应用到单文本拒绝、短路、规范化工具或流水线失败、替换和阻止;结构化多块策略结果保持自身形状。已有的生产方截断标记会复用,不会重复添加。省略该字段的生产方保留现有的无界控制行为。
## 完成通知
一项尚未报告的完成会把 `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 根本无法启动后台工作。
一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent无论挂载了多少 preset一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制服务哪些 agent组合中未加载 `tool-tasks` 的 agent 根本无法启动后台工作。
## 配置
@@ -57,7 +57,7 @@ Track every background task id you start. You are notified in-session when a tas
#### 模型看到的内容
表层可见时,会看到生成的 [`task_output`、`task_list` 和 `task_kill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks)。
工具集可见时,会看到生成的 [`task_output`、`task_list` 和 `task_kill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks)。
#### Token 影响
@@ -85,4 +85,4 @@ Track every background task id you start. You are notified in-session when a tas
- **完成通知不会唤醒空闲 agent**:需要立即获得结果的调用方必须使用 `task_output`
- **流读取只有单一消费方**:独立观察者需要另一套运行时 API。
- **无 owner 的任务没有会话隔离**:外部表层必须提供调用方策略或避开这些任务。
- **无 owner 的任务没有会话隔离**:外部调用方必须提供策略或避开这些任务。

View File

@@ -1,6 +1,6 @@
/**
* Model-facing `task_output`, `task_list`, and `task_kill` tools over
* `ctx.tasks`. Loading the plugin attaches the control surface required by
* `ctx.tasks`. Loading the plugin attaches the controller required by
* producers. It also injects unreported completions as durable context for the
* owner's next request; notices do not wake idle agents.
* @module @deepseek-ai/dsh-tool-tasks
@@ -216,8 +216,8 @@ export function apply(ctx: Context, config: Config): void {
return boundSingleText(result.content, maxBytes)
}
// Producers may start work only while a control surface is attached.
ctx.tasks.attachSurface('tool-tasks')
// Producers may start work only while a controller is attached.
ctx.tasks.attachController('tool-tasks')
// Cross-call guidance follows the bash section and precedes product sections.
ctx.systemPrompt.section({

View File

@@ -82,11 +82,11 @@ function text(result: { content: { type: string; text?: string }[] }): string {
const tick = () => new Promise<void>(r => setTimeout(r, 0))
describe('tool-tasks setup', () => {
it('attaches the control surface on load and detaches it with the fiber', async () => {
it('attaches the task controller on load and detaches it with the fiber', async () => {
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 serves this agent')
expect(() => ctx.tasks.start(producer().spec)).toThrow('no task controller serves this agent')
})
it('rejects a config whose default wait exceeds the cap', async () => {