mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
In-place port of dsh-workflow-vm from the in-process node:vm execution to one worker thread per run (the workflow-workerthread engine of PR #215, adopted as THE engine): the script's vm context moves inside the worker, agent() bridges to ctx.subagents over the message port (host.ts/protocol.ts/session.ts/worker.ts are new; runtime.ts loses the abandon channel — the host's grace timer force-settles and TERMINATES instead), start() pre-parses the body host-side to keep the seam's synchronous SCRIPT_PARSE throw, and a ready→go handshake keeps a run cancelled before start from ever executing the body. start() no longer blocks the host, termination is real, and the value boundary is serialization by construction. The package keeps its name until the follow-up rename commit; scripts see the identical hook surface, and the seam-contract tests hardened ahead of this swap pass unchanged. The run and child-RPC surfaces are class-shaped rather than literal bundles: WorkerRun IMPLEMENTS the seam's WorkflowRun (id/meta are its own clone, separate from event payloads') and start() returns the instance directly — interface parity with the seam is compiler-checked; worker-side, ChildRpcBridge (implements ChildPort; callId allocation + pending book-keeping settled by onChild* entry points) and RpcChildHandle (every member an RPC keyed by its callId) carry names in stacks. ChildPort's method is startAgent — it names what it starts, matching the script-side agent() hook and the agentsStarted / workflow/agent-* vocabulary; the Child* type names deliberately stay (the worker side is cordis- and subagent-free; these are reduced JSON projections, not the seam's types). Review findings from the reference PR are folded in rather than re-introduced: - cancel() drives BOTH child-cancel channels host-side: the request signal aborts AND each registered child's explicit cancel() is called — a worker wedged in a synchronous spin cannot relay its own ChildCancel RPCs (regression: cancel-only provider + wedged worker). - All host warn paths render through the total renderThrown; a child dispose() rejecting a value whose coercion throws still acks ChildDisposed instead of wedging the script's finally (regression). - built-worker.e2e.ts is wired into builtBinSmokeGate and the AGENTS.md CI sequence — the built lib/worker.js resolution contract now runs in an automated gate. - workflow/end payload pinned on the worker-death path (with the cancelled and grace-force-settle pins riding the ported spec). - Real-Worker scripted timing budgets widened (50-300ms → 150-1000ms) for starved CI hosts. Workspace plumbing: the "./worker" subpath export sanctions the second runtime bundle (check-workspace-constraints), tsdown builds two single-entry passes, tsx becomes a devDependency for the unbuilt worker spawn.
103 lines
4.3 KiB
TypeScript
103 lines
4.3 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import LlmService from '@deepseek-ai/dsh-llm'
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
|
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
|
import SubagentService from '@deepseek-ai/dsh-subagent'
|
|
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
|
|
import WorkerWorkflowEngine from '../src/index.ts'
|
|
|
|
/**
|
|
* With-key e2e: a REAL script in a REAL worker thread
|
|
* drives REAL spawn children against the live DeepSeek API — one plain child
|
|
* and one schema'd child through the real structured-output runtime — and
|
|
* the run's value, events, and child sessions are asserted from the outside
|
|
* (never the script's self-report alone). Key-gated (self-skips without
|
|
* DEEPSEEK_API_KEY).
|
|
*/
|
|
|
|
let ctx: Context | undefined
|
|
|
|
afterEach(async () => {
|
|
await ctx?.fiber.dispose()
|
|
ctx = undefined
|
|
})
|
|
|
|
async function harness(): Promise<Context> {
|
|
const built = new Context()
|
|
await built.plugin(LlmService)
|
|
await built.plugin(SessionStore)
|
|
await built.plugin(SystemPrompt)
|
|
await built.plugin(ToolRegistry)
|
|
await built.plugin(AgentRegistry)
|
|
await built.plugin(AgentLoop, { agents: [] })
|
|
await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
|
|
await built.plugin(SubagentService)
|
|
await built.plugin(Spawn, { providerName: 'spawn' })
|
|
await built.plugin(WorkerWorkflowEngine, { provider: 'spawn' })
|
|
return built
|
|
}
|
|
|
|
const SCRIPT = `export const meta = {
|
|
name: 'e2e-worker-arithmetic',
|
|
description: 'two real children through a worker thread: one prose, one structured',
|
|
phases: [{ title: 'Ask' }, { title: 'Judge' }],
|
|
}
|
|
phase('Ask')
|
|
log('asking the prose child')
|
|
const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?')
|
|
phase('Judge')
|
|
const judged = await agent(
|
|
'Here is an answer to the question "what is 2+2": ' + prose
|
|
+ ' — report whether it contains the number 4 and your confidence between 0 and 1.',
|
|
{ schema: { type: 'object', properties: { containsFour: { type: 'boolean' }, confidence: { type: 'number' } }, required: ['containsFour'] } },
|
|
)
|
|
return { prose, containsFour: judged === null ? null : judged.containsFour }`
|
|
|
|
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key e2e', () => {
|
|
it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => {
|
|
ctx = await harness()
|
|
const parentHandle = ctx.agents.create({
|
|
agentId: AgentId('wf-worker-e2e-parent'),
|
|
sessionId: 'wf-worker-e2e-session' as never,
|
|
agentOptions: { model: 'deepseek-v4-flash' },
|
|
})
|
|
|
|
const events: string[] = []
|
|
const childIds: string[] = []
|
|
for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) {
|
|
ctx.on(name, (...payload: unknown[]) => {
|
|
events.push(name)
|
|
if (name === 'workflow/agent-start') childIds.push((payload[1] as { childId: string }).childId)
|
|
})
|
|
}
|
|
|
|
const run = ctx.workflows.start({ script: SCRIPT, parent: parentHandle.agent })
|
|
const result = await run.result
|
|
await run.dispose()
|
|
|
|
expect(result.stopReason).toBe('completed')
|
|
expect(result.agentsStarted).toBe(2)
|
|
const value = result.value as { prose: string; containsFour: boolean | null }
|
|
// World checks: the prose child really answered (a real completion), and
|
|
// the structured child judged it against the REAL schema-forced tool.
|
|
expect(value.prose.length).toBeGreaterThan(0)
|
|
expect(value.containsFour).toBe(true)
|
|
|
|
expect(events[0]).toBe('workflow/start')
|
|
expect(events.at(-1)).toBe('workflow/end')
|
|
expect(events.filter(name => name === 'workflow/phase').length).toBe(2)
|
|
expect(events.filter(name => name === 'workflow/agent-start').length).toBe(2)
|
|
expect(childIds.length).toBe(2)
|
|
// The children were disposed to quiescence after collection.
|
|
for (const childId of childIds) {
|
|
expect(ctx.agents.get(AgentId(childId))).toBeUndefined()
|
|
}
|
|
await parentHandle.dispose()
|
|
}, 240_000)
|
|
})
|