workflow: meta rides the seam as data — the engine never evaluates it
P1 review finding: extractMeta timed only the literal's vm evaluation;
materializing the RESULT then read properties ordinarily on the HOST
stack, so a meta literal smuggling a getter (get name() { while(true){} })
could wedge the host outside any timeout — defeating the exact spin
isolation the worker thread exists for.
Rather than harden the evaluator (descriptor walks, AST validation),
delete the mechanism: the workflow's identity now reaches the seam as a
plain JSON field (WorkflowStartRequest.meta), carried by the tool as a
schema-validated `meta` object parameter the model fills directly. The
engine only shape-validates data (validateMeta, every violation named)
and pre-parses the body; the scanner, the vm evaluation, and the
host-side materialization are gone, and with them the hole. A body
still opening with a Claude Code-style `export const meta` statement
gets a pointed SCRIPT_PARSE message (the likeliest authoring slip; a
CC script's body stays drop-in, only its meta header moves into the
parameter). syncTimeoutMs now governs exactly one thing: the initial
synchronous slice inside the worker.
The RFC's decision section is rewritten in place (implemented-RFC
rule); the embedded-meta format moves to alternatives-considered with
the hole as the reason. Tool description, presentation (title now reads
meta.name directly — the textual sniff is gone), seam vocabulary docs,
and catalogs follow.
This commit is contained in:
@@ -34,7 +34,8 @@ 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",
|
||||
script: 'return 6 * 7',
|
||||
meta: { name: 'built-smoke', description: 'built worker smoke' },
|
||||
// A zero-agent script never touches the provider, so a bare id suffices.
|
||||
parent: { id: 'built-smoke-parent', options: {} },
|
||||
})
|
||||
|
||||
@@ -50,8 +50,8 @@ describe('dsh-workflow-workerthread over the real in-process stack', () => {
|
||||
const childIds: string[] = []
|
||||
ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) })
|
||||
const run = ctx.workflows.start({
|
||||
script: `export const meta = { name: 'integration', description: 'plain + structured children' }
|
||||
phase('Read')
|
||||
meta: { name: 'integration', description: 'plain + structured children' },
|
||||
script: `phase('Read')
|
||||
const prose = await agent('read the repo')
|
||||
phase('Judge')
|
||||
const judged = await agent('judge: ' + prose, {
|
||||
@@ -78,8 +78,8 @@ return { prose, verdict: judged.verdict, confidence: judged.confidence }`,
|
||||
textResponse('still prose after the nudge'),
|
||||
])
|
||||
const run = ctx.workflows.start({
|
||||
script: `export const meta = { name: 'null-path', description: 'schema failure maps to null' }
|
||||
const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } })
|
||||
meta: { name: 'null-path', description: 'schema failure maps to null' },
|
||||
script: `const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } })
|
||||
return { got: judged === null ? 'null' : 'value' }`,
|
||||
parent,
|
||||
})
|
||||
|
||||
@@ -1,182 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import { extractMeta } from '../src/meta.ts'
|
||||
import { validateMeta } from '../src/meta.ts'
|
||||
|
||||
const TIMEOUT = 1000
|
||||
|
||||
/** Extract and expect success. */
|
||||
function ok(script: string) {
|
||||
return extractMeta(script, TIMEOUT)
|
||||
}
|
||||
|
||||
/** The WorkflowError a bad script produces (throws if it extracts cleanly). */
|
||||
function bad(script: string): WorkflowError {
|
||||
/** Assert a META_INVALID throw whose message matches every given fragment. */
|
||||
function expectInvalid(value: unknown, ...fragments: string[]): void {
|
||||
let thrown: unknown
|
||||
try {
|
||||
extractMeta(script, TIMEOUT)
|
||||
validateMeta(value)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowError) return error
|
||||
throw error
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(WorkflowError)
|
||||
expect((thrown as WorkflowError).code).toBe('META_INVALID')
|
||||
for (const fragment of fragments) {
|
||||
expect((thrown as WorkflowError).message).toContain(fragment)
|
||||
}
|
||||
throw new Error('expected extraction to fail')
|
||||
}
|
||||
|
||||
describe('extractMeta', () => {
|
||||
it('extracts a full meta block and blanks the statement line-preservingly', () => {
|
||||
const script = `export const meta = {
|
||||
name: 'audit-routes',
|
||||
description: 'Audit every route',
|
||||
whenToUse: 'when auditing',
|
||||
phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }],
|
||||
}
|
||||
const x = 1
|
||||
return x`
|
||||
const { meta, body } = ok(script)
|
||||
expect(meta).toEqual({
|
||||
name: 'audit-routes',
|
||||
description: 'Audit every route',
|
||||
whenToUse: 'when auditing',
|
||||
phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }],
|
||||
describe('validateMeta', () => {
|
||||
it('accepts a minimal meta and returns a normalized copy (no aliasing of the input)', () => {
|
||||
const input = { name: 'audit', description: 'audit the repo' }
|
||||
const meta = validateMeta(input)
|
||||
expect(meta).toEqual({ name: 'audit', description: 'audit the repo' })
|
||||
expect(meta).not.toBe(input)
|
||||
input.name = 'mutated'
|
||||
expect(meta.name).toBe('audit')
|
||||
})
|
||||
|
||||
it('accepts the full shape and rebuilds phases entry by entry', () => {
|
||||
const meta = validateMeta({
|
||||
name: 'migrate',
|
||||
description: 'migrate call sites',
|
||||
whenToUse: 'large mechanical sweeps',
|
||||
phases: [
|
||||
{ title: 'Discover' },
|
||||
{ title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' },
|
||||
],
|
||||
})
|
||||
expect(meta).toEqual({
|
||||
name: 'migrate',
|
||||
description: 'migrate call sites',
|
||||
whenToUse: 'large mechanical sweeps',
|
||||
phases: [
|
||||
{ title: 'Discover' },
|
||||
{ title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' },
|
||||
],
|
||||
})
|
||||
// Same line count; the statement's characters blanked; the body intact.
|
||||
expect(body.split('\n').length).toBe(script.split('\n').length)
|
||||
expect(body.split('\n')[6]).toBe('const x = 1')
|
||||
expect(body).not.toContain('export')
|
||||
})
|
||||
|
||||
it('allows leading line and block comments before the meta statement', () => {
|
||||
const script = `// a workflow
|
||||
/* multi
|
||||
line */
|
||||
export const meta = { name: 'x', description: 'y' }
|
||||
return 1`
|
||||
expect(ok(script).meta.name).toBe('x')
|
||||
it('rejects non-object values loud', () => {
|
||||
expectInvalid(undefined, 'meta must be an object')
|
||||
expectInvalid('a string', 'meta must be an object')
|
||||
expectInvalid(null, 'meta must be an object')
|
||||
expectInvalid([{ name: 'x', description: 'd' }], 'meta must be an object')
|
||||
})
|
||||
|
||||
it('handles braces inside strings and comments while scanning', () => {
|
||||
const script = `export const meta = {
|
||||
name: 'tricky', // } not a close {
|
||||
/* } also not } */
|
||||
description: "has { braces } and 'quotes'",
|
||||
}
|
||||
return 2`
|
||||
expect(ok(script).meta.description).toBe("has { braces } and 'quotes'")
|
||||
it('rejects unknown fields by name (accepted-then-ignored is banned)', () => {
|
||||
expectInvalid({ name: 'x', description: 'd', color: 'red' }, 'meta.color is not a recognized field')
|
||||
})
|
||||
|
||||
it('tolerates template-quoted strings WITHOUT interpolation, escapes included', () => {
|
||||
const script = 'export const meta = { name: `plain`, description: `esc \\` tick` }\nreturn 1'
|
||||
expect(ok(script).meta.name).toBe('plain')
|
||||
it('rejects missing or mistyped name/description/whenToUse', () => {
|
||||
expectInvalid({ description: 'd' }, 'meta.name must be a non-empty string')
|
||||
expectInvalid({ name: '', description: 'd' }, 'meta.name must be a non-empty string')
|
||||
expectInvalid({ name: 'x' }, 'meta.description must be a non-empty string')
|
||||
expectInvalid({ name: 'x', description: 42 }, 'meta.description must be a non-empty string')
|
||||
expectInvalid({ name: 'x', description: 'd', whenToUse: 3 }, 'meta.whenToUse must be a string')
|
||||
})
|
||||
|
||||
it('consumes a trailing semicolon after the literal, spaces included', () => {
|
||||
const { body } = ok("export const meta = { name: 'x', description: 'y' };\nreturn 1")
|
||||
expect(body).not.toContain(';')
|
||||
expect(body.split('\n')[1]).toBe('return 1')
|
||||
const spaced = ok("export const meta = { name: 'x', description: 'y' } ;\nreturn 1")
|
||||
expect(spaced.body).not.toContain(';')
|
||||
it('rejects malformed phases, entry by entry', () => {
|
||||
expectInvalid({ name: 'x', description: 'd', phases: 'Scan' }, 'meta.phases must be an array')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: ['Scan'] }, 'meta.phases[0] must be an object')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: '' }] }, 'meta.phases[0].title must be a non-empty string')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', order: 1 }] }, 'meta.phases[0].order is not a recognized field')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', detail: 9 }] }, 'meta.phases[0].detail must be a string')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', model: 9 }] }, 'meta.phases[0].model must be a string')
|
||||
})
|
||||
|
||||
it('rejects a script that does not begin with the meta statement (SCRIPT_PARSE)', () => {
|
||||
expect(bad('const a = 1').code).toBe('SCRIPT_PARSE')
|
||||
expect(bad('').code).toBe('SCRIPT_PARSE')
|
||||
expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE')
|
||||
})
|
||||
|
||||
it('a near-miss prefix (comment header + whitespace, then no `export`) fails FAST as SCRIPT_PARSE', () => {
|
||||
// Regression: the previous all-alternation prefix regex backtracked
|
||||
// exponentially on exactly this shape (~×2 per extra whitespace char once
|
||||
// the match fails), spinning the host synchronously inside start(). The
|
||||
// linear trivia scan must reject it in effectively zero time.
|
||||
const nearMiss = `// deep-audit workflow: reviews every route handler\n${' \n'.repeat(40)}/* second header block */\n${' '.repeat(200)}\nconst meta = { name: 'x', description: 'y' }\n`
|
||||
const started = Date.now()
|
||||
expect(bad(nearMiss).code).toBe('SCRIPT_PARSE')
|
||||
expect(Date.now() - started).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('an unterminated block comment BEFORE the meta statement is SCRIPT_PARSE', () => {
|
||||
const error = bad('/* never closed\nexport const meta = { name: "x", description: "y" }')
|
||||
expect(error.code).toBe('SCRIPT_PARSE')
|
||||
expect(error.message).toContain('unterminated comment')
|
||||
})
|
||||
|
||||
it('a line comment running to EOF leaves no meta statement (SCRIPT_PARSE)', () => {
|
||||
expect(bad('// only a comment, no newline').code).toBe('SCRIPT_PARSE')
|
||||
})
|
||||
|
||||
it('rejects template interpolation in the meta block as impure (SCRIPT_PARSE)', () => {
|
||||
const error = bad('export const meta = { name: `w-${1}`, description: "d" }\nreturn 1')
|
||||
expect(error.code).toBe('SCRIPT_PARSE')
|
||||
expect(error.message).toContain('pure literal')
|
||||
})
|
||||
|
||||
it('rejects unbalanced literals, unterminated strings, and unterminated comments (SCRIPT_PARSE)', () => {
|
||||
expect(bad('export const meta = { name: "x", description: "y"').code).toBe('SCRIPT_PARSE')
|
||||
expect(bad('export const meta = { name: "x').code).toBe('SCRIPT_PARSE')
|
||||
expect(bad('export const meta = { /* open').code).toBe('SCRIPT_PARSE')
|
||||
// A line comment running to EOF (no newline) leaves the literal unbalanced.
|
||||
expect(bad('export const meta = { name: "x" // eof comment').code).toBe('SCRIPT_PARSE')
|
||||
})
|
||||
|
||||
it('rejects a literal referencing variables or calls (META_INVALID via the empty realm)', () => {
|
||||
const error = bad('export const meta = { name: someVariable, description: "d" }\nreturn 1')
|
||||
expect(error.code).toBe('META_INVALID')
|
||||
expect(error.message).toContain('pure literal')
|
||||
expect(bad('export const meta = { name: compute(), description: "d" }').code).toBe('META_INVALID')
|
||||
})
|
||||
|
||||
it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => {
|
||||
const error = bad('export const meta = { name: "x", description: "d", whenToUse: () => 1 }')
|
||||
expect(error.code).toBe('META_INVALID')
|
||||
expect(error.message).toContain('JSON data')
|
||||
})
|
||||
|
||||
it('a meta expression that THROWS maps to META_INVALID carrying the rendered value', () => {
|
||||
const error = bad('export const meta = { name: (() => { throw "nope" })(), description: "d" }\nreturn 1')
|
||||
expect(error.code).toBe('META_INVALID')
|
||||
expect(error.message).toContain('pure literal')
|
||||
expect(error.message).toContain('nope')
|
||||
})
|
||||
|
||||
it('a spinning meta expression dies by the eval timeout', () => {
|
||||
try {
|
||||
extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50)
|
||||
throw new Error('expected the extraction to time out')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(WorkflowError)
|
||||
expect((error as WorkflowError).code).toBe('META_INVALID')
|
||||
expect((error as WorkflowError).message.toLowerCase()).toContain('timed out')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects shape violations with EVERY violation listed (META_INVALID)', () => {
|
||||
const error = bad('export const meta = { description: 7, bogus: 1 }\nreturn 1')
|
||||
expect(error.code).toBe('META_INVALID')
|
||||
expect(error.message).toContain('meta.name must be a non-empty string')
|
||||
expect(error.message).toContain('meta.description must be a non-empty string')
|
||||
expect(error.message).toContain('meta.bogus is not a recognized field')
|
||||
})
|
||||
|
||||
it('rejects malformed whenToUse and phases shapes precisely', () => {
|
||||
expect(bad('export const meta = { name: "x", description: "d", whenToUse: 3 }').message)
|
||||
.toContain('meta.whenToUse must be a string')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: "no" }').message)
|
||||
.toContain('meta.phases must be an array')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [3] }').message)
|
||||
.toContain('meta.phases[0] must be an object')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [{}] }').message)
|
||||
.toContain('meta.phases[0].title must be a non-empty string')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", extra: 1 }] }').message)
|
||||
.toContain('meta.phases[0].extra is not a recognized field')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", detail: 1 }] }').message)
|
||||
.toContain('meta.phases[0].detail must be a string')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", model: 1 }] }').message)
|
||||
.toContain('meta.phases[0].model must be a string')
|
||||
})
|
||||
|
||||
it('stops scanning at the balanced literal — trailing expression text stays in the body', () => {
|
||||
// The scanner extracts exactly `{ valueOf: null }`; the ` && 3` is body
|
||||
// text (which would fail compilation later, but extraction sees only the
|
||||
// literal and reports its unknown field).
|
||||
expect(bad('export const meta = { valueOf: null } && 3').message)
|
||||
.toContain('meta.valueOf is not a recognized field')
|
||||
it('names EVERY violation in one throw, not just the first', () => {
|
||||
expectInvalid(
|
||||
{ description: 7, extra: true, phases: [{ title: 'Scan' }, 'bad'] },
|
||||
'meta.extra is not a recognized field',
|
||||
'meta.name must be a non-empty string',
|
||||
'meta.description must be a non-empty string',
|
||||
'meta.phases[1] must be an object',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,12 +42,12 @@ async function harness(): Promise<Context> {
|
||||
return built
|
||||
}
|
||||
|
||||
const SCRIPT = `export const meta = {
|
||||
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')
|
||||
const SCRIPT = `phase('Ask')
|
||||
log('asking the prose child')
|
||||
const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?')
|
||||
phase('Judge')
|
||||
@@ -76,7 +76,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key
|
||||
})
|
||||
}
|
||||
|
||||
const run = ctx.workflows.start({ script: SCRIPT, parent: parentHandle.agent })
|
||||
const run = ctx.workflows.start({ script: SCRIPT, meta: META, parent: parentHandle.agent })
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
|
||||
import * as workerEngineModule from '../src/index.ts'
|
||||
import WorkerWorkflowEngine, { type Config } from '../src/index.ts'
|
||||
|
||||
@@ -104,14 +104,14 @@ async function setup(options?: SetupOptions) {
|
||||
return { ctx, provider, parent: fakeParent() }
|
||||
}
|
||||
|
||||
/** Wrap a body in the minimal valid meta header. */
|
||||
function script(body: string, metaExtra = ''): string {
|
||||
return `export const meta = { name: 'test-flow', description: 'a test workflow'${metaExtra} }\n${body}`
|
||||
/** The standard test meta plus a body, spread into a start request. */
|
||||
function scripted(body: string, metaExtra?: Partial<WorkflowMeta>): { script: string; meta: WorkflowMeta } {
|
||||
return { script: body, meta: { name: 'test-flow', description: 'a test workflow', ...metaExtra } }
|
||||
}
|
||||
|
||||
/** Start + await one run, disposing on the way out. */
|
||||
async function run(ctx: Context, parent: Agent, source: string, args?: unknown): Promise<WorkflowResult> {
|
||||
const handle = ctx.workflows.start({ script: source, parent, ...args !== undefined ? { args } : {} })
|
||||
async function run(ctx: Context, parent: Agent, source: { script: string; meta: WorkflowMeta }, args?: unknown): Promise<WorkflowResult> {
|
||||
const handle = ctx.workflows.start({ ...source, parent, ...args !== undefined ? { args } : {} })
|
||||
try {
|
||||
return await handle.result
|
||||
} finally {
|
||||
@@ -127,13 +127,13 @@ describe('dsh-workflow-workerthread', () => {
|
||||
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, payload]) })
|
||||
}
|
||||
const result = await run(ctx, parent, script(`
|
||||
const result = await run(ctx, parent, scripted(`
|
||||
phase('Scan')
|
||||
log('starting with ' + args.files.length + ' files')
|
||||
const answers = await pipeline(args.files, (prev, item) => agent('read ' + item))
|
||||
phase('Report')
|
||||
return { answers, count: args.files.length }
|
||||
`, ", phases: [{ title: 'Scan' }, { title: 'Report' }]"), { files: ['a.ts', 'b.ts'] })
|
||||
`, { phases: [{ title: 'Scan' }, { title: 'Report' }] }), { files: ['a.ts', 'b.ts'] })
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
@@ -156,7 +156,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
const { ctx, parent, provider } = await setup({
|
||||
reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }),
|
||||
})
|
||||
const result = await run(ctx, parent, script(`
|
||||
const result = await run(ctx, parent, scripted(`
|
||||
const found = await agent('list files', { model: 'deepseek-v4-pro', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } })
|
||||
return { first: found.files[0], count: found.files.length }
|
||||
`))
|
||||
@@ -172,14 +172,14 @@ describe('dsh-workflow-workerthread', () => {
|
||||
|
||||
it('a fatal hook error inside the worker kills the script and reports the error', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script("return await parallel([() => agent('x', { isolation: 'worktree' })])"))
|
||||
const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])"))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('"isolation" is deferred')
|
||||
})
|
||||
|
||||
it('a provider start failure crosses back as a fatal AGENT_START error', async () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } })
|
||||
const result = await run(ctx, parent, script("return await pipeline([1], () => agent('p'))"))
|
||||
const result = await run(ctx, parent, scripted("return await pipeline([1], () => agent('p'))"))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('agent() could not start a child')
|
||||
})
|
||||
@@ -200,7 +200,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
await ctx.plugin(WorkerWorkflowEngine, { provider: 'rejecting', maxConcurrentAgents: 2 })
|
||||
const result = await run(ctx, fakeParent(), script(`
|
||||
const result = await run(ctx, fakeParent(), scripted(`
|
||||
try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal, message: e.message } }
|
||||
`))
|
||||
expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true })
|
||||
@@ -223,7 +223,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
await ctx.plugin(WorkerWorkflowEngine, { provider: 'bad-dispose', maxConcurrentAgents: 2 })
|
||||
const result = await run(ctx, fakeParent(), script("return await agent('p')"))
|
||||
const result = await run(ctx, fakeParent(), scripted("return await agent('p')"))
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toBe('fine')
|
||||
})
|
||||
@@ -248,17 +248,22 @@ describe('dsh-workflow-workerthread', () => {
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
await ctx.plugin(WorkerWorkflowEngine, { provider: 'coercion-trap-dispose', maxConcurrentAgents: 2 })
|
||||
const result = await run(ctx, fakeParent(), script("return await agent('p')"))
|
||||
const result = await run(ctx, fakeParent(), scripted("return await agent('p')"))
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toBe('fine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle: parse errors, cancellation, termination, disposal', () => {
|
||||
it('start() throws synchronously for an unparseable script or invalid meta (host-side pre-parse)', async () => {
|
||||
it('start() throws synchronously for invalid meta data or an unparseable body (host-side pre-checks)', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect(() => ctx.workflows.start({ script: 'const x = 1', parent })).toThrow(/must begin with/)
|
||||
expect(() => ctx.workflows.start({ script: script('return ((('), parent })).toThrow(/does not parse/)
|
||||
// Meta is DATA — shape violations reject loud, every one named.
|
||||
expect(() => ctx.workflows.start({ script: 'return 1', meta: { name: '', description: 'd' }, parent })).toThrow(/meta\.name must be a non-empty string/)
|
||||
expect(() => ctx.workflows.start({ script: 'return 1', meta: { name: 'x', description: 'd', extra: 1 } as unknown as WorkflowMeta, parent })).toThrow(/META_INVALID|not a recognized field/)
|
||||
expect(() => ctx.workflows.start({ ...scripted('return ((('), parent })).toThrow(/does not parse/)
|
||||
// The likeliest authoring slip — a Claude Code-style meta header in the
|
||||
// body — gets a pointed message, not a bare SyntaxError.
|
||||
expect(() => ctx.workflows.start({ ...scripted("export const meta = { name: 'x', description: 'd' }\nreturn 1"), parent })).toThrow(/meta rides the `meta` request field/)
|
||||
})
|
||||
|
||||
it('cancel() aborts in-flight children (signal AND cancel RPC) and settles the run cancelled', async () => {
|
||||
@@ -267,7 +272,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
ctx.on('workflow/agent-end', (_info, agent) => { ends.push(agent) })
|
||||
const runEnds: WorkflowResultInfo[] = []
|
||||
ctx.on('workflow/end', (_info, result) => { runEnds.push(result) })
|
||||
const handle = ctx.workflows.start({ script: script("return await agent('long job')"), parent })
|
||||
const handle = ctx.workflows.start({ ...scripted("return await agent('long job')"), parent })
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
handle.cancel('user stopped it')
|
||||
const result = await handle.result
|
||||
@@ -287,7 +292,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
controller.abort()
|
||||
const logs: string[] = []
|
||||
ctx.on('workflow/log', (_info, message) => { logs.push(message) })
|
||||
const handle = ctx.workflows.start({ script: script("log('ran')\nreturn 123"), parent, signal: controller.signal })
|
||||
const handle = ctx.workflows.start({ ...scripted("log('ran')\nreturn 123"), parent, signal: controller.signal })
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(result.value).toBeNull()
|
||||
@@ -298,7 +303,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
|
||||
it('cancel() right after start() cancels before the body runs; the signal aborting mid-run cancels like cancel()', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
const first = ctx.workflows.start({ script: script("return await agent('never')"), parent })
|
||||
const first = ctx.workflows.start({ ...scripted("return await agent('never')"), parent })
|
||||
// No-reason cancel: the canonical default reason must ride the result.
|
||||
first.cancel()
|
||||
const firstResult = await first.result
|
||||
@@ -308,7 +313,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
await first.dispose()
|
||||
|
||||
const controller = new AbortController()
|
||||
const second = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal })
|
||||
const second = ctx.workflows.start({ ...scripted("return await agent('job')"), parent, signal: controller.signal })
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
controller.abort()
|
||||
expect((await second.result).stopReason).toBe('cancelled')
|
||||
@@ -323,7 +328,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
// timing can hit reliably. (The closure runs only after `handle` below
|
||||
// is initialized — the listener fires on the worker's first message.)
|
||||
ctx.on('workflow/log', () => { handle.cancel('cancelled from the log listener') })
|
||||
const handle = ctx.workflows.start({ script: script("log('mark')\nreturn await agent('late')"), parent })
|
||||
const handle = ctx.workflows.start({ ...scripted("log('mark')\nreturn await agent('late')"), parent })
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(provider.runs.length).toBe(0)
|
||||
@@ -342,7 +347,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
// host cancellation. The trailing narration exercises host-side
|
||||
// suppression: posted pre-cancel-processing worker-side, arriving
|
||||
// post-cancel host-side.
|
||||
script: script(`
|
||||
...scripted(`
|
||||
log('started')
|
||||
const end = Date.now() + 1000
|
||||
while (Date.now() < end) {}
|
||||
@@ -366,7 +371,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
const runEnds: WorkflowResultInfo[] = []
|
||||
ctx.on('workflow/end', (_info, result) => { runEnds.push(result) })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script("await new Promise(() => {})\nreturn 'unreachable'"),
|
||||
...scripted("await new Promise(() => {})\nreturn 'unreachable'"),
|
||||
parent,
|
||||
})
|
||||
handle.cancel('user aborted')
|
||||
@@ -382,7 +387,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
it('dispose() on a stuck script returns within the grace instead of hanging (result settles cancelled)', async () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script("await new Promise(() => {})\nreturn 'unreachable'"),
|
||||
...scripted("await new Promise(() => {})\nreturn 'unreachable'"),
|
||||
parent,
|
||||
})
|
||||
const before = Date.now()
|
||||
@@ -394,7 +399,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
|
||||
it('dispose() is idempotent and settles cleanly after a completed run', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const handle = ctx.workflows.start({ script: script('return 1'), parent })
|
||||
const handle = ctx.workflows.start({ ...scripted('return 1'), parent })
|
||||
await handle.result
|
||||
await handle.dispose()
|
||||
await handle.dispose()
|
||||
@@ -405,7 +410,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
// apart from every other timeout in flight.
|
||||
const GRACE = 44_444
|
||||
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: GRACE } })
|
||||
const handle = ctx.workflows.start({ script: script('return 1'), parent })
|
||||
const handle = ctx.workflows.start({ ...scripted('return 1'), parent })
|
||||
await handle.result
|
||||
const spy = vi.spyOn(globalThis, 'setTimeout')
|
||||
try {
|
||||
@@ -424,7 +429,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
it('strays: children fired without await are aborted once the script settles, and dispose() waits for their disposal', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script(`
|
||||
...scripted(`
|
||||
agent('stray')
|
||||
return 'done without awaiting'
|
||||
`),
|
||||
@@ -468,7 +473,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
ctx.subagents.registerProvider(provider)
|
||||
await ctx.plugin(WorkerWorkflowEngine, { provider: 'signal-only', maxConcurrentAgents: 2 })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script(`
|
||||
...scripted(`
|
||||
agent('stray, never awaited')
|
||||
return 'done'
|
||||
`),
|
||||
@@ -515,7 +520,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
// microtask yields let the agent() continuation POST its child-start
|
||||
// before the spin seizes the worker's loop (the posted message needs
|
||||
// no further worker-loop turns to reach the host).
|
||||
script: script(`
|
||||
...scripted(`
|
||||
agent('wedged child')
|
||||
for (let i = 0; i < 20; i++) await null
|
||||
const end = Date.now() + 1500
|
||||
@@ -560,7 +565,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
// The stray child's start RPC reaches the host, then the script kills
|
||||
// its own worker through the documented vm escape — the host must
|
||||
// settle `error` with the exit diagnostics and wind the child down.
|
||||
script: script(`
|
||||
...scripted(`
|
||||
agent('doomed')
|
||||
const proc = ${ESCAPE}
|
||||
const st = globalThis.constructor.constructor('return setTimeout')()
|
||||
@@ -583,7 +588,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
it('an uncaught exception inside the worker surfaces as an error result and reaps the in-flight child', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script(`
|
||||
...scripted(`
|
||||
agent('in flight when the worker dies')
|
||||
const proc = ${ESCAPE}
|
||||
const st = globalThis.constructor.constructor('return setTimeout')()
|
||||
@@ -613,7 +618,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
// The STRAY child settles instantly, so its wrapper starts the slow
|
||||
// host-side disposal concurrently while the script goes on to kill
|
||||
// its own worker — the ack then resolves into a dead thread.
|
||||
script: script(`
|
||||
...scripted(`
|
||||
agent('stray, never awaited')
|
||||
const proc = ${ESCAPE}
|
||||
const st = globalThis.constructor.constructor('return setTimeout')()
|
||||
@@ -632,7 +637,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
it('a worker death AFTER a cancel reports cancelled, not error', async () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script(`
|
||||
...scripted(`
|
||||
const proc = ${ESCAPE}
|
||||
const st = globalThis.constructor.constructor('return setTimeout')()
|
||||
log('armed')
|
||||
@@ -659,8 +664,8 @@ describe('dsh-workflow-workerthread', () => {
|
||||
const { ctx, parent } = await setup()
|
||||
let eventMeta: WorkflowRunInfo | undefined
|
||||
ctx.on('workflow/start', (info) => { eventMeta = info })
|
||||
const first = ctx.workflows.start({ script: script('return 1'), parent })
|
||||
const second = ctx.workflows.start({ script: script('return 2'), parent })
|
||||
const first = ctx.workflows.start({ ...scripted('return 1'), parent })
|
||||
const second = ctx.workflows.start({ ...scripted('return 2'), parent })
|
||||
expect(first.id).not.toBe(second.id)
|
||||
eventMeta!.meta.name = 'corrupted'
|
||||
expect(second.meta.name).toBe('test-flow')
|
||||
@@ -676,7 +681,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
expect(ctx.get('workflows')).toBeDefined()
|
||||
// A zero-agent run through the DEFAULT config exercises the auto
|
||||
// concurrency resolution (cores - 2, capped) in start().
|
||||
const result = await run(ctx, fakeParent(), script('return 6 * 7'))
|
||||
const result = await run(ctx, fakeParent(), scripted('return 6 * 7'))
|
||||
expect(result.value).toBe(42)
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('workflows')).toBeUndefined()
|
||||
|
||||
Reference in New Issue
Block a user