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.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SubagentError } from './error.ts'
|
||||
|
||||
@@ -47,17 +48,6 @@ interface TransactionState {
|
||||
invalidated: boolean
|
||||
}
|
||||
|
||||
/** Package-private setup transaction consumed by the continuation manager. */
|
||||
export interface ActivationSetupTransaction {
|
||||
/**
|
||||
* Reject a batch invalidated by revocation before publication.
|
||||
* @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation.
|
||||
*/
|
||||
assertIntact(): void
|
||||
/** Promote this batch to resident installations. */
|
||||
commit(): void
|
||||
}
|
||||
|
||||
/** Re-read mutable removal state after a contribution may have revoked itself. */
|
||||
function isRemoved(registration: Registration): boolean {
|
||||
return registration.removed
|
||||
@@ -95,9 +85,9 @@ export class SubagentActivationSetupRegistry {
|
||||
/**
|
||||
* Install every live contribution into one unpublished child context.
|
||||
* @param childCtx - the child's unpublished scoped context.
|
||||
* @returns the provisioning transaction.
|
||||
* @returns the provisioning commit consumed at Agent publication.
|
||||
*/
|
||||
apply(childCtx: Context): ActivationSetupTransaction {
|
||||
apply(childCtx: Context): AgentSetupCommit {
|
||||
const state: TransactionState = { installations: [], invalidated: false }
|
||||
try {
|
||||
for (const registration of [...this.registrations]) {
|
||||
@@ -138,15 +128,14 @@ export class SubagentActivationSetupRegistry {
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
assertIntact: () => {
|
||||
if (!state.invalidated) return
|
||||
throw new SubagentError(
|
||||
'a continuable-subagent setup contribution was revoked while this child was being built; '
|
||||
+ 'the child was not established',
|
||||
'ACTIVATION_SETUP_REVOKED',
|
||||
)
|
||||
},
|
||||
commit: () => {
|
||||
if (state.invalidated) {
|
||||
throw new SubagentError(
|
||||
'a continuable-subagent setup contribution was revoked while this child was being built; '
|
||||
+ 'the child was not established',
|
||||
'ACTIVATION_SETUP_REVOKED',
|
||||
)
|
||||
}
|
||||
for (const installation of state.installations) installation.transaction = undefined
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
Agent,
|
||||
AgentHandle,
|
||||
AgentOptions,
|
||||
AgentSetupCommit,
|
||||
CreateAgentOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
@@ -799,19 +800,9 @@ export class SubagentContinuationManager {
|
||||
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
|
||||
// some other owner holds — a duplicate would reject there with rollback.
|
||||
inputs.signal.throwIfAborted()
|
||||
const setup = (childCtx: Context): void => {
|
||||
const setup = (childCtx: Context): AgentSetupCommit => {
|
||||
applyChildComposition(childCtx, inputs.composition)
|
||||
const setupTransaction = this.setupRegistry.apply(childCtx)
|
||||
// Validate and freeze the batch inside the creation callback, before the
|
||||
// factory can publish the session: a revoked contribution must reject
|
||||
// the create/resume call pre-publication, so no persisted session is
|
||||
// ever left behind for a child the manager rejects — rollback only
|
||||
// disposes the live handle, and the persistence seam has no delete, so
|
||||
// a post-publication rejection would leave a resumable ghost child.
|
||||
// Committing here also means a later contribution removal releases the
|
||||
// installation instead of invalidating a child already being established.
|
||||
setupTransaction.assertIntact()
|
||||
setupTransaction.commit()
|
||||
return this.setupRegistry.apply(childCtx)
|
||||
}
|
||||
const observer = this.host.observeActivation(provider, childId, parent)
|
||||
const { create } = inputs
|
||||
@@ -867,9 +858,8 @@ export class SubagentContinuationManager {
|
||||
for (const item of items) activation.accepted.delete(item.message.id)
|
||||
this.wake(activation)
|
||||
})
|
||||
// Setup already validated and committed inside the creation callback;
|
||||
// revocations from here on are immediate live revocation, never
|
||||
// creation invalidation.
|
||||
// Agent creation committed setup at its publication boundary;
|
||||
// revocations from here on are immediate live revocation.
|
||||
// Publish the start edge before any turn can run, so observers see this
|
||||
// epoch before its first request.
|
||||
observer.start(handle.agent)
|
||||
|
||||
@@ -19,8 +19,7 @@ describe('SubagentActivationSetupRegistry', () => {
|
||||
|
||||
const transaction = registry.apply(child.ctx)
|
||||
expect(order).toEqual(['first', 'second'])
|
||||
expect(() => { transaction.assertIntact() }).not.toThrow()
|
||||
transaction.commit()
|
||||
expect(() => { transaction.commit() }).not.toThrow()
|
||||
expect(order).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
@@ -68,7 +67,7 @@ describe('SubagentActivationSetupRegistry', () => {
|
||||
|
||||
remove()
|
||||
expect(disposals).toBe(1)
|
||||
expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/)
|
||||
expect(() => { transaction.commit() }).toThrow(/revoked while this child was being built/)
|
||||
})
|
||||
|
||||
it('catches a contribution revoked inside its own installer', () => {
|
||||
@@ -82,7 +81,7 @@ describe('SubagentActivationSetupRegistry', () => {
|
||||
|
||||
const transaction = registry.apply(childContext().ctx)
|
||||
expect(disposals).toBe(1)
|
||||
expect(() => { transaction.assertIntact() }).toThrow(/revoked/)
|
||||
expect(() => { transaction.commit() }).toThrow(/revoked/)
|
||||
})
|
||||
|
||||
it('attempts every contribution-removal disposer before reporting failures', () => {
|
||||
|
||||
Reference in New Issue
Block a user