From cf5fbd02b8c6480cd00d2d9631d648eeefe758a7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 16:34:29 +0800 Subject: [PATCH] fix(tasks-local): return the layer disposer directly and cover scoped teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ScopedLayers.effect` already returns an exact `() => void`, so the inherited `() => void dispose()` wrapper voided a void — two lint rules, four errors. The scoped layer's own teardown had no test, which is the registry-contribution disposal contract the testing policy requires and the only path that calls `TaskLayer.isEmpty()`: `ScopedLayers` prunes a scope's layer when its last contribution disposes. The new case mounts one plugin contributing both a surface and a listener into one scope, then unloads it and observes that the agents which joined that scope are refused again. Refs #2141 --- packages/tasks/tasks-local/src/index.ts | 6 ++--- .../tasks/tasks-local/tests/tasks.spec.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 146d3e4ca7..cb7de27ecd 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -238,23 +238,21 @@ export class LocalTaskService extends TaskService { } onTaskDone(listener: TaskDoneListener): () => void { - const dispose = this.layers.effect( + return this.layers.effect( this.ctx, layer => layer.listeners.append(listener), { label: 'tasks.onTaskDone()' }, ) - return () => void dispose() } attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) - const dispose = this.layers.effect( + return this.layers.effect( this.ctx, layer => layer.surfaces.append(token), { label: 'tasks.attachSurface()' }, ) - return () => void dispose() } /** diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index bdaa31d975..6a7eb44d09 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -805,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)