workflow: pin thenable-return semantics as documented async-JS behavior

Codex code-review round 4 flagged the return channel: an async IIFE
Promise-assimilates a returned thenable, so its then() runs past the sync
slice and the RESOLUTION replaces the raw object. Verified against the real
engine and judged behavior, not defect:

- Assimilation is standard JavaScript (an async function's returned thenable
  resolves before the caller sees it) and is load-bearing ergonomics: an
  un-awaited 'return agent(...)' / 'return parallel(...)' resolves to the
  intended value precisely because of it. Rejecting callable-then returns
  would break that; intercepting pre-assimilation is spec-impossible (the
  Get(v,'then') and job enqueue are internal to promise resolution).
- The realm-boundary guard applies to the RESOLUTION (a thenable resolving to
  non-JSON is still RESULT_UNSERIALIZABLE), so nothing crosses unmaterialized.
- A spin inside a returned thenable's then() is the same accepted class as any
  post-slice spin (it runs on the microtask queue, past the vm timeout's
  reach); the docs previously said 'after the first await', which was too
  narrow — reworded to 'past the initial synchronous slice (an await
  continuation, or a thenable's then invoked by promise resolution)'.

Pinned with an engine test (un-awaited return agent(); custom thenable
resolution as the return value; thenable resolving to non-JSON rejects), and
the limitation wording updated in the module doc, README, and RFC.
This commit is contained in:
Tianyi Cui
2026-07-05 20:57:51 +08:00
parent fff2e1f33d
commit 95c8c878e1
4 changed files with 26 additions and 9 deletions
@@ -217,6 +217,19 @@ describe('dsh-workflow-vm', () => {
expect(result.stopReason).toBe('completed')
expect(result.value).toBeNull()
})
it('a returned promise/thenable resolves per async-JS semantics before materialization', async () => {
const { ctx, parent } = await setup()
// Load-bearing ergonomics: forgetting await on the final hook call works.
expect((await run(ctx, parent, script("return agent('x')"))).value).toBe('stub reply')
// A hand-built thenable is assimilated by the async return — the
// RESOLUTION is the script's return value (standard JavaScript), and the
// realm-boundary guard applies to that resolution, not the thenable.
expect((await run(ctx, parent, script('return { value: 1, then(resolve) { resolve({ ok: true }) } }'))).value).toEqual({ ok: true })
const nonJson = await run(ctx, parent, script('return { then(resolve) { resolve({ bad: new Date(0) }) } }'))
expect(nonJson.stopReason).toBe('error')
expect(nonJson.error).toContain('not plain JSON data')
})
})
describe('combinator semantics', () => {