Session.run() 的 _is_inbox_receipt 守卫 + bundled runtime 的 session id collision 导致同一 sessionId 多轮对话完全失败 #5940
|
Title: Session.run() 的 _is_inbox_receipt 守卫 + bundled runtime 的 session id collision 导致同一 sessionId 多轮对话完全失败 Version: deepseek-harness-sdk 0.1.2rc1 + deepseek-harness-runtime-bin 0.1.2rc1 问题 A:_is_inbox_receipt 守卫过严api.py Session.run() 第 168-181 行要求 session/prompt 后首个通知必须是 agent/inbox/spliced。bundled runtime 在恢复已完成会话时可能不发送此事件,直接发 session.status: idle,导致循环丢弃通知后阻塞。 问题 B(更严重):Bundled runtime 跨进程 session id collisiondeepseek-harness-runtime-bin 0.1.2rc1 在收到 session/prompt 时,若检测到磁盘 session.jsonl 与当前 live session 不一致(不同进程),直接拒绝续接,返回 id collision 错误。这导致任何跨进程的同一 sessionId 复用都失败——而 dsh_agent.py 修复建议Session.run():给 _is_inbox_receipt 加 idle 快速路径 + 超时兜底(见 api.py patch) 临时绕过调用方通过"首轮 SDK + 续轮 node headless"双通道策略绕过,但这不是长久之计——SDK 的流式输出、tool-call 等特性在续轮中丢失。 |
Replies: 3 comments 1 reply
|
Independent service-level reproduction on The test uses two fresh Cordis Context / SDK server instances, real JSONL persistence, and a loopback mock model endpoint:
The failure occurs in server-side session acquisition, before the second prompt receives an inbox receipt. No Python receipt guard was changed. This is a same-process service integration reproduction, not a separate-process or Python-wheel end-to-end test. The existing 32 server tests pass. The added regression fails as expected against unchanged production source. It asserts prior user/assistant history in the next model request once restoration is supported. Should RED regression patch (not a production fix)Run the owning Vitest file with diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts
index d0bb7e1c8..befda899a 100644
--- a/packages/sdk/server/tests/server.spec.ts
+++ b/packages/sdk/server/tests/server.spec.ts
@@ -1192,4 +1192,63 @@ describe('HarnessSdkJsonRpcServer', () => {
await expect(server.shutdown()).rejects.toBe(listenerFailure)
expect(on).toHaveBeenCalledTimes(4)
})
+
+ it('restores persisted history in a fresh SDK runtime', async () => {
+ const storageDir = await mkdtemp(join(tmpdir(), 'dsh-sdk-resume-'))
+ let firstCtx: Context | undefined
+ let secondCtx: Context | undefined
+ try {
+ const llmServer = await mockCompletionServer()
+ vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
+ vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
+ firstCtx = await makeHarness(storageDir)
+ const firstTransport = new FakeTransport()
+ const firstServer = new HarnessSdkJsonRpcServer(firstCtx, firstTransport)
+ await firstServer.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'dsagent-model' })
+ const firstReceipt = await firstServer.prompt({
+ sessionId: 'main',
+ contentBlocks: [{ type: 'text', text: 'first prompt' }],
+ })
+ expect(firstReceipt.messageId).toBeTypeOf('string')
+ await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
+ await vi.waitFor(() => {
+ expect(firstTransport.notifications.findLast(n => n.method === 'session.status')).toEqual({
+ method: 'session.status', params: { sessionId: 'main', status: 'idle' },
+ })
+ })
+
+ // A live owner would exercise a different collision path.
+ await firstServer.shutdown()
+ await firstCtx.fiber.dispose()
+ secondCtx = await makeHarness(storageDir)
+ const stored = await secondCtx.get('sessionPersistence')?.stat(SessionId('main'))
+ expect(stored?.header.id).toBe('main')
+ const secondTransport = new FakeTransport()
+ const secondServer = new HarnessSdkJsonRpcServer(secondCtx, secondTransport)
+ await secondServer.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'dsagent-model' })
+ const secondReceipt = await secondServer.prompt({
+ sessionId: 'main',
+ contentBlocks: [{ type: 'text', text: 'second prompt' }],
+ })
+ expect(secondReceipt.messageId).toBeTypeOf('string')
+ await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) })
+ await vi.waitFor(() => {
+ expect(secondTransport.notifications.findLast(n => n.method === 'session.status')).toEqual({
+ method: 'session.status', params: { sessionId: 'main', status: 'idle' },
+ })
+ })
+ const body = llmServer.requests[1] as { messages: { role: string }[] }
+ expect(body.messages.map(message => message.role)).toContain('assistant')
+ expect(body.messages.filter(message => message.role === 'user')).toHaveLength(2)
+ expect(JSON.stringify(body.messages)).toContain('first prompt')
+ expect(JSON.stringify(body.messages)).toContain('second prompt')
+ await secondServer.shutdown()
+ } finally {
+ const cleanup = await Promise.allSettled([firstCtx?.fiber.dispose(), secondCtx?.fiber.dispose()])
+ await rm(storageDir, { recursive: true, force: true })
+ const errors = cleanup.flatMap(result => result.status === 'rejected' ? [result.reason] : [])
+ if (errors.length > 0) throw new AggregateError(errors, 'SDK test context cleanup failed')
+ }
+ })
+
}) |
|
建议按隐式加载实现,即第2次继续对话时,只有复用了之前会话相同的sessionId,即自动加载之前的历史会话消息即可;和web交互的逻辑保持一致,加载历史消息时,根据上下文窗口大小设置百分比,自动压缩历史会话,避免上下文超出窗口大小。 你们之前的原则:同一文件不能被2个进程同时访问,这个限制确实必要的。所以我们使用过程中,肯定是保持串行使用同一个sessionId的方式的,此方式也绝对不违反你们的原则。 |


Independent service-level reproduction on
c389f96bf3a9b6807cb71ed6bdad5849be0df6d8(macOS arm64, Node 22.23.1, Vitest 4.1.8).The test uses two fresh Cordis Context / SDK server instances, real JSONL persistence, and a loopback mock model endpoint:
SessionAlreadyExistsError: session "main" already existsfromJsonlSessionPersistence.create.The failure occurs in server-side session acquisition, before the second prompt receives an inbox receipt. No Python receipt guard was changed. Th…