Files
deepseek-harness/packages/ui/jsonrpc/src/server.ts
Tianyi Cui d5e894edf4 fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.

Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.

Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.

Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.

Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 21:40:12 +08:00

277 lines
11 KiB
TypeScript

/**
* `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin
* serves to out-of-process SDK clients (e.g. the Python `deepseek_harness`
* package). Requests: `initialize` → `session/prompt`* → `shutdown`.
* Notifications pushed to the host: `session.event` (every durable session
* event, verbatim), `session.finished` (per prompt turn settle),
* `subagent.started` / `subagent.finished` (child-session lineage and run
* outcomes). The server owns only the SDK-facing session map — the harness
* itself is the context the plugin mounts in; plugins, persistence, and
* the LLM adapter set all come from the external `cordis.yml`.
*
* @module @deepseek-ai/dsh-jsonrpc/server
*/
import type { Context } from 'cordis'
import { resolve } from 'node:path'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { JsonRpcTransportPeer } from './transport.ts'
/** Parameters of the `initialize` request (once per process, before any prompt). */
export interface InitializeParams {
/** Working directory recorded on every SDK-created session's header. */
cwd: string
/** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */
model: string
}
/** Result of the `initialize` request: the server's identity for the SDK handshake. */
export interface InitializeResult {
/** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */
serverInfo: { name: string; version: string }
}
/**
* Parameters of a `session/prompt` request: one user turn on one SDK session,
* with at most one in flight per session.
*/
export interface SessionPromptParams {
/** The SDK-side session id; an unknown id lazily creates the agent+session pair. */
sessionId: string
/** The prompt content blocks, sent verbatim as the user message. */
contentBlocks: ContentBlock[]
}
/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */
export interface SessionPromptResult {
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
accepted: true
}
interface SessionRecord {
handle: AgentHandle
lastTurnEnd: TurnEndReason | undefined
activePrompt: boolean
}
interface SubagentRecord {
childSessionId: string
parentSessionId: string | undefined
}
/**
* The SDK server over a booted harness context. Constructing it subscribes to
* the context's `session/event`, `session/created`, `agent/created`, and
* `subagent/end` events and forwards them to the host as notifications; the
* subscriptions live until {@link shutdown}. One instance serves one transport
* peer for the process lifetime — there is no re-`initialize`.
*/
export class HarnessSdkServer {
private cwd = process.cwd()
private model = 'deepseek'
private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>()
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
private readonly subagentSessions = new Map<string, SubagentRecord>()
private readonly disposers: (() => void)[] = []
private shutdownTask: Promise<Record<string, never>> | undefined
private shuttingDown = false
constructor(
private readonly ctx: Context,
private readonly transport: JsonRpcTransportPeer,
) {
this.disposers.push(ctx.on('session/event', (session, event) => {
if (event.type === 'turn/end') {
const rec = this.sessions.get(String(session.id))
if (rec) rec.lastTurnEnd = event.data.reason
}
this.transport.notify('session.event', { sessionId: String(session.id), event })
}))
this.disposers.push(ctx.on('session/created', (session) => {
const parentSession = session.header.parentSession
if (parentSession === undefined) return
this.transport.notify('subagent.started', {
parentSessionId: String(parentSession),
childSessionId: String(session.id),
})
}))
// Cache agent → session lineage on creation: by the time `subagent/end`
// fires the child agent may already be disposed and gone from the registry.
this.disposers.push(ctx.on('agent/created', (agent) => {
this.subagentSessions.set(String(agent.id), {
childSessionId: String(agent.session.id),
parentSessionId: agent.session.header.parentSession === undefined
? undefined
: String(agent.session.header.parentSession),
})
}))
this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => {
const rec = this.subagentSessions.get(String(info.id))
const agent = this.ctx.agents.get(info.id)
const childSessionId = rec?.childSessionId ?? (agent === undefined ? undefined : String(agent.session.id))
const parentSessionId = rec?.parentSessionId ?? (
agent?.session.header.parentSession === undefined ? undefined : String(agent.session.header.parentSession)
)
if (childSessionId === undefined) return
this.transport.notify('subagent.finished', {
provider: info.provider,
agentId: String(info.id),
...(parentSessionId === undefined ? {} : { parentSessionId }),
childSessionId,
status: info.stopReason === 'completed' ? 'ok' : 'error',
stopReason: info.stopReason,
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
})
}))
}
/**
* Handle `initialize`: record the SDK deployment facts (cwd, model) and, when
* no registered adapter serves `params.model`, mount the DeepSeek adapter for
* it (credentials from `$DEEPSEEK_API_KEY`/`$DEEPSEEK_BASE_URL`) — a config
* that already registered an adapter for the model wins.
* @param params - the SDK handshake parameters.
* @returns the server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
this.cwd = resolve(params.cwd)
this.model = params.model
if (!this.llmFiber && !this.hasAdapterFor(this.model)) {
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] })
}
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
}
/**
* Handle `session/prompt`: get-or-create the session's agent, send the
* content as the user message, await turn settle (quiescence), then notify
* `session.finished` with the settled turn's outcome. A session accepts at
* most one prompt at a time; an overlapping request fails immediately while
* other sessions remain independent.
* @param params - the target session id and prompt content.
* @returns `{ accepted: true }` after the turn settled.
*/
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
const rec = await this.getOrCreateSession(params.sessionId)
if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`)
rec.activePrompt = true
try {
rec.lastTurnEnd = undefined
rec.handle.agent.send(params.contentBlocks)
await rec.handle.agent.whenIdle()
const status = this.finishedStatus(rec.lastTurnEnd)
this.transport.notify('session.finished', {
sessionId: params.sessionId,
status,
reason: rec.lastTurnEnd,
})
return { accepted: true }
} finally {
rec.activePrompt = false
}
}
/**
* Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop
* quiescence), unmount the adapter fiber this server mounted (if any), and
* detach the event subscriptions. The CONTEXT stays up — the bin disposes it
* as part of process exit.
* @returns an empty object (the JSON-RPC result).
*/
shutdown(): Promise<Record<string, never>> {
this.shutdownTask ??= this.performShutdown()
return this.shutdownTask
}
private async performShutdown(): Promise<Record<string, never>> {
this.shuttingDown = true
const pendingCreations = [...this.sessionCreations.values()]
await Promise.allSettled(pendingCreations)
this.sessionCreations.clear()
const records = [...this.sessions.values()]
this.sessions.clear()
this.subagentSessions.clear()
const failures: unknown[] = []
while (this.disposers.length > 0) {
try {
this.disposers.pop()?.()
} catch (error) {
failures.push(error)
}
}
const teardownResults = await Promise.allSettled([
...records.map(rec => Promise.resolve().then(() => rec.handle.dispose())),
...(this.llmFiber === undefined ? [] : [Promise.resolve().then(() => this.llmFiber?.dispose())]),
])
this.llmFiber = undefined
failures.push(...teardownResults
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map(result => result.reason as unknown))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'SDK server teardown failed')
return {}
}
/**
* Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a
* JSON-RPC error response) on an unknown method.
* @param method - the JSON-RPC method name.
* @param params - the raw params object from the wire.
* @returns the handler's result, to be serialized as the response.
*/
async handleRequest(method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
switch (method) {
case 'initialize':
return this.initialize(params as unknown as InitializeParams)
case 'session/prompt':
return this.prompt(params as unknown as SessionPromptParams)
case 'shutdown':
return this.shutdown()
default:
throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)
}
}
private async getOrCreateSession(sessionId: string): Promise<SessionRecord> {
if (this.shuttingDown) throw new Error('SDK server is shutting down')
const existing = this.sessions.get(sessionId)
if (existing) return existing
const pending = this.sessionCreations.get(sessionId)
if (pending) return pending
const creation = this.createSession(sessionId)
this.sessionCreations.set(sessionId, creation)
void creation.then(
() => { this.sessionCreations.delete(sessionId) },
() => { this.sessionCreations.delete(sessionId) },
)
return creation
}
private async createSession(sessionId: string): Promise<SessionRecord> {
const handle = await this.ctx.agents.create({
agentId: AgentId(sessionId),
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
agentOptions: { model: this.model },
})
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
this.sessions.set(sessionId, rec)
return rec
}
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
if (!reason) return 'error'
return reason.kind === 'completed' ? 'ok' : 'error'
}
private hasAdapterFor(model: string): boolean {
return this.ctx.get('llm')?.models().includes(model) ?? false
}
}