workflow: simplify to the trust premise; settle result on cancellation

Two review responses that belong together — the same review argued the
engine was defending the wrong threat while a benign-input bug wedged
the product.

1) Drop hostile-value containment; state the trust premise.

Scripts are model-written — the same trust level as the model's bash
access — yet successive pre-push review rounds had ratcheted in defenses
that only matter against an adversarial author: trap-free proxy
rejection, accessor-never-invoked descriptor walks, realm-side
pre-rendering of thrown values, realm-built promises/arrays/error clones
with structural fatal recognition. That same author keeps a documented,
accepted, unkillable event-loop spin, so containing its error VALUES is
cost without a threat model — and the planned hardened engine
(worker/isolated-vm) gets value isolation by serialization and deletes
all of this machinery anyway.

What stays, because benign scripts hit it constantly: result never
rejects; dropped hook promises cannot become unhandled rejections; the
value boundary rejects LOUD everything JSON cannot carry (now a plain
recursive walk — getters are read ordinarily and their result is what
crosses; a throwing read fails loud); a "__proto__" key still copies as
a data property; the fatal-vs-null combinator discipline (now host
instanceof — unforgeable from the realm and simpler than clone-shape
recognition). What changes for scripts (documented in the engine
README): hooks hand back host values and host errors — in-script
`instanceof Error` on a hook failure is false (branch on e.name/e.code)
— and args are host-cloned once so a script cannot mutate the caller's
object. realm.ts drops 289 → 173 lines; the hostile-value test tables go
with it. The premise now leads the engine module doc, the README, and
the RFC's engine section, with the removed machinery recorded under
What was rejected.

2) result settles within the dispose grace of a cancellation.

Review finding (verified through the real registry + tool + engine): a
script parked on a promise no hook owns — `await new Promise(() => {})`,
`await Promise.race([])`, a returned never-settling thenable — could not
be settled by cancel(): hooks reject and children abort, but nothing
touches a promise the engine does not own, so `result` stayed pending
FOREVER (the previous cut even pinned that as intended). The tool awaits
run.result BEFORE its disposing finally, the registry awaits the tool,
the loop awaits the registry — one such script wedged the whole agent
turn past any abort, unrecoverable in-process; the mock engine in the
tool's abort test settles result on cancel, which is exactly the
behavior the real engine lacked, so no existing test could see it.

