workflow: swap the engine's internals to node:worker_threads

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.
This commit is contained in:
imccyu
2026-07-09 18:39:31 +08:00
parent fbc9eb313c
commit b5f618bcfb
21 changed files with 2073 additions and 953 deletions

View File

@@ -0,0 +1,56 @@
import { existsSync } from 'node:fs'
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const packageRoot = fileURLToPath(new URL('..', import.meta.url))
const builtIndex = join(packageRoot, 'lib', 'index.js')
const builtWorker = join(packageRoot, 'lib', 'worker.js')
const run = promisify(execFile)
/**
* The BUILT-output guard for the worker entry: every other suite runs
* unbuilt (src/ + tsx), so nothing else proves that `lib/index.js` resolves
* its sibling `lib/worker.js` and that the bundle boots a worker under plain
* node (no tsx loader). Keyless — a zero-agent script needs no provider —
* and self-skips until `pnpm run build` has produced the bundles.
*/
describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.js)', () => {
it('the built engine spawns its built worker under plain node and completes a run', async () => {
// ESM resolves bare specifiers from the IMPORTING FILE's location, so the
// driver must live inside the package for its node_modules to apply — a
// temp-named file at the package root, removed on the way out.
const driver = join(packageRoot, `.built-worker-driver-${process.pid}.mjs`)
try {
await writeFile(driver, `
import { Context } from 'cordis'
import SubagentService from '@deepseek-ai/dsh-subagent'
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-vm'
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(WorkerWorkflowEngine, {})
const run = ctx.workflows.start({
script: "export const meta = { name: 'built-smoke', description: 'built worker smoke' }\\nreturn 6 * 7",
// A zero-agent script never touches the provider, so a bare id suffices.
parent: { id: 'built-smoke-parent', options: {} },
})
const result = await run.result
await run.dispose()
if (result.stopReason !== 'completed' || result.value !== 42) {
console.error('unexpected result: ' + JSON.stringify(result))
process.exit(1)
}
console.log('built-worker-smoke-ok')
`, 'utf8')
// Plain node — no tsx loader anywhere; the bundle must stand on its own.
const { stdout } = await run(process.execPath, [driver], { cwd: packageRoot, timeout: 60_000 })
expect(stdout).toContain('built-worker-smoke-ok')
} finally {
await rm(driver, { force: true })
}
}, 120_000)
})

View File

