소스 검색

fix(session-controller): resume cold sessions for Inbox commands

_Kerman 1 개월 전
부모
커밋
6322bb96e8

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-08-17-durable-web-queue-recovery.md
+2026-08-17-durable-web-queue-recovery.md: 4b8f6a321be6550631763a99e0a130376ab4b081
+2026-08-17-durable-web-queue-recovery.zh.md: c482a97c55af74f90e3d9f6f4ab82bdda3489086

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md

@@ -0,0 +1,29 @@
+# Agent Note: Resume cold sessions for Inbox commands
+
+Status: implemented
+
+English | [中文](2026-08-17-durable-web-queue-recovery.zh.md)
+
+## Problem
+
+Inbox state survives in the session log, but `session.updateQueue` previously looked up only a live Agent. After a Host restart, an ordinary persisted Session remains cold until an operation needs its Agent, so editing or removing a restored pending item incorrectly returned `queue-item-not-found`.
+
+## Decision
+
+`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before reading or mutating its Inbox. A missing persisted Session still maps to `queue-item-not-found`, while other resume failures keep their existing error and subagent ownership keeps the same fence as other Agent operations.
+
+The resolved Agent constructs its Inbox from the registered durable projection. The command therefore reads the restored pending lists and records edits or removals through the existing normalized `agent/inbox/spliced` event. No new session event or on-disk format is introduced.
+
+## Verification
+
+A cold-operation test provides a detached persisted Session with a pending Inbox splice, invokes `session.updateQueue`, and proves that the Session is resumed, the row is removed, and the durable removal splice is appended.
+
+## Alternatives considered
+
+**Treat every missing live Agent as a missing queue item.** Rejected because persistence may still own the ordinary Session and its durable Inbox projection.
+
+**Fold the session log inside `session.updateQueue`.** Rejected because the Inbox projection already owns reconstruction, while the shared Agent resolver owns cold lifecycle setup and preset composition.
+
+## Consequences
+
+Operations on restored Inbox rows use the same preset composition, ownership checks, and durable mutation path as operations on live rows. Reading durable state does not itself require eager Agent recovery; only an explicit command resumes the ordinary Agent.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 为 Inbox 命令恢复冷会话
+
+Status: implemented
+
+[English](2026-08-17-durable-web-queue-recovery.md) | 中文
+
+## 问题
+
+Inbox 状态保存在会话日志中,但 `session.updateQueue` 之前只查找 live Agent。Host 重启后,普通持久 Session 会保持冷状态,直到某项操作需要其 Agent,因此编辑或移除已恢复的待处理项会错误返回 `queue-item-not-found`。
+
+## 决策
+
+`session.updateQueue` 在读取或修改 Inbox 前,通过共享 Agent 解析器解析普通冷 Session。持久 Session 确实不存在时仍映射为 `queue-item-not-found`;其他恢复失败保留原有错误,subagent ownership 也保持与其他 Agent 操作相同的限制。
+
+解析出的 Agent 从已注册的持久投影构建 Inbox。因此,该命令会读取恢复出的待处理列表,并通过既有的规范化 `agent/inbox/spliced` 事件记录编辑或移除。系统不引入新的会话事件或磁盘格式。
+
+## 验证
+
+冷操作测试提供一份带待处理 Inbox splice 的分离持久 Session,调用 `session.updateQueue`,并证明 Session 会被恢复、待处理项会被移除且持久删除 splice 会被追加。
+
+## 考虑过的替代方案
+
+**把所有缺少 live Agent 的情况都当作队列项不存在。** 不予采纳,因为持久层可能仍拥有该普通 Session 及其持久 Inbox 投影。
+
+**在 `session.updateQueue` 内折叠会话日志。** 不予采纳,因为 Inbox 投影已经拥有重建逻辑,而共享 Agent 解析器拥有冷生命周期初始化和 preset 组合。
+
+## 后果
+
+对已恢复 Inbox 项的操作使用与 live 项相同的 preset 组合、所有权检查和持久变更路径。读取持久状态本身不要求提前恢复 Agent;只有显式命令会恢复普通 Agent。

+ 9 - 7
packages/api/session-controller/src/commands.ts

@@ -377,11 +377,11 @@ export class SessionCommandController {
   }
 
   /**
-   * Mutate one still-pending queue occurrence without resuming a cold Agent.
+   * Mutate one still-pending queue occurrence, resuming a cold Agent first.
    * @param request - Session, queue item, and requested mutation.
    * @returns acknowledgement that the queue mutation was applied.
    */
-  updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
+  async updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue> {
     if (request.action.kind === 'edit'
       && request.action.content.some(block => block.type !== 'text')) {
       reject(
@@ -390,13 +390,15 @@ export class SessionCommandController {
         { reason: 'QUEUE_EDIT_NON_TEXT' },
       )
     }
-    const agent = this.ctx.agents.get(request.sessionId)
-    if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
-      rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
-    }
-    if (agent === undefined) {
+    const found = await this.agents.resolveAgent(request.sessionId)
+    if ('error' in found) {
+      if (found.error.code !== 'session-not-found') rejectFailure(found.error)
       reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
     }
+    const { agent } = found
+    if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
+      rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
+    }
     const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
     const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
     const located = nextTurn === undefined

+ 2 - 2
packages/api/session-controller/src/index.ts

@@ -257,12 +257,12 @@ export class SessionController extends TypertRemoteService {
   }
 
   /**
-   * Mutate one still-pending queue occurrence on a live Agent.
+   * Mutate one still-pending queue occurrence, resuming a cold Agent first.
    * @param request - Session, queue item, and requested mutation.
    * @returns acknowledgement that the queue mutation was applied.
    */
   @Remote('updateQueue')
-  updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
+  updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue> {
     return this.commands.updateQueue(request)
   }
 

+ 68 - 0
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -11,6 +11,7 @@ import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import SessionStore from '@deepseek-ai/dsh-session'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
+import InboxService from '@deepseek-ai/dsh-agent/inbox'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
 import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
 import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
@@ -419,6 +420,73 @@ describe('cold history recovery view', () => {
 })
 
 describe('Remote Agent and Session lookup policy', () => {
+  it('resumes a cold session before mutating a restored queue row', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(InboxService)
+    const sessionId = sid('session-cold-queue-mutation')
+    const meta = header(sessionId, 1000)
+    const message = createUserMessage({
+      content: [{ type: 'text', text: 'survives restart' }],
+      source: { kind: 'user' },
+    })
+    const events = [{
+      type: 'agent/inbox/spliced',
+      seq: 0,
+      time: 1001,
+      data: { target: 'next-turn', start: 0, inserted: [message] },
+    }] as SessionEvent[]
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([meta]),
+      inspect: () => Promise.resolve({ meta, events }),
+      locate: () => undefined,
+    } as never)
+    let resumedAgent: Agent | undefined
+    const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
+      const session = ctx.sessions.create(sessionId, {
+        seed: events,
+        meta: { cwd: '/proj', createdAt: meta.createdAt },
+      })
+      resumedAgent = {
+        id: session.id,
+        options: {},
+        session,
+        inbox: undefined as never,
+        status: 'idle',
+        ctx,
+        send() {},
+        followup() {},
+        steer() {},
+        inject() {},
+        cancel() {},
+        runMaintenance: task => task(new AbortController().signal),
+        whenIdle: () => Promise.resolve(),
+      } satisfies Agent
+      Object.assign(resumedAgent, { inbox: ctx.inboxes.create(resumedAgent) })
+      ctx.agents.register(resumedAgent)
+      return { agent: resumedAgent, dispose: () => Promise.resolve() }
+    })
+    const remote = createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/tmp',
+    })
+
+    const response = await remote.updateQueue(request({
+      sessionId,
+      itemId: message.id,
+      action: { kind: 'remove' },
+    }))
+
+    expect(response).toEqual({ ok: true, value: { accepted: true } })
+    expect(resume).toHaveBeenCalledOnce()
+    expect(resumedAgent?.inbox.nextTurn).toEqual([])
+    expect(resumedAgent?.session.events.at(-1)).toMatchObject({
+      type: 'agent/inbox/spliced',
+      data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
+    })
+  })
+
   it('deduplicates a cold resume across Agent and Session parameters', async () => {
     const ctx = new Context()
     await ctx.plugin(TypertRegistry)