mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(jsonrpc): drain runtime before protocol exit
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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: f749d6a72b4c32a189a9f848595076457819d9b9
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2b511573bc68e5378279cec8d22ce960af0966e9
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: f3da981c478ef08672a82f23ab9cd42e0f38ebab
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 7db7efde7e555985a541b14a678d550fe5891159
|
||||
|
||||
@@ -27,7 +27,7 @@ Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's t
|
||||
|
||||
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin:
|
||||
|
||||
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering the `shutdown` request it disposes its own fiber, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
|
||||
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
|
||||
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
|
||||
|
||||
Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic.
|
||||
|
||||
@@ -27,7 +27,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
|
||||
|
||||
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答 `shutdown` 请求后 dispose 自身 fiber,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。
|
||||
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并刷新 `shutdown` 响应后 dispose 根运行时以排空持久化,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。
|
||||
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。
|
||||
|
||||
配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——“实际启动的插件由外部 `cordis.yml` 决定”是硬语义。
|
||||
|
||||
@@ -218,16 +218,15 @@ describe('dsh-subagent-dsh-sdk provider', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps accumulated streamed text when the turn is cut short before a full message', async () => {
|
||||
// The fake streams one text-delta chunk and then violates the protocol on
|
||||
// the same pipe; frame order guarantees the chunk was dispatched before
|
||||
// the failure settles, so the accumulated partial text (no complete
|
||||
// assistant/message ever arrived) must survive into the error result.
|
||||
it('does not attribute streamed text when prompt acceptance is malformed', async () => {
|
||||
// The fake streams one text-delta chunk but never returns the MessageId
|
||||
// needed to establish this run's durable inbox receipt. The text therefore
|
||||
// lies outside an owned activity interval and cannot become its output.
|
||||
const ctx = await setup({ FAKE_STREAM_THEN_MALFORMED: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 })
|
||||
const run = await ctx.subagents.start('dsh-sdk', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(text(result.output)).toBe('streamed then cut short')
|
||||
expect(result.output).toEqual([])
|
||||
await run.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -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/ui/jsonrpc/README.md
|
||||
README.md: 976c63ee4f00336cd68e30288e569d514e7ee65b
|
||||
README.zh.md: e7862aaa335c3277863647e0931eb815ef2ccc1a
|
||||
README.md: 7eb0336a770397df280fbaf95a6ad88243d28944
|
||||
README.zh.md: 993d227d464c460d2c1a3b309542a9c59d1fd18c
|
||||
|
||||
@@ -18,7 +18,7 @@ Stdout carries only JSON-RPC frames. The deployment must not compose a stdout lo
|
||||
|
||||
## Shutdown and exit semantics
|
||||
|
||||
The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to quiescence, closes the transport, then exits with code 0. EOF and signal exits belong to the app bin, which disposes the root context. Unloading only this plugin stops serving without exiting the process.
|
||||
The plugin answers `shutdown`, flushes the response, disposes the root context so SDK-owned agents, subscriptions, and persistence reach quiescence, then exits with code 0. EOF and signal exits belong to the app bin, which also disposes the root context. Unloading only this plugin stops serving without exiting the process.
|
||||
|
||||
## Wire notes
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写
|
||||
|
||||
## 关闭与退出语义
|
||||
|
||||
插件响应 `shutdown`,将 SDK 持有的 agent 和订阅 dispose(资源释放)至完全停稳,关闭传输层,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。
|
||||
插件响应 `shutdown`,刷新响应并 dispose(资源释放)根上下文,使 SDK 持有的 agent、订阅和持久化全部停稳,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者也会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。
|
||||
|
||||
## 协议说明
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
|
||||
* whether to load it; see the single-executable Agent Note and package README.
|
||||
* Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
|
||||
* This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin
|
||||
* This plugin answers `shutdown`, disposes the complete root runtime, and exits 0; the app bin
|
||||
* owns EOF and signal exits. Keep named plugin exports with no default export so
|
||||
* Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`.
|
||||
*
|
||||
@@ -40,14 +40,15 @@ export const Config: Schema<JsonRpcConfig> = Schema.object({
|
||||
/**
|
||||
* Serve SDK requests over the configured streams. Effect disposal shuts down
|
||||
* SDK-created agents and closes the transport. A `shutdown` response is flushed
|
||||
* before this plugin's fiber is disposed and the process exits 0; the app bin
|
||||
* before the root runtime is disposed and the process exits 0; the app bin
|
||||
* owns root-context disposal for EOF and signals.
|
||||
*/
|
||||
export function apply(ctx: Context, config: JsonRpcConfig): void {
|
||||
// Cordis applies the schema default before invoking the plugin.
|
||||
const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean }
|
||||
// The later transport callback must dispose this plugin's fiber, not its ambient context.
|
||||
const fiber = ctx.fiber
|
||||
// Protocol shutdown owns the complete runtime process, so it must await the
|
||||
// root lifecycle (including persistence) before exiting.
|
||||
const rootFiber = ctx.root.fiber
|
||||
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
|
||||
const input = config.input ?? process.stdin
|
||||
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
|
||||
@@ -60,12 +61,13 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
|
||||
maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
|
||||
})
|
||||
|
||||
// Share one exit task and attempt flush and disposal independently before exiting.
|
||||
// Share one exit task so racing shutdown requests cannot dispose the root or
|
||||
// exit the process more than once.
|
||||
let exitTask: Promise<void> | undefined
|
||||
const disposeAndExit = (): Promise<void> => {
|
||||
exitTask ??= (async () => {
|
||||
await Promise.allSettled([Promise.resolve().then(() => transport.flush())])
|
||||
await Promise.allSettled([Promise.resolve().then(() => fiber.dispose())])
|
||||
await Promise.allSettled([Promise.resolve().then(() => rootFiber.dispose())])
|
||||
exit(0)
|
||||
})()
|
||||
return exitTask
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as jsonrpc from '../src/index.ts'
|
||||
type WireEvent =
|
||||
| { kind: 'frame'; frame: Record<string, unknown> }
|
||||
| { kind: 'write-complete'; ids: (string | number)[] }
|
||||
| { kind: 'root-disposed' }
|
||||
| { kind: 'exit'; code: number }
|
||||
|
||||
interface ApplyHarness {
|
||||
@@ -99,6 +100,7 @@ async function mountPlugin(
|
||||
output.on('error', (error: Error) => { outputErrors.push(error) })
|
||||
const exit = (code: number): void => { events.push({ kind: 'exit', code }) }
|
||||
|
||||
ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness')
|
||||
const fiber = await ctx.plugin(jsonrpc, { input, output, exit })
|
||||
|
||||
const frames = (): Record<string, unknown>[] =>
|
||||
@@ -229,16 +231,19 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1'))
|
||||
const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2'))
|
||||
const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0)
|
||||
const rootDisposed = harness.events.findIndex(event => event.kind === 'root-disposed')
|
||||
expect(firstResponse).toBeGreaterThanOrEqual(0)
|
||||
expect(secondResponse).toBeGreaterThanOrEqual(0)
|
||||
expect(firstComplete).toBeGreaterThan(firstResponse)
|
||||
expect(secondComplete).toBeGreaterThan(secondResponse)
|
||||
expect(flushComplete).toBeGreaterThan(firstComplete)
|
||||
expect(flushComplete).toBeGreaterThan(secondComplete)
|
||||
expect(exitIndex).toBeGreaterThan(flushComplete)
|
||||
expect(rootDisposed).toBeGreaterThan(flushComplete)
|
||||
expect(exitIndex).toBeGreaterThan(rootDisposed)
|
||||
|
||||
await settle()
|
||||
expect(harness.exits()).toEqual([0])
|
||||
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
|
||||
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
||||
@@ -259,6 +264,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure')
|
||||
await settle()
|
||||
expect(harness.exits()).toEqual([0])
|
||||
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
|
||||
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
|
||||
|
||||
const before = harness.frames().length
|
||||
@@ -284,6 +290,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
})
|
||||
|
||||
await harness.fiber.dispose()
|
||||
expect(harness.events.some(event => event.kind === 'root-disposed')).toBe(false)
|
||||
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
||||
|
||||
Reference in New Issue
Block a user