The seam contract now says it out loud: once a run is cancelled, result
SETTLES within the implementation's bounded grace even if the script
never does. The vm engine arms an abandon channel in cancel(); drive()
races the script against it, force-settling 'cancelled' at the grace
(the abandoned settlement stays contained; a post-slice synchronous spin
remains the documented limitation). dispose()'s outer race now exists
for child quiescence only, and `workflow/end` again fires exactly once
per started run. The old 'result stays pending' pin is FLIPPED to the
new contract (the pinned behavior was the bug); new regressions cover
cancel-then-settle on a parked script, a never-settling returned
thenable, and the full composition through the REAL registry + tool +
vm engine (tool-workflow gains workflow-vm/subagent devDeps for it).
agentsStarted JSDoc clarified while touching the vocabulary (accepted
calls, including ones still queued at cancellation).
This commit is contained in:
Tianyi Cui
2026-07-06 00:48:49 +08:00
parent 7234d41b91
commit 2accf85714
16 changed files with 352 additions and 609 deletions
@@ -103,29 +103,19 @@ return 2`
})
it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => {
const error = bad('export const meta = { name: "x", description: "d", phases: [{ get title() { return "t" } }] }')
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('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('a meta expression THROWING a hostile value maps to META_INVALID — rendering stays realm-side', () => {
// bad() rethrows anything that is not a WorkflowError, so a hostile value
// escaping the realm-side renderer raw would fail this test.
const error = bad('export const meta = { name: (() => { throw { get stack() { throw new Error("boom") }, toString() { throw new Error("boom") } } })(), description: "d" }\nreturn 1')
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('[unrenderable thrown value]')
expect(error.message).toContain('nope')
})
it('a spinning meta expression (even inside a thrown stack getter) dies by the eval timeout', () => {
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')
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import * as vm from 'node:vm'
import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering } from '../src/realm.ts'
import { materializeFromRealm, MaterializeError, renderThrown } from '../src/realm.ts'
/** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */
function inRealm(expression: string): unknown {
@@ -35,17 +35,21 @@ describe('materializeFromRealm', () => {
expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a')
})
it('never invokes accessors: a counting getter is rejected, not read', () => {
it('invokes getters ordinarily — the getter RESULT is what crosses (trust premise)', () => {
const counter = inRealm(`
(() => {
globalThis.reads = 0
return { get x() { globalThis.reads += 1; return 1 } }
return { get x() { globalThis.reads += 1; return globalThis.reads } }
})()
`)
expect(rejection(counter)).toContain('accessor properties cannot cross')
// The getter body never ran — descriptor inspection only.
expect((counter as { x?: unknown }).x).toBe(1) // sanity: reading DOES run it…
expect(rejection(counter)).toContain('accessor') // …but materialization still never did
expect(materializeFromRealm(counter)).toEqual({ x: 1 })
})
it('a getter that THROWS surfaces as a MaterializeError carrying the rendered failure', () => {
const hostile = inRealm("{ get x() { throw new Error('read failed') } }")
const message = rejection(hostile)
expect(message).toContain('reading the value threw')
expect(message).toContain('read failed')
})
it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => {
@@ -81,39 +85,18 @@ 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 } })()')
expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } })
})
it('rejects sparse arrays, accessor elements, and non-index array properties', () => {
it('rejects sparse arrays and non-index array properties; an array getter element materializes its value', () => {
expect(rejection(inRealm('[1, , 3]'))).toContain('sparse')
expect(rejection(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 1 }); return a })()')))
.toContain('accessor')
expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()')))
.toContain('non-index')
expect(materializeFromRealm(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 7, enumerable: true }); return a })()')))
.toEqual([7])
})
it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => {
@@ -134,45 +117,29 @@ describe('materializeFromRealm', () => {
})
})
describe('describeThrown (host-side thrown-value rendering)', () => {
it('renders a HOST Error via its identity-verified native stack getter', () => {
const error = new Error('host failure')
const rendered = describeThrown(error)
expect(rendered).toContain('host failure')
expect(rendered).toContain('at ') // a real stack, not just the message
})
it('never invokes a REALM error stack getter (identity mismatch) — message renders instead', () => {
describe('renderThrown', () => {
it('prefers the stack, for host and realm errors alike', () => {
const host = renderThrown(new Error('host failure'))
expect(host).toContain('host failure')
expect(host).toContain('at ') // a real stack, not just the message
const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()')
expect(describeThrown(realmError)).toBe('realm failure')
expect(renderThrown(realmError)).toContain('realm failure')
})
it('reads a data-property stack directly and falls through a setter-only accessor', () => {
expect(describeThrown({ stack: 'data stack' })).toBe('data stack')
const setterOnly = { message: 'via message' }
Object.defineProperty(setterOnly, 'stack', { set() { /* swallow */ } })
expect(describeThrown(setterOnly)).toBe('via message')
it('falls back from stack to message to String()', () => {
expect(renderThrown({ stack: 'custom data stack' })).toBe('custom data stack')
const stackless = new Error('stackless failure')
delete stackless.stack
expect(renderThrown(stackless)).toBe('stackless failure')
expect(renderThrown({ code: 42 })).toBe('[object Object]')
expect(renderThrown('plain')).toBe('plain')
expect(renderThrown(42)).toBe('42')
expect(renderThrown(undefined)).toBe('undefined')
expect(renderThrown(null)).toBe('null')
})
it('labels proxies and functions without touching them; primitives stringify', () => {
expect(describeThrown(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBe('[thrown proxy]')
expect(describeThrown(() => 1)).toBe('[thrown function]')
expect(describeThrown('plain')).toBe('plain')
expect(describeThrown(42)).toBe('42')
expect(describeThrown(undefined)).toBe('undefined')
expect(describeThrown(null)).toBe('null')
expect(describeThrown({ code: 42 })).toBe('[object Object]')
})
})
describe('thrownRendering (the realm-catch wrapper reader)', () => {
it('extracts the pre-rendered string from a wrapper and nothing else', () => {
expect(thrownRendering({ __wfThrown: 'rendered text' })).toBe('rendered text')
expect(thrownRendering({ __wfThrown: 42 })).toBeUndefined()
expect(thrownRendering({ other: 'x' })).toBeUndefined()
expect(thrownRendering(new Error('plain'))).toBeUndefined()
expect(thrownRendering('string')).toBeUndefined()
expect(thrownRendering(null)).toBeUndefined()
expect(thrownRendering(new Proxy({ __wfThrown: 'forged' }, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBeUndefined()
it('is total: a value whose accessors/toString throw renders as a fixed label', () => {
expect(renderThrown({ get stack() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
expect(renderThrown({ [Symbol.toPrimitive]() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
})
})
@@ -289,22 +289,13 @@ describe('dsh-workflow-vm', () => {
() => agent('fine'),
() => 'plain value',
() => { throw 'string throw' },
() => { throw new Proxy({ name: 'WorkflowError', fatal: true }, {}) },
() => { throw { name: 'WorkflowError', fatal: 'forged-but-not-true' } },
() => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } },
])
`))
// The last three probe the fatal-clone recognition: a non-object, a
// proxy (never inspected), and a shape miss are all ordinary nulls.
expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null, null])
})
it('a script forging a fatal clone kills only its own run (self-sabotage, not a bypass)', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
return await parallel([() => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } }])
`))
expect(result.stopReason).toBe('error')
expect(result.error).toContain('forged fatal')
// The last entry probes fatality: it is recognized by host instanceof,
// which a script-built object can never pass — a WorkflowError-SHAPED
// throw is an ordinary null, and real fatality cannot be forged.
expect(result.value).toEqual([null, 'stub reply', 'plain value', null, null])
})
it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => {
@@ -386,11 +377,12 @@ describe('dsh-workflow-vm', () => {
expect((await run(ctx, parent, script("return await agent('p', { effort: 'high' })"))).error).toContain('"effort" is deferred')
})
it('rejects options that are not plain JSON data (an accessor smuggled into opts)', async () => {
it('rejects options whose property reads throw (materialization is loud, not silent)', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script("return await agent('p', { get label() { return 'x' } })"))
const result = await run(ctx, parent, script("return await agent('p', { get label() { throw new Error('read failed') } })"))
expect(result.stopReason).toBe('error')
expect(result.error).toContain('options must be plain JSON data')
expect(result.error).toContain('read failed')
})
it('validates phase() and log() arguments loudly', async () => {
@@ -416,7 +408,7 @@ describe('dsh-workflow-vm', () => {
})
})
describe('determinism bans and realm isolation', () => {
describe('determinism bans and the value boundary', () => {
it('Date.now, Math.random, and argless new Date throw; parameterized Date stays usable', async () => {
const { ctx, parent } = await setup()
expect((await run(ctx, parent, script('return Date.now()'))).error).toContain('Date.now() is not available')
@@ -426,18 +418,16 @@ describe('dsh-workflow-vm', () => {
expect(ok.value).toBe(0)
})
it('args cross into the realm as data: mutating them (or their prototype chain) cannot reach host intrinsics', async () => {
it('args are cloned at start: a script scribbling on them cannot mutate the caller\'s object', async () => {
const { ctx, parent } = await setup()
const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } }
const result = await run(ctx, parent, script(`
args.files.push('b.ts')
Object.getPrototypeOf(args).polluted = 'realm-only'
return { count: args.files.length, deep: args.nested.deep[1] }
`), hostArgs)
expect(result.value).toEqual({ count: 2, deep: 2 })
// The host copy is untouched, and the HOST Object.prototype was never reachable.
// The caller's object is untouched (the engine cloned args host-side).
expect(hostArgs.files).toEqual(['a.ts'])
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
it('scalar/null args pass through directly; absent args leave the global undefined', async () => {
@@ -447,51 +437,24 @@ describe('dsh-workflow-vm', () => {
expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined')
})
it('hook promises are REALM promises: instanceof holds in-script, host Promise.prototype stays unreachable', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
const p = agent('x')
const par = parallel([() => 'v'])
const pipe = pipeline([1], (n) => n)
Object.getPrototypeOf(p).wfLeakProbe = 'realm-only'
return {
agentIsRealmPromise: p instanceof Promise,
parallelIsRealmPromise: par instanceof Promise,
pipelineIsRealmPromise: pipe instanceof Promise,
value: await p,
}
`))
expect(result.stopReason).toBe('completed')
expect(result.value).toEqual({
agentIsRealmPromise: true,
parallelIsRealmPromise: true,
pipelineIsRealmPromise: true,
value: 'stub reply',
})
expect((Promise.prototype as unknown as Record<string, unknown>).wfLeakProbe).toBeUndefined()
delete (Promise.prototype as unknown as Record<string, unknown>).wfLeakProbe
})
it('hook failures cross the boundary as realm-built WorkflowError clones', async () => {
it('hook failures reach the script as HOST WorkflowErrors: fields readable, in-realm instanceof Error is false', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
try {
await agent('p', { bogus: true })
return 'unreachable'
} catch (e) {
Object.getPrototypeOf(Object.getPrototypeOf(e)).wfErrLeakProbe = 'realm-only'
// The documented consequence of the trust premise: hook errors are
// host objects, so realm instanceof is false — read the fields.
return { isRealmError: e instanceof Error, name: e.name, code: e.code, fatal: e.fatal, message: e.message }
}
`))
expect(result.stopReason).toBe('completed')
expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true })
expect(result.value).toMatchObject({ isRealmError: false, name: 'WorkflowError', code: 'UNSUPPORTED_OPTION', fatal: true })
expect((result.value as { message: string }).message).toContain('"bogus" is not recognized')
// The script mutated its error's prototype CHAIN — host intrinsics untouched.
expect((Object.prototype as unknown as Record<string, unknown>).wfErrLeakProbe).toBeUndefined()
expect((Error.prototype as unknown as Record<string, unknown>).wfErrLeakProbe).toBeUndefined()
})
it('a non-WorkflowError host failure (a rejecting provider result) crosses as a generic realm clone', async () => {
it('a non-WorkflowError host failure (a rejecting provider result) reaches the script raw', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider: SubagentProvider = {
@@ -507,68 +470,34 @@ describe('dsh-workflow-vm', () => {
ctx.subagents.registerProvider(provider)
await ctx.plugin(VmWorkflowEngine, { provider: 'rejecting' })
const result = await run(ctx, fakeParent(), script(`
try { await agent('p'); return 'unreachable' } catch (e) { return { isRealmError: e instanceof Error, name: e.name, message: e.message } }
try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, message: e.message } }
`))
expect(result.value).toMatchObject({ isRealmError: true, name: 'Error' })
expect(result.value).toMatchObject({ name: 'Error' })
expect((result.value as { message: string }).message).toContain('backend exploded')
})
it('phase()/log() synchronous throws cross as realm clones too', async () => {
it('phase()/log() throw host WorkflowErrors synchronously on misuse', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
try { phase(3) } catch (e) {
if (!(e instanceof Error) || e.name !== 'WorkflowError') throw e
if (e.name !== 'WorkflowError') throw e
}
try { log(3) } catch (e) {
return { isRealmError: e instanceof Error, name: e.name, message: e.message }
return { name: e.name, message: e.message }
}
`))
expect(result.value).toMatchObject({ isRealmError: true, name: 'WorkflowError' })
expect(result.value).toMatchObject({ name: 'WorkflowError' })
expect((result.value as { message: string }).message).toContain('log() requires')
})
it('parallel/pipeline resolve to REALM arrays: instanceof holds in-script, host intrinsics stay unreachable', async () => {
it('a returned value whose property reads throw fails loud as RESULT_UNSERIALIZABLE', 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') } })
return { get a() { throw new Error('read failed') } }
`))
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')
expect(result.error).toContain('read failed')
})
it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => {
@@ -696,60 +625,6 @@ describe('dsh-workflow-vm', () => {
expect(result.error).toBe('[object Object]')
})
it('hostile thrown values render realm-side: result NEVER rejects, no unhandled rejection', async () => {
const unhandled: unknown[] = []
const onUnhandled = (reason: unknown): void => { unhandled.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
const { ctx, parent } = await setup()
// Each thrown value runs code (or throws) when rendered — the realm
// wrapper renders it INSIDE script execution, and the host catch only
// ever descriptor-reads the pre-rendered string.
const cases: [string, string][] = [
["throw { get stack() { throw new Error('stack getter threw') } }", '[object Object]'],
["throw { get stack() { throw new Error('x') }, message: 'getter threw, message renders' }", 'getter threw, message renders'],
["throw { get message() { throw new Error('message getter threw') } }", '[object Object]'],
["throw { stack: 'custom data stack' }", 'custom data stack'],
["throw (() => { const o = { message: 'setter-only stack' }; Object.defineProperty(o, 'stack', { set() {} }); return o })()", 'setter-only stack'],
["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('gopd trap threw') } })", '[object Object]'],
["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive threw') } }", '[unrenderable thrown value]'],
['throw () => 1', '() => 1'],
['throw null', 'null'],
]
for (const [body, rendered] of cases) {
const result = await run(ctx, parent, script(body))
expect(result.stopReason).toBe('error')
expect(result.error).toBe(rendered)
}
// Let any stray rejection reach the process hook before asserting.
await new Promise(resolve => setTimeout(resolve, 20))
expect(unhandled).toEqual([])
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('a synchronous spin hidden in a thrown stack getter dies by the vm timeout, not on the host', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } })
// The realm-side renderer reads e.stack INSIDE the timed sync slice, so
// the spin is killed exactly like a plain `while (true) {}` body.
const result = await run(ctx, parent, script('throw { get stack() { while (true) {} } }'))
expect(result.stopReason).toBe('error')
expect(result.error?.toLowerCase()).toContain('timed out')
})
it('a hostile thenable rejection that bypasses the realm wrapper renders host-side, data-only', async () => {
const { ctx, parent } = await setup()
// Returning a thenable makes the host unwrap it AFTER the script
// settled — its rejection value skips the realm catch entirely and hits
// drive()'s catch raw. The proxy must be labelled, its traps never run.
const result = await run(ctx, parent, script(`
return { then(_resolve, reject) { reject(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })) } }
`))
expect(result.stopReason).toBe('error')
expect(result.error).toBe('[thrown proxy]')
})
it('falls back to the message for an Error whose stack was stripped', async () => {
const { ctx, parent } = await setup()
const result = await run(ctx, parent, script(`
@@ -803,18 +678,43 @@ describe('dsh-workflow-vm', () => {
}
})
it('dispose() abandons a stuck script after the grace instead of hanging (result stays pending)', async () => {
it('cancel() force-settles the result of a script parked on a promise no hook owns', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } })
const handle = ctx.workflows.start({
// No hooks involved: an unsettleable await cancellation cannot reject
// — the abandon grace is the only thing that can settle this run.
script: script("await new Promise(() => {})\nreturn 'unreachable'"),
parent,
})
handle.cancel('user aborted')
const result = await handle.result
expect(result.stopReason).toBe('cancelled')
expect(result.error).toContain('user aborted')
await handle.dispose()
})
it('a never-settling returned thenable is abandoned the same way', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } })
const handle = ctx.workflows.start({ script: script('return { then() {} }'), parent })
handle.cancel()
expect((await handle.result).stopReason).toBe('cancelled')
await handle.dispose()
})
it('dispose() abandons a stuck script after the grace instead of hanging (result settles cancelled)', async () => {
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } })
const handle = ctx.workflows.start({
// No hooks involved: an unsettleable await the engine cannot reject.
script: script("await new Promise(() => {})\nreturn 'unreachable'"),
parent,
})
const before = Date.now()
await handle.dispose()
expect(Date.now() - before).toBeLessThan(1000)
const settled = await Promise.race([handle.result.then(() => 'settled'), Promise.resolve('pending')])
expect(settled).toBe('pending')
// The abandon that freed dispose() also settled result — a consumer
// still awaiting it (the tool does, before its disposing finally) is
// released rather than wedged forever.
const result = await handle.result
expect(result.stopReason).toBe('cancelled')
})
it('dispose() is idempotent and settles cleanly after a completed run', async () => {