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:
Tianyi Cui
2026-08-02 20:09:05 +08:00
parent dbe053fe08
commit b54381f3e7
28 changed files with 198 additions and 106 deletions

View File

@@ -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
},
}

View File

@@ -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)

View File

@@ -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', () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md
README.md: c1cff4d023e35ff246e592f58b0c85c8a10f327a
README.zh.md: 167a6338e8db9fbb5efce7037392f7c75e48f116
README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58
README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8

View File

@@ -58,7 +58,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del
## Known Limitations and Deferred Work
- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication.
- **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary.
- **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report.
- **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary.

View File

@@ -58,7 +58,6 @@
## 已知限制与暂缓事项
- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()``ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。
- **父级可能在宿主启动 dispose 后继续接受报告**`AgentHandle.dispose()` 会先取消并等待完全停稳然后才撤销作用域并离开注册表它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript但该父级不会在本进程中处理它。对于由延续管理器拥有的父级管理器的准入边界会在整棵子树拆卸期间拒绝该上报。
- **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议也不保证恰好一次。任一侧记录接受后若进程失败结果都不明确外部重试可能产生重复上报。
- **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。

View File

@@ -350,6 +350,34 @@ describe('dsh-tool-subagent-report', () => {
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
})
it('rolls back materialization when setup revocation lands before publication', async () => {
const { ctx, parent } = await setup({ load: false })
const self: { revoke?: () => void } = {}
let installed = false
self.revoke = ctx.subagents.registerContinuableSetup(() => {
installed = true
queueMicrotask(() => { self.revoke?.() })
return () => { installed = false }
})
const announced: SessionId[] = []
const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) })
await expect(ctx.subagents.startContinuable({
provider: 'spawn',
label: 'revoked child',
request: {
prompt: [{ type: 'text', text: 'revoked child' }],
parent,
},
signal: testSignal,
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
removeListener()
expect(installed).toBe(false)
expect(announced).toEqual([])
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
expect(ctx.sessions.list()).toEqual([parent.session])
})
it('accepts a report into a host-disposing but still-registered parent', async () => {
const { ctx } = await setup()
const parentHandle = await ctx.agents.create({