fix(subagent): validate setup transactions before agent publication
`materialize` ran `setupTransaction.assertIntact()` only after `ctx.agents.create()/resume()` resolved — but the factory publishes `session/created` (and the persistence backend writes the descriptor seed) inside that call, and `rollbackUnpublished()` only disposes the live handle; the persistence seam has no delete. A setup contribution revoked during construction therefore left a durable ghost: `startContinuable()` rejected with `ACTIVATION_SETUP_REVOKED` and returned no child id, yet `list_agents` surfaced a persisted `continuable` child whose log carries a valid descriptor — so a later `send_message` could cold-resume a child the deployment had explicitly refused to establish. Move the validation into the creation callback, before the factory can publish: `assertIntact()` then rejects the create/resume call itself, so no session is ever persisted for a rejected child. Commit the batch in the same callback so a later contribution removal releases the installation instead of invalidating a child already being established (live revocation, matching the resident semantics). Pins the rollback regression test to assert that no `session/created` is ever announced for the rejected child (the parent is created before the listener registers), in addition to the existing registry assertion.
This commit is contained in:
@@ -804,6 +804,16 @@ export class SubagentContinuationManager {
|
|||||||
const setup = (childCtx: Context): void => {
|
const setup = (childCtx: Context): void => {
|
||||||
applyChildComposition(childCtx, inputs.composition)
|
applyChildComposition(childCtx, inputs.composition)
|
||||||
setupTransaction = this.setupRegistry.apply(childCtx)
|
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()
|
||||||
}
|
}
|
||||||
const observer = this.host.observeActivation(provider, childId, parent)
|
const observer = this.host.observeActivation(provider, childId, parent)
|
||||||
const { create } = inputs
|
const { create } = inputs
|
||||||
@@ -842,7 +852,6 @@ export class SubagentContinuationManager {
|
|||||||
try {
|
try {
|
||||||
inputs.signal.throwIfAborted()
|
inputs.signal.throwIfAborted()
|
||||||
this.assertAdmitting(parent)
|
this.assertAdmitting(parent)
|
||||||
setupTransaction.assertIntact()
|
|
||||||
this.acquireOwnership(parent, childId)
|
this.acquireOwnership(parent, childId)
|
||||||
// Every accepted id leaves the inbox exactly once, through dequeue or
|
// Every accepted id leaves the inbox exactly once, through dequeue or
|
||||||
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
|
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
|
||||||
@@ -860,8 +869,9 @@ export class SubagentContinuationManager {
|
|||||||
for (const item of items) activation.accepted.delete(item.message.id)
|
for (const item of items) activation.accepted.delete(item.message.id)
|
||||||
this.wake(activation)
|
this.wake(activation)
|
||||||
})
|
})
|
||||||
// Resident setup revokes live from here instead of invalidating creation.
|
// Setup already validated and committed inside the creation callback;
|
||||||
setupTransaction.commit()
|
// revocations from here on are immediate live revocation, never
|
||||||
|
// creation invalidation.
|
||||||
// Publish the start edge before any turn can run, so observers see this
|
// Publish the start edge before any turn can run, so observers see this
|
||||||
// epoch before its first request.
|
// epoch before its first request.
|
||||||
observer.start(handle.agent)
|
observer.start(handle.agent)
|
||||||
|
|||||||
@@ -327,6 +327,15 @@ describe('dsh-tool-subagent-report', () => {
|
|||||||
return dispose
|
return dispose
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// No session may be announced for the rejected child: the setup
|
||||||
|
// validation must reject inside the creation callback, before the factory
|
||||||
|
// publishes — a post-publication rejection would persist a resumable
|
||||||
|
// ghost that `list_agents` surfaces and `send_message` can resurrect.
|
||||||
|
// The parent was created inside setup(), so any later announcement is the
|
||||||
|
// rejected child's.
|
||||||
|
const announced: SessionId[] = []
|
||||||
|
const listener = (session: { id: SessionId }): void => { announced.push(session.id) }
|
||||||
|
const removeListener = ctx.on('session/created', listener)
|
||||||
await expect(ctx.subagents.startContinuable({
|
await expect(ctx.subagents.startContinuable({
|
||||||
provider: 'spawn',
|
provider: 'spawn',
|
||||||
label: 'racing child',
|
label: 'racing child',
|
||||||
@@ -336,6 +345,8 @@ describe('dsh-tool-subagent-report', () => {
|
|||||||
},
|
},
|
||||||
signal: testSignal,
|
signal: testSignal,
|
||||||
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
|
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
|
||||||
|
removeListener()
|
||||||
|
expect(announced).toEqual([])
|
||||||
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
|
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user