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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)`;一次结算也只会抵达其所有者所属组合注册的监听器。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user