소스 검색

fix(agent-loop): preserve standalone inbox registration

_Kerman 1 주 전
부모
커밋
0ed47fe860

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml

@@ -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 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
-2026-07-31-claimed-pre-step-inbox-lifecycle.md: de58747423fe71a7f04cd9354d39e7eb215f048e
-2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 9103e5c6f5197ecb74269cde249d610c0396354f
+2026-07-31-claimed-pre-step-inbox-lifecycle.md: 6a71d0ffc7757046755c5134c477b416053c16fa
+2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: c94b252e45801854bf0fbbe5d253c392154a26ed

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md

@@ -18,7 +18,7 @@ Before every proposed step, the loop's package-internal `ReactLoopInbox` atomica
 
 The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields.
 
-`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and reads the definition registered by `AgentLoop` at service activation. This service lifetime keeps cold Inbox reads available without any live Agent.
+`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` also registers it at service activation; registry reference counting keeps cold Inbox reads available without any live Agent.
 
 The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentLoop` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the generic projection change feed and read the complete Inbox value directly.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
 
-`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并读取 `AgentLoop` 在服务激活时注册的定义。该服务生命周期让没有 live Agent 时的冷 Inbox 读取仍然可用。
+`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前于 agent scope 上注册标准定义。`AgentLoop` 也在服务激活时注册它;注册表通过引用计数,让没有 live Agent 时的冷 Inbox 读取仍然可用。
 
 两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentLoop` 在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用通用投影变更流,直接读取完整的 Inbox 值。
 

+ 2 - 2
packages/core/agent-loop/README.i18n.yaml

@@ -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: baea98aedd37f4f0807648359f2510a67d265744
-README.zh.md: 6ba530b30a3cb25e5483b15e345aa630c4f4fc40
+README.md: da689bcb1e9f847d7cb9b44495b15365581932f2
+README.zh.md: 002f32fdfe3981d25a6943e6914f144fd568a168

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
packages/core/agent-loop/README.md


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
packages/core/agent-loop/README.zh.md


+ 4 - 2
packages/core/agent-loop/src/inbox.ts

@@ -76,7 +76,9 @@ export class ReactLoopInbox implements InboxContract {
     private readonly projections: SessionProjectionRegistry,
     private readonly session: Session,
     private readonly dispatch: AgentEventDispatch,
-  ) {}
+  ) {
+    this.projections.register(inboxProjectionDefinition)
+  }
 
   /** Prompts awaiting individual turns. */
   get nextTurn(): readonly UserMessage[] {
@@ -186,7 +188,7 @@ export class ReactLoopInbox implements InboxContract {
   /** Read the current durable projection state. */
   private current(): InboxState {
     const state = this.projections.stateOf(this.session, 'inbox')
-    /* v8 ignore next -- AgentLoop registers this key before publishing its factory */
+    /* v8 ignore next -- the constructor registers this key before any read */
     if (state === undefined) {
       throw new Error(
         `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`,

+ 2 - 6
packages/core/agent-loop/tests/inbox.spec.ts

@@ -5,7 +5,7 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { UserMessage } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { describe, expect, it } from 'vitest'
-import { inboxProjectionDefinition, ReactLoopInbox } from '../src/inbox.ts'
+import { ReactLoopInbox } from '../src/inbox.ts'
 
 function unsupportedInbox(): Agent['inbox'] {
   const rejectMutation = (): never => {
@@ -48,7 +48,6 @@ async function inboxAgent(rawId: string): Promise<{
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionProjectionRegistry)
-  ctx.sessionProjections.register(inboxProjectionDefinition)
   const session = ctx.sessions.create(SessionId(rawId))
   const agent = stubAgent(rawId, { ctx, session })
   const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
@@ -65,7 +64,6 @@ async function reconstructPersistedInbox(
   const session = ctx.sessions.create(SessionId(rawId))
   populate(session)
   await ctx.plugin(SessionProjectionRegistry)
-  ctx.sessionProjections.register(inboxProjectionDefinition)
   const agent = stubAgent(rawId, { ctx, session })
   const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
   try {
@@ -78,11 +76,10 @@ async function reconstructPersistedInbox(
 }
 
 describe('ReactLoopInbox', () => {
-  it('reads restored pending input from the shared projection', async () => {
+  it('registers the durable projection in its constructor', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(SessionProjectionRegistry)
-    ctx.sessionProjections.register(inboxProjectionDefinition)
     const session = ctx.sessions.create(SessionId('inbox-projection'))
     const pending = createUserMessage({
       content: [{ type: 'text', text: 'pending' }],
@@ -133,7 +130,6 @@ describe('ReactLoopInbox', () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(SessionProjectionRegistry)
-    ctx.sessionProjections.register(inboxProjectionDefinition)
     const parent = ctx.sessions.create(SessionId('inbox-fork-parent'))
     const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent })
     const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent))

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.