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:
@@ -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')
|
||||
|
||||
@@ -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 } })()')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user