workflow, subagent: fix Codex code-review round-1 blockers

Six verified A-findings from the code-stage review, each with a regression test:

- parallel()/pipeline() resolved to HOST arrays inside the vm realm, exposing
  host Array.prototype to scripts; combinator results are now realm-built
  (in-realm Array.from bound at context setup).
- materializeFromRealm ran proxy traps (ownKeys/getOwnPropertyDescriptor/
  getPrototypeOf) during the descriptor walk — realm code on the host stack,
  outside the vm timeout, escaping as raw errors; proxies (root, nested, and
  in the prototype position) are now rejected trap-free via util.types.isProxy
  before any inspection.
- an already-aborted signal or an immediate cancel() no longer reports
  'completed' for a hook-free script: drive() checks cancellation before
  running the body and again when the script settles.
- dispose() now waits (bounded by disposeGraceMs) for stray agent() children
  to FINISH disposing, not just for the script to settle: every agent() call
  is tracked and quiesce() drains the in-flight set.
- workflow/* event payloads were live mutable aliases shared across emissions;
  emitWorkflowEvent now hands each listener its own structural clone.
- the structured-output turn-continuation veto is now prepend: true, so an
  earlier-registered force-continue listener cannot short-circuit it.

Docs updated in the same change (READMEs, core-data-structures/workflow.md,
the dynamic-workflows RFC, regenerated cordis catalogs).
This commit is contained in:
Tianyi Cui
2026-07-05 19:04:38 +08:00
parent 1d43ea3cd5
commit e264a106fd
17 changed files with 358 additions and 51 deletions

View File

@@ -108,6 +108,14 @@ return 2`
expect(error.message).toContain('JSON data')
})
it('rejects a meta literal containing a proxy as META_INVALID — its traps never run', () => {
// bad() rethrows anything that is not a WorkflowError, so a trap firing
// ('trap ran') would fail this test instead of mapping to META_INVALID.
const error = bad('export const meta = { name: "x", description: "d", phases: new Proxy([], { getPrototypeOf() { throw new Error("trap ran") } }) }')
expect(error.code).toBe('META_INVALID')
expect(error.message).toContain('proxies cannot cross')
})
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')

View File

@@ -81,6 +81,27 @@ describe('materializeFromRealm', () => {
expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 })
})
it('rejects proxies (root, nested, revoked, host-realm) WITHOUT running any trap', () => {
const trapped = inRealm(`new Proxy({ a: 1 }, {
ownKeys() { throw new Error('trap ran') },
getOwnPropertyDescriptor() { throw new Error('trap ran') },
getPrototypeOf() { throw new Error('trap ran') },
})`)
// A trap firing would surface 'trap ran' (a non-MaterializeError) instead.
expect(rejection(trapped)).toContain('proxies cannot cross')
expect(rejection(inRealm('{ nested: new Proxy([], {}) }'))).toContain('value.nested')
const revoked = inRealm('(() => { const r = Proxy.revocable({}, {}); r.revoke(); return r.proxy })()')
expect(rejection(revoked)).toContain('proxies cannot cross')
expect(rejection(new Proxy({}, {}))).toContain('proxies cannot cross')
})
it('rejects an object whose PROTOTYPE is a proxy without dereferencing through it', () => {
const value = inRealm(`Object.create(new Proxy({}, {
getPrototypeOf() { throw new Error('trap ran') },
}))`)
expect(rejection(value)).toContain('exotic prototype')
})
it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => {
expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular')
const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()')

View File

@@ -36,6 +36,7 @@ class StubProvider implements SubagentProvider {
constructor(
readonly name: string,
private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult,
private readonly disposeDelayMs = 0,
) {}
start(request: SubagentStartRequest): SubagentRun {
@@ -57,8 +58,17 @@ class StubProvider implements SubagentProvider {
settle({ output: [], stopReason: 'aborted' })
},
dispose: () => {
controlled.disposed = true
return Promise.resolve()
if (this.disposeDelayMs === 0) {
controlled.disposed = true
return Promise.resolve()
}
// A slow-winding child (quiescence tests): disposal completes late.
return new Promise<void>((resolve) => {
setTimeout(() => {
controlled.disposed = true
resolve()
}, this.disposeDelayMs)
})
},
}
}
@@ -73,12 +83,17 @@ interface SetupOptions {
config?: Config
reply?: (request: SubagentStartRequest, index: number) => SubagentResult
manual?: boolean
disposeDelayMs?: number
}
async function setup(options?: SetupOptions) {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('stub', options?.manual ? undefined : options?.reply ?? (() => text('stub reply')))
const provider = new StubProvider(
'stub',
options?.manual ? undefined : options?.reply ?? (() => text('stub reply')),
options?.disposeDelayMs ?? 0,
)
ctx.subagents.registerProvider(provider)
await ctx.plugin(VmWorkflowEngine, { provider: 'stub', ...options?.config })
return { ctx, provider, parent: fakeParent() }
@@ -405,6 +420,50 @@ describe('dsh-workflow-vm', () => {
expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined')
})
it('parallel/pipeline resolve to REALM arrays: instanceof holds in-script, host intrinsics stay unreachable', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
const fromParallel = await parallel([() => agent('a'), () => 'plain'])
const fromPipeline = await pipeline([1], (prev) => prev + 1)
Object.getPrototypeOf(fromParallel).polluted = 'realm-only'
return {
parallelIsRealmArray: fromParallel instanceof Array,
pipelineIsRealmArray: fromPipeline instanceof Array,
values: [fromParallel[1], fromPipeline[0]],
}
`))
expect(result.stopReason).toBe('completed')
expect(result.value).toEqual({
parallelIsRealmArray: true,
pipelineIsRealmArray: true,
values: ['plain', 2],
})
// The script's prototype mutation stayed realm-side: the HOST
// Array.prototype was never reachable through a combinator result.
expect(([] as unknown as Record<string, unknown>).polluted).toBeUndefined()
})
it('a returned proxy is rejected as RESULT_UNSERIALIZABLE without running its traps', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
return new Proxy({ a: 1 }, { ownKeys() { throw new Error('trap ran') } })
`))
expect(result.stopReason).toBe('error')
expect(result.error).toContain('not plain JSON data')
expect(result.error).toContain('proxies cannot cross')
expect(result.error).not.toContain('trap ran')
})
it('agent() options passed as a proxy are rejected loudly, traps never invoked', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
return await agent('p', new Proxy({}, { ownKeys() { throw new Error('trap ran') } }))
`))
expect(result.stopReason).toBe('error')
expect(result.error).toContain('options must be plain JSON data')
expect(result.error).not.toContain('trap ran')
})
it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => {
const { ctx, parent } = await setup()
const withDate = await run(ctx, parent, script('return { when: new Date(0) }'))
@@ -452,6 +511,51 @@ describe('dsh-workflow-vm', () => {
await handle.dispose()
})
it('an already-aborted signal cancels a HOOK-FREE script: the body never runs at all', async () => {
const { ctx, parent } = await setup()
const controller = new AbortController()
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 result = await handle.result
expect(result.stopReason).toBe('cancelled')
expect(result.value).toBeNull()
expect(logs).toEqual([])
await handle.dispose()
})
it('cancel() right after start() reports cancelled even when the script needed no hooks', async () => {
const { ctx, parent } = await setup()
const handle = ctx.workflows.start({ script: script('return 123'), parent })
handle.cancel('changed my mind')
const result = await handle.result
expect(result.stopReason).toBe('cancelled')
expect(result.value).toBeNull()
expect(result.error).toContain('changed my mind')
await handle.dispose()
})
it('an agent() call AFTER a mid-run cancel rejects at entry — no child ever starts', async () => {
const { ctx, parent, provider } = await setup({ manual: true })
const handle = ctx.workflows.start({
script: script(`
await agent('first')
return await agent('second')
`),
parent,
})
await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
// Same synchronous block: the first child settles completed, then the
// cancel lands BEFORE the script's continuation can call agent() again.
provider.runs[0]!.settle(text('first done'))
handle.cancel('mid-run')
const result = await handle.result
expect(result.stopReason).toBe('cancelled')
expect(provider.runs.length).toBe(1)
await handle.dispose()
})
it('the signal aborting mid-run cancels like cancel()', async () => {
const { ctx, parent, provider } = await setup({ manual: true })
const controller = new AbortController()
@@ -577,6 +681,24 @@ describe('dsh-workflow-vm', () => {
})
await handle.dispose()
})
it('dispose() waits for a stray child to FINISH disposing (quiescence), not just the script settle', async () => {
const { ctx, parent, provider } = await setup({ manual: true, disposeDelayMs: 40 })
const handle = ctx.workflows.start({
script: script(`
agent('stray')
return 'done without awaiting'
`),
parent,
})
const result = await handle.result
expect(result.stopReason).toBe('completed')
expect(provider.runs.length).toBe(1)
await handle.dispose()
// Not a waitFor: by the time dispose() returns, the slow child disposal
// must already be complete.
expect(provider.runs[0]!.disposed).toBe(true)
})
})
describe('service surface', () => {
@@ -595,6 +717,24 @@ describe('dsh-workflow-vm', () => {
await second.dispose()
})
it('a listener mutating one event payload cannot corrupt later events (per-emission snapshots)', async () => {
const { ctx, parent } = await setup()
const ends: unknown[] = []
let endInfo: WorkflowRunInfo | undefined
ctx.on('workflow/agent-start', (info, agent) => {
agent.seq = 999
agent.label = 'HACKED'
info.meta.name = 'HACKED'
})
ctx.on('workflow/agent-end', (info, agent) => {
ends.push(agent)
endInfo = info
})
await run(ctx, parent, script("return await agent('job', { label: 'honest' })"))
expect(ends[0]).toMatchObject({ seq: 1, label: 'honest', outcome: 'completed' })
expect(endInfo!.meta.name).toBe('test-flow')
})
it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)