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:
@@ -238,23 +238,21 @@ export class LocalTaskService extends TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onTaskDone(listener: TaskDoneListener): () => void {
|
onTaskDone(listener: TaskDoneListener): () => void {
|
||||||
const dispose = this.layers.effect(
|
return this.layers.effect(
|
||||||
this.ctx,
|
this.ctx,
|
||||||
layer => layer.listeners.append(listener),
|
layer => layer.listeners.append(listener),
|
||||||
{ label: 'tasks.onTaskDone()' },
|
{ label: 'tasks.onTaskDone()' },
|
||||||
)
|
)
|
||||||
return () => void dispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
attachSurface(name: string): () => void {
|
attachSurface(name: string): () => void {
|
||||||
// One token per call keeps duplicate labels independently disposable.
|
// One token per call keeps duplicate labels independently disposable.
|
||||||
const token = Symbol(name)
|
const token = Symbol(name)
|
||||||
const dispose = this.layers.effect(
|
return this.layers.effect(
|
||||||
this.ctx,
|
this.ctx,
|
||||||
layer => layer.surfaces.append(token),
|
layer => layer.surfaces.append(token),
|
||||||
{ label: 'tasks.attachSurface()' },
|
{ label: 'tasks.attachSurface()' },
|
||||||
)
|
)
|
||||||
return () => void dispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -805,6 +805,30 @@ describe('LocalTaskService disposal', () => {
|
|||||||
expect(ownerEffects()).toHaveLength(0)
|
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 () => {
|
it('detaching the last surface re-arms the register fence', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LocalTaskService)
|
await ctx.plugin(LocalTaskService)
|
||||||
|
|||||||
Reference in New Issue
Block a user