@@ -11,15 +11,17 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import VmWorkflowEngine from '../src/index.ts'
import WorkerWorkflowEngine from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/**
* The whole in-process stack, keyless: the vm engine drives the REAL spawn
* backend (with its structured runtime) on a real agent loop; the scripted
* mock MODEL is the only mocked boundary. This is the integration guard the
* per-hook unit tests (which stub the subagent seam) structurally cannot give.
* The whole in-process stack, keyless, with the script in a REAL worker
* thread: the engine drives the REAL spawn backend (with its
* structured runtime) on a real agent loop; the scripted mock MODEL is the
* only mocked boundary. This is the guard the unit suites structurally
* cannot give — the MessageChannel suite fakes the host, and the host suite
* stubs the subagent seam.
*/
async function setup(script: Script) {
const ctx = new Context()
@@ -33,7 +35,7 @@ async function setup(script: Script) {
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(spawn, { providerName: 'spawn' })
await ctx.plugin(VmWorkflowEngine, {})
await ctx.plugin(WorkerWorkflowEngine, {})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
return { ctx, parent, adapter }

View File

@@ -0,0 +1,504 @@
import { describe, expect, it, vi } from 'vitest'
import { MessageChannel } from 'node:worker_threads'
import type { MessagePort } from 'node:worker_threads'
import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts'
import type { HostToWorkerMessage, WorkerToHostMessage } from '../src/protocol.ts'
import { requireParentPort, runWorkerSession } from '../src/session.ts'
import type { ChildResult, WorkerInit } from '../src/types.ts'
/** Default limits for in-process sessions (concurrency pinned; auto is machine-derived). */
function limits(overrides?: Partial<WorkerInit['limits']>): WorkerInit['limits'] {
return { maxConcurrentAgents: 8, maxTotalAgents: 1000, maxItemsPerCall: 4096, syncTimeoutMs: 5000, ...overrides }
}
/** Wrap a body in the minimal valid meta header (the session receives it pre-extracted). */
function init(body: string, args?: unknown, limitOverrides?: Partial<WorkerInit['limits']>): WorkerInit {
return {
meta: { name: 'test-flow', description: 'a test workflow' },
body,
...args !== undefined ? { args } : {},
limits: limits(limitOverrides),
}
}
/** One scripted host over the other end of a MessageChannel. */
interface FakeHost {
port: MessagePort
messages: WorkerToHostMessage[]
/** Messages of one type, as they arrive. */
ofType<T extends WorkerToHostMessage['type']>(type: T): Extract<WorkerToHostMessage, { type: T }>[]
send(message: HostToWorkerMessage): void
/** Resolves with the terminal result message. */
result(): Promise<Extract<WorkerToHostMessage, { type: 'result' }>['result']>
close(): void
}
interface FakeHostOptions {
/** Auto-respond to child-start: reply started + settled per child index. Omit a reply to leave the child pending. */
reply?: (request: { prompt: string; schema?: unknown; model?: string }, index: number) => ChildResult | undefined
/** Reject the start instead (child-start-error) when returning a string. */
refuse?: (index: number) => string | undefined
/** Auto-send `go` on `ready` (default true). */
go?: boolean
/** Manual mode: do NOT auto-answer child-start at all (the test scripts the replies). */
manual?: boolean
}
/**
* Drive runWorkerSession IN-PROCESS over a MessageChannel: this is where the
* worker-side files earn their coverage — code inside a real Worker is
* invisible to main-process coverage. The fake host mirrors the real host's
* protocol discipline (one started/start-error per start; settled/disposed
* follow).
*/
function fakeHost(options?: FakeHostOptions): FakeHost {
const channel = new MessageChannel()
const messages: WorkerToHostMessage[] = []
const resultGate = Promise.withResolvers<Extract<WorkerToHostMessage, { type: 'result' }>['result']>()
let childIndex = 0
channel.port1.on('message', (message: WorkerToHostMessage) => {
messages.push(message)
switch (message.type) {
case WorkerToHostType.Ready:
if (options?.go !== false) channel.port1.postMessage({ type: HostToWorkerType.Go } satisfies HostToWorkerMessage)
break
case WorkerToHostType.ChildStart: {
if (options?.manual) break
const index = childIndex
childIndex += 1
const refusal = options?.refuse?.(index)
if (refusal !== undefined) {
channel.port1.postMessage(
{ type: HostToWorkerType.ChildStartError, callId: message.callId, rendered: refusal } satisfies HostToWorkerMessage,
)
break
}
channel.port1.postMessage({ type: HostToWorkerType.ChildStarted, callId: message.callId, childId: `child-${index}` } satisfies HostToWorkerMessage)
const reply = options?.reply?.(message.request, index)
if (reply !== undefined) {
channel.port1.postMessage(
{ type: HostToWorkerType.ChildSettled, callId: message.callId, result: reply } satisfies HostToWorkerMessage,
)
}
break
}
case WorkerToHostType.ChildDispose:
channel.port1.postMessage({ type: HostToWorkerType.ChildDisposed, callId: message.callId } satisfies HostToWorkerMessage)
break
case WorkerToHostType.Result:
resultGate.resolve(message.result)
break
default:
break
}
})
return {
port: channel.port2,
messages,
ofType: type => messages.filter((message): message is never => message.type === type),
send: (message) => { channel.port1.postMessage(message) },
result: () => resultGate.promise,
close: () => { channel.port1.close() },
}
}
/** A completed text child result. */
function text(reply: string): ChildResult {
return { output: [{ type: 'text', text: reply }], stopReason: 'completed' }
}
describe('runWorkerSession over an in-process MessageChannel', () => {
it('runs a script end to end: ready/go handshake, phases, log, agents, result', async () => {
const host = fakeHost({ reply: (_request, index) => text(`answer-${index}`) })
const session = runWorkerSession(host.port, init(`
phase('Scan')
log('starting with ' + args.files.length + ' files')
const answers = await pipeline(args.files, (prev, item) => agent('read ' + item))
return { answers }
`, { files: ['a.ts', 'b.ts'] }))
const result = await host.result()
await session
expect(result.stopReason).toBe('completed')
expect(result.agentsStarted).toBe(2)
expect(result.value).toEqual({ answers: ['answer-0', 'answer-1'] })
expect(host.messages[0]!.type).toBe('ready')
expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['Scan'])
expect(host.ofType(WorkerToHostType.Log).map(m => m.message)).toEqual(['starting with 2 files'])
expect(host.ofType(WorkerToHostType.AgentStart).map(m => m.info.childId)).toEqual(['child-0', 'child-1'])
expect(host.ofType(WorkerToHostType.AgentEnd).every(m => m.info.outcome === 'completed')).toBe(true)
host.close()
})
it('agent({schema}) forwards the schema on the start request and returns the structured value', async () => {
const host = fakeHost({ reply: () => ({ output: [], structured: { files: ['x.ts'] }, stopReason: 'completed' }) })
void runWorkerSession(host.port, init(`
const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } }, model: 'deepseek-v4-pro' })
return { first: found.files[0] }
`))
const result = await host.result()
expect(result.value).toEqual({ first: 'x.ts' })
const start = host.ofType(WorkerToHostType.ChildStart)[0]!
expect(start.request.schema).toEqual({ type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } })
expect(start.request.model).toBe('deepseek-v4-pro')
host.close()
})
it('a schema child completing WITHOUT a structured value resolves null with a failed outcome', async () => {
const host = fakeHost({ reply: () => text('prose, no structure') })
void runWorkerSession(host.port, init("return await agent('p', { schema: { type: 'object' } })"))
const result = await host.result()
expect(result.value).toBeNull()
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed')
host.close()
})
it('a child settling non-completed resolves null (scripts filter), never throwing into the script', async () => {
const host = fakeHost({ reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok') })
void runWorkerSession(host.port, init("return await parallel([() => agent('one'), () => agent('two')])"))
const result = await host.result()
expect(result.value).toEqual([null, 'ok'])
expect(host.ofType(WorkerToHostType.AgentEnd).map(m => m.info.outcome)).toEqual(expect.arrayContaining(['failed', 'completed']))
host.close()
})
it('a start refusal (child-start-error) is a fatal AGENT_START that kills the script through a combinator', async () => {
const host = fakeHost({ refuse: () => 'no provider here' })
void runWorkerSession(host.port, init("return await pipeline([1], () => agent('p'))"))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('agent() could not start a child')
expect(result.error).toContain('no provider here')
host.close()
})
it('a child-failed message (infrastructure rejection) is fatal AGENT_RESULT with the paired failed outcome', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init(`
try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal } }
`))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend exploded' })
const result = await host.result()
expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true })
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed')
host.close()
})
it('cancel before go: the body never runs at all and the result is cancelled (a second cancel is a no-op)', async () => {
const host = fakeHost({ go: false })
const session = runWorkerSession(host.port, init("log('ran')\nreturn 123"))
await vi.waitFor(() => { expect(host.messages.some(m => m.type === WorkerToHostType.Ready)).toBe(true) })
host.send({ type: HostToWorkerType.Cancel, reason: 'aborted before start' })
// Idempotence: the first reason wins; a duplicate cancel changes nothing.
host.send({ type: HostToWorkerType.Cancel, reason: 'a later reason that must lose' })
const result = await host.result()
await session
expect(result.stopReason).toBe('cancelled')
expect(result.error).toContain('aborted before start')
expect(result.error).not.toContain('must lose')
expect(result.value).toBeNull()
expect(host.ofType(WorkerToHostType.Log)).toEqual([])
host.close()
})
it('a script with no return value resolves value: null', async () => {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init("await agent('p')"))
const result = await host.result()
expect(result.stopReason).toBe('completed')
expect(result.value).toBeNull()
host.close()
})
it('cancel mid-run: in-flight children get cancel RPCs, hooks throw at entry, the run reports cancelled', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init(`
phase('before')
try { await agent('x') } catch (e) {}
try { phase('after') } catch (e) {}
try { log('after') } catch (e) {}
try { await parallel([() => 'ran']) } catch (e) {}
try { await pipeline(['item'], p => p) } catch (e) {}
return 'survived by catching'
`))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
host.send({ type: HostToWorkerType.Cancel, reason: 'stop everything' })
// The real host settles the aborted child; mirror it.
host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } })
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
expect(result.error).toContain('stop everything')
expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId)
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
// No post-cancel narration left the runtime (the hooks threw at entry).
expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['before'])
expect(host.ofType(WorkerToHostType.Log)).toEqual([])
host.close()
})
it('cancellation between a queued waiter and its slot: the waiter rejects without a child-start', async () => {
const host = fakeHost({ go: true })
void runWorkerSession(host.port, init(
"return await parallel([() => agent('a'), () => agent('b')])",
undefined,
{ maxConcurrentAgents: 1 },
))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
host.send({ type: HostToWorkerType.Cancel, reason: 'raced' })
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
// Only the first agent ever reached the host.
expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1)
host.close()
})
it('a stray (never-awaited) agent is reaped after settlement: cancel + dispose RPCs flow, no unhandled rejection', async () => {
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown): void => { unhandled.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
const host = fakeHost()
void runWorkerSession(host.port, init(`
agent('stray, never awaited')
return 'done without awaiting'
`))
const result = await host.result()
expect(result.stopReason).toBe('completed')
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } })
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) })
await new Promise(resolve => setTimeout(resolve, 20))
expect(unhandled).toEqual([])
host.close()
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('an unparseable body settles an error result instead of dying without one (host pre-parse skew guard)', async () => {
const host = fakeHost()
await runWorkerSession(host.port, init('return ((('))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('does not parse')
expect(result.agentsStarted).toBe(0)
host.close()
})
it('a synchronous spin in the initial slice dies by the in-worker vm timeout', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init('while (true) {}', undefined, { syncTimeoutMs: 50 }))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error?.toLowerCase()).toContain('timed out')
host.close()
})
it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => {
const host = fakeHost()
void runWorkerSession(host.port, init('return { when: new Date(0) }'))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('not plain JSON data')
host.close()
})
it('tolerates replies for unknown callIds (a teardown race): nothing crashes, the run completes', async () => {
const host = fakeHost({ reply: () => text('fine') })
void runWorkerSession(host.port, init("return await agent('p')"))
host.send({ type: HostToWorkerType.ChildStarted, callId: 999, childId: 'ghost' })
host.send({ type: HostToWorkerType.ChildStartError, callId: 999, rendered: 'ghost' })
host.send({ type: HostToWorkerType.ChildSettled, callId: 999, result: text('ghost') })
host.send({ type: HostToWorkerType.ChildFailed, callId: 999, rendered: 'ghost' })
host.send({ type: HostToWorkerType.ChildDisposed, callId: 999 })
const result = await host.result()
expect(result.stopReason).toBe('completed')
expect(result.value).toBe('fine')
host.close()
})
it('caps and malformed hook arguments reject loud (the runtime runs unchanged inside the session)', async () => {
const cases: [string, string][] = [
['return await agent(42)', 'non-empty prompt string'],
["return await agent('')", 'non-empty prompt string'],
["return await agent('p', 'opts')", 'options must be an object'],
["return await agent('p', { label: 3 })", '"label" must be a string'],
["return await agent('p', { get label() { throw new Error('read failed') } })", 'options must be plain JSON data'],
["return await agent('p', { bogus: true })", '"bogus" is not recognized'],
["return await agent('p', { effort: 'high' })", '"effort" is deferred'],
["return await agent('p', { schema: { type: 'object', oneOf: [] } })", 'outside the supported subset'],
['return await parallel([() => 1, () => 2, () => 3])', 'over the per-call cap (2)'],
['return await pipeline([1, 2, 3], (x) => x)', 'maxItemsPerCall'],
["return await parallel('no')", 'parallel() requires an array'],
['return await parallel([3])', 'item 0 is not a function'],
["return await pipeline('no', () => 1)", 'pipeline() requires an items array'],
['return await pipeline([1])', 'at least one stage'],
["return await pipeline([1], 'x')", 'stage 0 is not a function'],
["phase('')", 'phase() requires a non-empty title string'],
['log(3)', 'log() requires a message string'],
]
for (const [body, expected] of cases) {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init(body, undefined, { maxItemsPerCall: 2 }))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain(expected)
host.close()
}
})
it('combinator semantics: thunk/stage throws null the item; a forged fatal-shaped object stays null; real fatals propagate', async () => {
const host = fakeHost({ reply: () => text('fine') })
void runWorkerSession(host.port, init(`
const viaParallel = await parallel([
() => { throw new Error('boom') },
() => agent('fine'),
() => 'plain value',
() => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } },
])
const viaPipeline = await pipeline([10, 20],
(prev, item, index) => { if (item === 10) throw new Error('ordinary failure'); return 'kept-' + item + '-' + index },
)
return { viaParallel, viaPipeline }
`))
const result = await host.result()
expect(result.stopReason).toBe('completed')
expect(result.value).toEqual({
viaParallel: [null, 'fine', 'plain value', null],
viaPipeline: [null, 'kept-20-1'],
})
host.close()
})
it('trips the total-agent cap with a message naming the config knob', async () => {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init("await agent('1'); await agent('2'); await agent('3')", undefined, { maxTotalAgents: 2 }))
const result = await host.result()
expect(result.stopReason).toBe('error')
expect(result.error).toContain('total agent cap (2)')
expect(result.agentsStarted).toBe(2)
host.close()
})
it('queued agents proceed through the concurrency semaphore in FIFO order', async () => {
const host = fakeHost({ reply: request => text(`ok:${request.prompt}`) })
void runWorkerSession(host.port, init(
"return await parallel([1, 2, 3].map((n) => () => agent('job ' + n)))",
undefined,
{ maxConcurrentAgents: 1 },
))
const result = await host.result()
expect(result.value).toEqual(['ok:job 1', 'ok:job 2', 'ok:job 3'])
host.close()
})
it('labels default from the prompt first line, truncated; explicit label/phase options win', async () => {
const host = fakeHost({ reply: () => text('ok') })
void runWorkerSession(host.port, init(`
phase('Find')
await agent('a prompt that is quite long and will surely get truncated down to a display label\\n'
+ 'with a second line the label must not include')
await agent('short', { label: 'named', phase: 'Custom' })
return null
`))
await host.result()
const starts = host.ofType(WorkerToHostType.AgentStart).map(m => m.info)
expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find' })
expect(starts[0]!.label.length).toBeLessThanOrEqual(48)
expect(starts[0]!.label).not.toContain('second line')
expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' })
host.close()
})
it('non-text output blocks are filtered out of the text result', async () => {
const host = fakeHost({
reply: () => ({
output: [
{ type: 'text', text: 'first ' },
{ type: 'tool_call', id: 'c1', name: 'x', arguments: {} } as never,
{ type: 'text', text: 'second' },
],
stopReason: 'completed',
}),
})
void runWorkerSession(host.port, init("return await agent('p')"))
const result = await host.result()
expect(result.value).toBe('first second')
host.close()
})
it('a cancel landing DURING the start round-trip winds the fresh child down (cancel + dispose) and dies cancelled', async () => {
const host = fakeHost({ manual: true })
void runWorkerSession(host.port, init("return await agent('p')"))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
// Cancel FIRST, then the (stale) started reply: the worker processes them
// in order, so the agent() continuation resumes already-cancelled — the
// window the real host cannot produce (it refuses starts once cancelled)
// but a teardown race can.
host.send({ type: HostToWorkerType.Cancel, reason: 'raced the start' })
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
await vi.waitFor(() => {
expect(host.ofType(WorkerToHostType.ChildCancel).map(m => m.callId)).toContain(callId)
expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId)
})
// The child never became an agent-start: it was wound down pre-lifecycle.
expect(host.ofType(WorkerToHostType.AgentStart)).toEqual([])
host.close()
})
it('a start refusal arriving after a cancel reads as the cancellation, not a broken seam', async () => {
const host = fakeHost({ manual: true })
void runWorkerSession(host.port, init(`
try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code } }
`))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.Cancel, reason: 'stopping' })
host.send({ type: HostToWorkerType.ChildStartError, callId, rendered: 'workflow run cancelled: stopping' })
const result = await host.result()
// The run reports cancelled (the script died of CANCELLED, not AGENT_START).
expect(result.stopReason).toBe('cancelled')
host.close()
})
it('a child result rejection while cancelled pairs a cancelled agent-end, and the run reports cancelled', async () => {
const host = fakeHost({ manual: true })
void runWorkerSession(host.port, init("return await agent('doomed')"))
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.AgentStart).length).toBe(1) })
host.send({ type: HostToWorkerType.Cancel, reason: 'user aborted' })
host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend crashed on abort' })
const result = await host.result()
expect(result.stopReason).toBe('cancelled')
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
host.close()
})
})
describe('the worker bootstrap', () => {
it('requireParentPort narrows a real port and throws on the main thread', () => {
const channel = new MessageChannel()
expect(requireParentPort(channel.port1)).toBe(channel.port1)
channel.port1.close()
expect(() => requireParentPort(null)).toThrow(/inside a worker thread/)
})
it('the entry module itself throws when loaded on the main thread (no parentPort)', async () => {
// This import EXECUTES ../src/worker.ts on the main thread, which is what
// covers the bootstrap file: requireParentPort throws before
// runWorkerSession is reached.
await expect(import('../src/worker.ts')).rejects.toThrow(/inside a worker thread/)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -9,16 +9,14 @@ 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 * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import { CallId } from '@deepseek-ai/dsh-llm'
import VmWorkflowEngine from '../src/index.ts'
import WorkerWorkflowEngine from '../src/index.ts'
/**
* With-key e2e for the workflow engine: a REAL script 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
* 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).
*/
@@ -40,14 +38,13 @@ async function harness(): Promise<Context> {
await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await built.plugin(SubagentService)
await built.plugin(Spawn, { providerName: 'spawn' })
await built.plugin(VmWorkflowEngine, { provider: 'spawn' })
await built.plugin(ToolWorkflow, {})
await built.plugin(WorkerWorkflowEngine, { provider: 'spawn' })
return built
}
const SCRIPT = `export const meta = {
name: 'e2e-arithmetic',
description: 'two real children: one prose, one structured',
name: 'e2e-worker-arithmetic',
description: 'two real children through a worker thread: one prose, one structured',
phases: [{ title: 'Ask' }, { title: 'Judge' }],
}
phase('Ask')
@@ -61,12 +58,12 @@ const judged = await agent(
)
return { prose, containsFour: judged === null ? null : judged.containsFour }`
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', () => {
it('runs a two-phase script over real children, one through the structured runtime', async () => {
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-e2e-parent'),
sessionId: 'wf-e2e-session' as never,
agentId: AgentId('wf-worker-e2e-parent'),
sessionId: 'wf-worker-e2e-session' as never,
agentOptions: { model: 'deepseek-v4-flash' },
})
@@ -102,30 +99,4 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', (
}
await parentHandle.dispose()
}, 240_000)
it('the workflow TOOL runs the same path through the real registry pipeline', async () => {
ctx = await harness()
const parentHandle = ctx.agents.create({
agentId: AgentId('wf-e2e-tool-parent'),
sessionId: 'wf-e2e-tool-session' as never,
agentOptions: { model: 'deepseek-v4-flash' },
})
const result = await ctx.tools.execute({
callId: CallId('wf-e2e-call'),
name: 'workflow',
arguments: {
script: `export const meta = { name: 'e2e-tool', description: 'one real child via the tool' }
const answer = await agent('Reply with exactly one word: the capital of France.')
return { answer }`,
},
agent: parentHandle.agent,
})
expect(result.isError).toBe(false)
const text = (result.content[0] as { text: string }).text
expect(text).toContain('workflow "e2e-tool" completed (1 agent)')
expect(text.toLowerCase()).toContain('paris')
await parentHandle.dispose()
}, 240_000)
})