Files
deepseek-harness/packages/subagent/subagent/tests/activation-setup-registry.spec.ts
Tianyi Cui b54381f3e7 fix(agent): commit mutable setup at publication
Agent setup may await while a mutable contribution registry changes. The previous subagent path validated and committed its provisioning batch inside the setup callback. A revocation queued after that callback returned therefore treated the installation as resident and released it, even though AgentLoop had not published the child yet. AgentLoop could then admit and announce a child whose required capability had already disappeared.

Introduce AgentSetupCommit as the optional synchronous result of create and resume setup. AgentLoop now awaits setup, invokes that commit with no intervening asynchronous boundary, and only then enters the Session and Agent registries. A commit failure follows the existing private-transaction rollback, so neither identity is published and the caller can reuse the id.

Keep continuable-subagent installations provisional until this publication commit. Contribution removal still releases every installation immediately, but now marks an unpublished batch invalid so its commit rejects with ACTIVATION_SETUP_REVOKED. Once the commit succeeds, later removal remains ordinary live revocation.

Cover create and resume ordering, resume commit rejection and identity reuse, and an assembled microtask revocation that leaves only the parent Agent and Session. Update the public JSDoc, architecture flow, package contracts, current Agent Notes, Chinese counterparts, pairing records, and generated Cordis API to describe the new boundary.

Validated with the four focused Agent/subagent test files (91 tests), the isolated assembled regression, targeted TypeScript project builds, generated Cordis API freshness, export JSDoc verification, scoped translation pairing, Markdown wrapping, and Mermaid parsing.
2026-08-02 20:09:05 +08:00

164 lines
5.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SubagentActivationSetupRegistry from '../src/activation-setup-registry.ts'
/** A child-like scoped context with observable disposal. */
function childContext(): { ctx: Context; close: () => Promise<void> } {
const root = new Context()
const scope = root.plugin(function child() {})
return { ctx: scope.ctx, close: async () => { await scope.dispose() } }
}
describe('SubagentActivationSetupRegistry', () => {
it('installs contributions in registration order and commits them', () => {
const registry = new SubagentActivationSetupRegistry()
const order: string[] = []
registry.register(() => { order.push('first'); return () => order.push('undo-first') })
registry.register(() => { order.push('second'); return () => order.push('undo-second') })
const child = childContext()
const transaction = registry.apply(child.ctx)
expect(order).toEqual(['first', 'second'])
expect(() => { transaction.commit() }).not.toThrow()
expect(order).toEqual(['first', 'second'])
})
it('makes repeated removal and converging ownership idempotent', async () => {
const registry = new SubagentActivationSetupRegistry()
let disposals = 0
const remove = registry.register(() => () => { disposals += 1 })
const child = childContext()
registry.apply(child.ctx).commit()
remove()
remove()
await child.close()
expect(disposals).toBe(1)
})
it('makes the opposite ownership convergence idempotent', async () => {
const registry = new SubagentActivationSetupRegistry()
let disposals = 0
const remove = registry.register(() => () => { disposals += 1 })
const child = childContext()
registry.apply(child.ctx).commit()
await child.close()
remove()
expect(disposals).toBe(1)
})
it('skips a contribution removed before a child is applied', () => {
const registry = new SubagentActivationSetupRegistry()
const installed: string[] = []
const remove = registry.register(() => { installed.push('gone'); return () => {} })
registry.register(() => { installed.push('kept'); return () => {} })
remove()
registry.apply(childContext().ctx).commit()
expect(installed).toEqual(['kept'])
})
it('invalidates a provisioning batch revoked before commit', () => {
const registry = new SubagentActivationSetupRegistry()
let disposals = 0
const remove = registry.register(() => () => { disposals += 1 })
const transaction = registry.apply(childContext().ctx)
remove()
expect(disposals).toBe(1)
expect(() => { transaction.commit() }).toThrow(/revoked while this child was being built/)
})
it('catches a contribution revoked inside its own installer', () => {
const registry = new SubagentActivationSetupRegistry()
let disposals = 0
const self: { remove?: () => void } = {}
self.remove = registry.register(() => {
self.remove?.()
return () => { disposals += 1 }
})
const transaction = registry.apply(childContext().ctx)
expect(disposals).toBe(1)
expect(() => { transaction.commit() }).toThrow(/revoked/)
})
it('attempts every contribution-removal disposer before reporting failures', () => {
const registry = new SubagentActivationSetupRegistry()
const released: string[] = []
let seq = 0
const remove = registry.register(() => {
const id = `child-${++seq}`
return () => {
released.push(id)
if (id === 'child-1') throw new Error('disposer exploded')
}
})
for (const child of [childContext(), childContext(), childContext()]) {
registry.apply(child.ctx).commit()
}
expect(() => { remove() }).toThrow(/failed to release 1 installation\(s\)/)
expect(released).toEqual(['child-1', 'child-2', 'child-3'])
})
it('attempts every child-scope disposer before reporting failures', async () => {
const registry = new SubagentActivationSetupRegistry()
const released: string[] = []
registry.register(() => () => {
released.push('a')
throw new Error('first disposer exploded')
})
registry.register(() => () => { released.push('b') })
const child = childContext()
registry.apply(child.ctx).commit()
await child.close().catch(() => undefined)
expect(released).toEqual(['a', 'b'])
})
it('rolls back earlier installations when a later contribution throws', () => {
const registry = new SubagentActivationSetupRegistry()
const undone: string[] = []
registry.register(() => () => undone.push('first'))
registry.register(() => { throw new Error('boom') })
registry.register(() => () => undone.push('third'))
expect(() => registry.apply(childContext().ctx)).toThrow(/boom/)
expect(undone).toEqual(['first'])
})
it('does not dispose twice when revocation precedes setup rollback', () => {
const registry = new SubagentActivationSetupRegistry()
const disposals: string[] = []
const removeFirst = registry.register(() => () => { disposals.push('first') })
registry.register(() => {
removeFirst()
throw new Error('second failed after revoking the first')
})
expect(() => registry.apply(childContext().ctx)).toThrow(/second failed/)
expect(disposals).toEqual(['first'])
})
it('does not cross-release independent child scopes', async () => {
const registry = new SubagentActivationSetupRegistry()
const disposed: string[] = []
let seq = 0
registry.register(() => {
const id = `child-${++seq}`
return () => disposed.push(id)
})
const first = childContext()
const second = childContext()
registry.apply(first.ctx).commit()
registry.apply(second.ctx).commit()
await first.close()
expect(disposed).toEqual(['child-1'])
await second.close()
expect(disposed).toEqual(['child-1', 'child-2'])
})
})