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:
@@ -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/core/agent-loop/README.md
|
||||
README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6
|
||||
README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817
|
||||
README.md: f146ea3d2cc379303514286c5cfd2833397f23ed
|
||||
README.zh.md: 767fbd420709be05e9a8f85dc8ec6fe03ae6aa66
|
||||
|
||||
@@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
|
||||
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
|
||||
|
||||
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
|
||||
|
||||
@@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
|
||||
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
### 公开 API
|
||||
|
||||
创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。
|
||||
创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。
|
||||
|
||||
调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose(资源释放)或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
|
||||
`AgentLoop` 还实现 `AgentFactory` seam,并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent:
|
||||
|
||||
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。
|
||||
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),使用同一 id 注册 agent,重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup,再执行受回滚保护的发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。
|
||||
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。
|
||||
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),在同一 id 下重建历史,针对全新且尚未发布的 agent 作用域等待 setup,调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。
|
||||
|
||||
配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent(该路径会丢弃 handle)。对于以编程方式创建的 agent,handle 持有者是唯一面向消费方的 teardown 能力;AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。
|
||||
|
||||
|
||||
@@ -558,7 +558,10 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal)
|
||||
const published = (async () => {
|
||||
try {
|
||||
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId)
|
||||
const setupCommit = await raceAbort(
|
||||
options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId,
|
||||
)
|
||||
setupCommit?.commit()
|
||||
return prepared.publish('startup')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
@@ -617,7 +620,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
})
|
||||
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
|
||||
try {
|
||||
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
setupCommit?.commit()
|
||||
return prepared.publish('resume')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
|
||||
@@ -291,6 +291,13 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
return {
|
||||
commit: () => {
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
order.push('setup:commit')
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -304,6 +311,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'setup:commit',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
@@ -359,6 +367,33 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume setup commit rejection publishes nothing and releases the identity', async () => {
|
||||
const sessionId = SessionId('resume-setup-commit-reject')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: () => ({
|
||||
commit: () => { throw new Error('resume setup commit failed') },
|
||||
}),
|
||||
})).rejects.toThrow('resume setup commit failed')
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const retry = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
|
||||
const sessionId = SessionId('resume-setup-owner-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
|
||||
@@ -264,6 +264,13 @@ describe('agent scope lifecycle', () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
return {
|
||||
commit: () => {
|
||||
expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined()
|
||||
order.push('setup:commit')
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
await setupStarted.promise
|
||||
@@ -276,6 +283,7 @@ describe('agent scope lifecycle', () => {
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'setup:commit',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
|
||||
Reference in New Issue
Block a user