mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(sdk-minimal): checkpoint sessions before dispatch
This commit is contained in:
1 parent
147fee4186
commit
f62182e577
19 files changed
+236
-4
No files matched your search
@@ -0,0 +1,71 @@
|
||||
/** The shipped minimal profile must stop model dispatch when a durable checkpoint fails. */
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
|
||||
const dshBin = fileURLToPath(new URL('../../../lib/bin.js', import.meta.url))
|
||||
const fixture = fileURLToPath(new URL('./fixtures/checkpoint-failure.mjs', import.meta.url))
|
||||
|
||||
describe('sdk-minimal durability checkpoints', () => {
|
||||
it.each([false, true])('checkpoint storage failure with policy disabled=%s', async (disabled) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-checkpoint-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
let modelRequests = 0
|
||||
const server = createServer((request, response) => {
|
||||
request.resume()
|
||||
request.on('end', () => {
|
||||
modelRequests += 1
|
||||
const events = [
|
||||
{ type: 'message_start', message: { id: 'checkpoint-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } },
|
||||
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: 'OK' } },
|
||||
{ type: 'content_block_stop', index: 0 },
|
||||
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
|
||||
{ type: 'message_stop' },
|
||||
]
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''))
|
||||
})
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model server has no TCP address')
|
||||
const patch = join(root, 'checkpoint.patch.yml')
|
||||
await writeFile(patch, JSON.stringify([
|
||||
{ id: 'session-log-deepseek', config: { enabled: false } },
|
||||
{ insert: [{ id: 'checkpoint-failure', name: fixture }] },
|
||||
...disabled ? [{ id: 'session-checkpoint-policy', disabled: true }] : [],
|
||||
]))
|
||||
const harness = new DeepSeekHarness({
|
||||
dshBin, profile: 'sdk-minimal', dshHome: join(root, 'home'),
|
||||
cwd: root, processCwd: root, patches: [patch],
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-checkpoint-test', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` },
|
||||
})
|
||||
onTestFinished(async () => { await harness.close() })
|
||||
const result = await harness.run('Reply OK without calling tools.')
|
||||
expect(modelRequests).toBe(disabled ? 1 : 0)
|
||||
const terminal = result.events.findLast(event => event.type === 'turn/end')
|
||||
expect(terminal).toMatchObject({ data: { reason: { kind: disabled ? 'completed' : 'error' } } })
|
||||
if (disabled) {
|
||||
expect(result.finalResponse).toBe('OK')
|
||||
} else {
|
||||
expect(terminal).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: { message: 'sdk checkpoint persistence unavailable' } } },
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Reject durable checkpoints without changing background JSONL writes. */
|
||||
export const name = 'sdk-checkpoint-failure'
|
||||
export const inject = ['sessionPersistence']
|
||||
|
||||
/**
|
||||
* Install a storage-failure signal at the public session flush event.
|
||||
* @param ctx - Plugin context owning the flush listener.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
ctx.on('session/flush', () => {
|
||||
throw new Error('sdk checkpoint persistence unavailable')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/sdk-minimal-checkpoints/guide.md
|
||||
/:
|
||||
en: c81d35a7d1605742
|
||||
zh: 49a149c848fdbb35
|
||||
/sdk-minimal-requires-durable-checkpoints:
|
||||
en: 353b6975265e5e07
|
||||
zh: b91ad5afa831b810
|
||||
/sdk-minimal-requires-durable-checkpoints/change:
|
||||
en: 3ba980ee74955e34
|
||||
zh: 495fe6c0dd9001bf
|
||||
/sdk-minimal-requires-durable-checkpoints/migration:
|
||||
en: 43c3d55a0fa7a562
|
||||
zh: f2b087fb1387b517
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The sdk-minimal profile now stops model and tool dispatch when session persistence cannot complete a checkpoint."
|
||||
---
|
||||
|
||||
# SDK-minimal requires durable checkpoints
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
The `sdk-minimal` profile enables `session-checkpoint-policy`. It waits for session history to flush before model requests, top-level tool execution, and agent steps. Previously these operations could proceed while JSONL writes were buffered; persistence errors could remain background warnings. A checkpoint failure now stops the dependent operation. First-use file-lock initialization is paid before the first model request, and checkpoint writes contribute to request latency. This does not change the Session format or guarantee that forced termination preserves the final unflushed response.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Keep the selected `dsh_home` writable and allow request deadlines to cover storage initialization and checkpoint writes. A longer shutdown timeout alone does not affect a pre-request checkpoint.
|
||||
2. Check profile and invocation patches for overrides of `session-checkpoint-policy`. To receive the durability policy, retain its enabled default alongside `sessions`.
|
||||
3. Run a short SDK turn, close normally, and use `resume_session()` with the same home and session ID to verify restoration. Investigate checkpoint failures before retrying model or tool work; the operation does not run until the checkpoint succeeds.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "sdk-minimal profile 现在会在会话持久化无法完成检查点时停止模型和工具调度。"
|
||||
---
|
||||
|
||||
# SDK-minimal 要求持久化检查点
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`sdk-minimal` profile 启用 `session-checkpoint-policy`,在模型请求、顶层工具执行和 agent step 之前等待会话历史刷盘。此前,JSONL 写入尚在缓冲时这些操作也可以继续,持久化错误可能只记录为后台警告。现在,检查点失败会停止依赖它的操作。首次文件锁初始化发生在第一次模型请求之前,检查点写入也计入请求延迟。此变更不修改 Session 格式,也不保证强制终止时保留最后尚未刷盘的回复。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 确保选定的 `dsh_home` 可写,并让请求超时覆盖存储初始化和检查点写入。仅延长关闭超时不会影响请求前的检查点。
|
||||
2. 检查 profile 和调用级 patch 是否覆盖了 `session-checkpoint-policy`。要使用此持久化策略,应在 `sessions` 旁保留其默认启用状态。
|
||||
3. 运行一轮 SDK 短对话,正常关闭,再使用相同 home 和会话 ID 调用 `resume_session()` 验证恢复。重试模型或工具操作前,先排查检查点失败;只有检查点成功后,操作才会执行。
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-sdk-minimal/use-this-package:
|
||||
en: 39483eef4a61f212
|
||||
zh: 62d82f46a04e07fe
|
||||
en: 44072e053b253924
|
||||
zh: fecb1b7986ac05d4
|
||||
/deepseek-ai-dsh-sdk-minimal/understand-the-implementation:
|
||||
en: 5db5d8f041867958
|
||||
zh: bff5ef3a2e140712
|
||||
|
||||
@@ -38,6 +38,8 @@ Use `dsh plugin --profile sdk-minimal` to manage persistent external dependencie
|
||||
|
||||
The profile mounts exactly one persistent shell stack: Bash on Linux and macOS, or PowerShell on Windows. Both stacks use a 300-second timeout and one owner-scoped terminal; the other platform's rows remain disabled.
|
||||
|
||||
[Session checkpoints](../../session/session-checkpoint-policy/README.md) flush history before each model request, top-level tool execution, and agent step. Dispatch waits for the flush and stops if persistence fails, so the first model request also waits for native file-lock initialization. The final response still depends on background writes or successful shutdown; checkpoints do not make forced termination safe for unflushed events.
|
||||
|
||||
Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains the shell, `read_image`, and `working_directory` tools.
|
||||
|
||||
-----
|
||||
|
||||
@@ -38,6 +38,8 @@ dsh --profile sdk-minimal
|
||||
|
||||
该 profile 只挂载一套持久 shell:Linux 和 macOS 使用 Bash,Windows 使用 PowerShell。两套配置都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。
|
||||
|
||||
[Session 检查点](../../session/session-checkpoint-policy/README.zh.md)会在每次模型请求、顶层工具执行和 agent step 之前刷盘。调度会等待刷盘完成,持久化失败时停止,因此第一次模型请求也会等待原生文件锁初始化。最后一段回复仍依赖后台写入或成功关闭来保存;检查点不能保证强制终止时尚未刷盘的事件不丢失。
|
||||
|
||||
与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍为 shell、`read_image` 与 `working_directory` 三个工具。
|
||||
|
||||
-----
|
||||
|
||||
@@ -170,3 +170,6 @@
|
||||
config:
|
||||
root: !!js dshHomePath('sessions')
|
||||
compression: none
|
||||
|
||||
- id: session-checkpoint-policy
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
@@ -52,6 +52,7 @@
|
||||
"@deepseek-ai/dsh-sdk-app": "workspace:*",
|
||||
"@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-log-deepseek": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('dsh-sdk-minimal bundle', () => {
|
||||
['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
|
||||
['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
|
||||
['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
|
||||
['session-checkpoint-policy', '@deepseek-ai/dsh-session-checkpoint-policy'],
|
||||
])
|
||||
expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' })
|
||||
expect(rows.find(row => row.id === 'sdk-jsonrpc-server')).toMatchObject({
|
||||
|
||||
Generated
+3
@@ -2652,6 +2652,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-checkpoint-policy':
|
||||
specifier: workspace:*
|
||||
version: link:../../session/session-checkpoint-policy
|
||||
'@deepseek-ai/dsh-session-log-deepseek':
|
||||
specifier: workspace:*
|
||||
version: link:../../session/session-log-deepseek
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 37884fe76e167a0e
|
||||
zh: 4b6e50768c4bb945
|
||||
/python-contributor-workflows/run-against-node-source:
|
||||
en: 7430d9e6b3d02ddc
|
||||
zh: 9044e6be91d0ad01
|
||||
en: 940c941d189a8248
|
||||
zh: 321f5183383d4a12
|
||||
/python-contributor-workflows/build-distributions:
|
||||
en: 4aa941562dba5482
|
||||
zh: 50543a92b3d0cf04
|
||||
|
||||
@@ -66,6 +66,14 @@ Repository contributors can select either development route; both execute the no
|
||||
|
||||
`python/sdk/tests/manual_sdk_agent_smoke.py` uses the internal `_launch_args` test adapter to exercise the unbuilt TypeScript CLI under tsx. Arbitrary argv replacement is intentionally absent from the public SDK.
|
||||
|
||||
The manual [shutdown example](sdk/tests/manual_sdk_shutdown_smoke.py) uses ordinary SDK calls: run one short real-model turn, close immediately with the default 1-second budget, then open another runtime and restore the same session. Install the SDK/runtime and set `DEEPSEEK_API_KEY` before running:
|
||||
|
||||
```sh
|
||||
python python/sdk/tests/manual_sdk_shutdown_smoke.py
|
||||
```
|
||||
|
||||
The example prints the close duration and history files, retains its temporary directory, and lets restoration failures raise normally. Recovery uses a separate 30-second shutdown budget. Reproduction depends on the host and first-turn timing; successful restoration does not establish a shutdown latency bound. This manual example is not collected by pytest.
|
||||
|
||||
## Build distributions
|
||||
|
||||
The root `package.json` version is authoritative for both Python distributions. The staging script injects that version into both wheels and pins the SDK to the same `deepseek-harness-runtime-bin` version.
|
||||
|
||||
@@ -66,6 +66,14 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/test-dsh-home") as harness:
|
||||
|
||||
`python/sdk/tests/manual_sdk_agent_smoke.py` 使用内部 `_launch_args` 测试适配器,通过 tsx 验证未构建的 TypeScript CLI。公开 SDK 刻意不提供任意 argv 替换。
|
||||
|
||||
手动[关闭示例](sdk/tests/manual_sdk_shutdown_smoke.py)只使用普通 SDK 调用:运行一轮真实模型短对话,以默认 1 秒预算立即关闭,再启动另一个运行时恢复同一会话。安装 SDK/运行时并设置 `DEEPSEEK_API_KEY` 后运行:
|
||||
|
||||
```sh
|
||||
python python/sdk/tests/manual_sdk_shutdown_smoke.py
|
||||
```
|
||||
|
||||
示例打印关闭耗时和历史文件列表,保留临时目录,并让恢复失败正常抛出异常。恢复进程使用独立的 30 秒关闭预算。能否复现取决于主机和首轮耗时,恢复成功不能确定关闭耗时上限。pytest 不收集此手动示例。
|
||||
|
||||
## 构建分发包
|
||||
|
||||
根目录 `package.json` 的版本是两个 Python 分发包的权威版本。暂存脚本会将该版本注入两个 wheel 包,并将 SDK 固定到同版本的 `deepseek-harness-runtime-bin`。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Run a short SDK session, close immediately, and try restoring its history.
|
||||
|
||||
Requires an installed SDK/runtime and DEEPSEEK_API_KEY. Run this file directly.
|
||||
The temporary Harness home is retained for inspection.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from tempfile import mkdtemp
|
||||
from time import monotonic
|
||||
|
||||
from deepseek_harness import DeepSeekHarness, DeepSeekHarnessConfig
|
||||
|
||||
root = Path(mkdtemp(prefix="dsh-shutdown-"))
|
||||
config = DeepSeekHarnessConfig(
|
||||
profile="sdk-minimal",
|
||||
dsh_home=str(root / "home"),
|
||||
cwd=str(root),
|
||||
shutdown_timeout_seconds=1.0,
|
||||
)
|
||||
print("Test directory:", root)
|
||||
|
||||
with DeepSeekHarness(config) as harness:
|
||||
result = harness.run("只回复 OK,不要调用工具。")
|
||||
started = monotonic()
|
||||
|
||||
print(f"close() took {monotonic() - started:.3f}s")
|
||||
print("Reply:", result.final_response)
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Model turn failed ({result.finish_reason}); check credentials and the session log.")
|
||||
print("History files:", list((root / "home" / "sessions").rglob("*.jsonl")))
|
||||
|
||||
# Recovery gets a separate budget so its own shutdown does not repeat the test.
|
||||
config.shutdown_timeout_seconds = 30.0
|
||||
with DeepSeekHarness(config) as harness:
|
||||
harness.resume_session(result.session_id)
|
||||
print("History restored successfully:", result.session_id)
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Require a live-session checkpoint before the recorded minimal-profile model requests. */
|
||||
export const name = 'minimal-checkpoint-observer'
|
||||
export const inject = ['llm', 'sessions', 'sessionPersistence']
|
||||
|
||||
/**
|
||||
* Observe checkpoints without flushing or altering the recorded model transcript.
|
||||
* @param ctx - Plugin context owning the checkpoint and model listeners.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const checkpointed = new WeakSet()
|
||||
ctx.on('session/flush', session => { checkpointed.add(session) })
|
||||
ctx.on('llm/stream', async function* (options, next) {
|
||||
const session = options.sessionId === undefined ? undefined : ctx.sessions.get(options.sessionId)
|
||||
if (session !== undefined) {
|
||||
if (!checkpointed.has(session)) throw new Error('sdk-minimal dispatched without a session checkpoint')
|
||||
const handle = await ctx.sessionPersistence.open(session.id, 'read')
|
||||
try {
|
||||
const { events } = await handle.read()
|
||||
if (events.length === 0) throw new Error('sdk-minimal dispatched before its history was persisted')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
yield* next()
|
||||
})
|
||||
}
|
||||
@@ -2,3 +2,7 @@
|
||||
- id: session-log-deepseek
|
||||
config:
|
||||
enabled: false
|
||||
|
||||
- insert:
|
||||
- id: minimal-checkpoint-observer
|
||||
name: './checkpoint-observer.mjs'
|
||||
@@ -952,6 +952,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
|
||||
const actualContext = contextOf(ordered, cwd)
|
||||
if (scenario.name === 'minimal-image-compaction') {
|
||||
const events = results.flatMap(result => result.events)
|
||||
const failures = events.filter(event => event.type === 'turn/end' && event.data.reason.kind === 'error')
|
||||
expect(failures, 'minimal profile completes its checkpointed turn').toEqual([])
|
||||
const image = events.find(event => event.type === 'tool/result')
|
||||
expect(image).toMatchObject({ data: { message: {
|
||||
toolCallId: 'minimal-image-read',
|
||||
|
||||
Reference in new issue
Block a user