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:
Tianyi Cui
2026-08-02 12:27:55 +08:00
parent 2a3a8ff66d
commit 42ee4e22de
2 changed files with 24 additions and 3 deletions

View File

@@ -327,6 +327,15 @@ describe('dsh-tool-subagent-report', () => {
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({
provider: 'spawn',
label: 'racing child',
@@ -336,6 +345,8 @@ describe('dsh-tool-subagent-report', () => {
},
signal: testSignal,
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
removeListener()
expect(announced).toEqual([])
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
})