fix(tasks-local): return the layer disposer directly and cover scoped teardown

`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
This commit is contained in:
Yichen Jiang
2026-08-10 16:34:29 +08:00
parent 109059429a
commit cf5fbd02b8
2 changed files with 26 additions and 4 deletions
+2 -4
View File
@@ -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()
}
/**
@@ -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)