Skip to content

Commit fe09a2e

Browse files
authored
feat(codemode): support Promise.any and new Promise construction (anomalyco#36339)
1 parent 5920235 commit fe09a2e

10 files changed

Lines changed: 741 additions & 47 deletions

File tree

packages/codemode/codemode.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,13 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
6464
### Tool execution
6565

6666
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
67-
async functions, chained `.then`/`.catch`/`.finally` reactions, `Promise.all`, `Promise.allSettled`, `Promise.race`,
68-
`Promise.resolve`, and `Promise.reject`. Nested functions therefore cannot end the lifetime of work they started.
67+
async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the
68+
`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime
69+
of work they started.
6970
Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler.
70-
`Promise.race` uses native non-cancelling settlement semantics: its first result wins while losers continue running.
71+
`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers
72+
continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the
73+
executor first-class resolve/reject callables that may escape and settle the promise later, exactly once.
7174
Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach
7275
order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count
7376
parity beyond that. At normal completion CodeMode interrupts everything still running - race losers,

packages/codemode/interpreter-support.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ ultimate source of truth.
9494
- [x] Sequence expressions (the comma operator).
9595
- [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its
9696
continuation one reaction turn.
97-
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams.
97+
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
9898
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
9999
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
100100
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -103,15 +103,15 @@ ultimate source of truth.
103103
- [x] Prefix and postfix `++` and `--`.
104104
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
105105
- [ ] Unary `void` and `delete`.
106-
- [ ] Arbitrary constructors and `new Promise(...)`.
106+
- [ ] Arbitrary constructors.
107107

108108
## Promises and tools
109109

110110
- [x] Tool calls start eagerly and return supervised, run-once sandbox promises.
111111
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
112112
- [x] `Promise.resolve` and `Promise.reject`.
113-
- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain
114-
values.
113+
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing
114+
promises and plain values.
115115
- [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings.
116116
- [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records.
117117
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
@@ -130,9 +130,14 @@ ultimate source of truth.
130130
diagnostics. A combinator abandoned inside its final settlement turn counts as pending and is interrupted
131131
without a warning.
132132
- [x] `try`/`catch` can handle awaited tool and promise failures.
133-
- [ ] `Promise.any`.
133+
- [x] `Promise.any`: first fulfillment wins; all-rejected rejects with an `AggregateError` whose `errors` array holds
134+
the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`.
135+
- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject
136+
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
137+
throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the
138+
promise itself rejects with a `TypeError`. Resolver callables work as `.then`/`.catch` handlers and collection
139+
callbacks but remain opaque references that cannot cross the data boundary.
134140
- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises).
135-
- [ ] Custom promise construction with `new Promise(...)`.
136141
- [ ] Async iterables, host streams, and stream consumption.
137142

138143
## Objects and properties
@@ -163,7 +168,7 @@ ultimate source of truth.
163168
- [ ] The mapper and `thisArg` forms of `Array.from`.
164169
- [ ] `Array.prototype.toSpliced`.
165170
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
166-
- [ ] Complete sparse-array parity.
171+
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
167172
- [ ] Correct `findLast` return behavior when its predicate mutates the examined element.
168173

169174
## Strings
@@ -268,6 +273,8 @@ ultimate source of truth.
268273

269274
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
270275
or without `new`.
276+
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
277+
an all-rejected `Promise.any`.
271278
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
272279
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
273280
- [x] Catchable interpreter failures and awaited tool failures.

packages/codemode/src/interpreter/model.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class ComputedValue {
6161

6262
export class PromiseNamespace {}
6363

64-
export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject"
64+
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
6565

6666
export class PromiseMethodReference {
6767
constructor(readonly name: PromiseMethodName) {}
@@ -76,6 +76,12 @@ export class PromiseInstanceMethodReference {
7676
) {}
7777
}
7878

79+
// The resolve/reject callables handed to a `new Promise(executor)` executor. `settle` closes
80+
// over the promise's deferred and is first-settlement-wins; later calls are no-ops, as in JS.
81+
export class PromiseCapabilityFunction {
82+
constructor(readonly settle: (value: unknown) => void) {}
83+
}
84+
7985
export type GlobalNamespaceName =
8086
| "Object"
8187
| "Math"
@@ -131,7 +137,7 @@ export type DiagnosticKind =
131137
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
132138

133139
export const supportedSyntaxMessage =
134-
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls, and promise chaining with .then/.catch/.finally."
140+
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction."
135141

136142
export class InterpreterRuntimeError extends Error {
137143
readonly node?: AstNode

packages/codemode/src/interpreter/runtime.ts

Lines changed: 110 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { parse } from "acorn"
2-
import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect"
2+
import { Cause, Deferred, Effect, Exit, Fiber, Scope, Semaphore } from "effect"
33
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
44
import {
55
copyIn,
@@ -42,6 +42,7 @@ import {
4242
isRecord,
4343
type MemberReference,
4444
OptionalShortCircuit,
45+
PromiseCapabilityFunction,
4546
PromiseInstanceMethodReference,
4647
PromiseMethodReference,
4748
type PromiseMethodName,
@@ -94,6 +95,7 @@ import {
9495
coerceToNumber,
9596
coerceToString,
9697
compoundOperators,
98+
createAggregateErrorValue,
9799
createErrorValue,
98100
errorBrandName,
99101
errorConstructors,
@@ -226,11 +228,22 @@ const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A
226228
const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
227229
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
228230

229-
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction
231+
// Short-circuit marker for Promise.any: the first fulfillment travels the error channel of
232+
// the flipped members so fail-fast Effect.all stops observing on it.
233+
class PromiseAnyFulfilled {
234+
constructor(readonly value: unknown) {}
235+
}
236+
237+
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction
230238

231239
// Non-callables are ignored as in JS: `.then(undefined, f)` relies on the passthrough.
232240
const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => {
233-
if (value instanceof CodeModeFunction || value instanceof CoercionFunction || value instanceof UriFunction) {
241+
if (
242+
value instanceof CodeModeFunction ||
243+
value instanceof CoercionFunction ||
244+
value instanceof UriFunction ||
245+
value instanceof PromiseCapabilityFunction
246+
) {
234247
return value
235248
}
236249
if (typeofValue(value) === "function") {
@@ -250,6 +263,21 @@ const caughtErrorValue = (thrown: unknown): unknown => {
250263
return createErrorValue(name, normalizeError(thrown).message)
251264
}
252265

266+
// `new Error("msg")` and the no-new call form share this; AggregateError alone takes
267+
// (errors, message?) with a required errors collection, as in JS.
268+
const constructErrorValue = (name: string, args: Array<unknown>, node: AstNode): SafeObject => {
269+
if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
270+
const errors = spreadItems(args[0])
271+
if (errors === undefined) {
272+
throw new InterpreterRuntimeError(
273+
"new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).",
274+
node,
275+
).as("TypeError")
276+
}
277+
// Copy: spreadItems returns array input itself, and the error value must not alias caller data.
278+
return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1]))
279+
}
280+
253281
const isRuntimeReference = (value: unknown): boolean =>
254282
value instanceof CodeModeFunction ||
255283
value instanceof ToolReference ||
@@ -262,6 +290,7 @@ const isRuntimeReference = (value: unknown): boolean =>
262290
value instanceof SandboxPromise ||
263291
value instanceof CoercionFunction ||
264292
value instanceof UriFunction ||
293+
value instanceof PromiseCapabilityFunction ||
265294
value instanceof ErrorConstructorReference ||
266295
isSandboxValue(value)
267296

@@ -305,6 +334,7 @@ const typeofValue = (value: unknown): string => {
305334
value instanceof PromiseMethodReference ||
306335
value instanceof PromiseInstanceMethodReference ||
307336
value instanceof PromiseNamespace ||
337+
value instanceof PromiseCapabilityFunction ||
308338
value instanceof ErrorConstructorReference
309339
)
310340
return "function"
@@ -1584,19 +1614,10 @@ class Interpreter<R> {
15841614
const argNodes = getArray(node, "arguments")
15851615
const self = this
15861616
if (name === "Promise") {
1587-
throw new InterpreterRuntimeError(
1588-
"new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.",
1589-
node,
1590-
"UnsupportedSyntax",
1591-
[supportedSyntaxMessage],
1592-
)
1617+
return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) => self.constructPromise(args[0], node))
15931618
}
15941619
if (errorConstructors.has(name)) {
1595-
return Effect.gen(function* () {
1596-
const arg =
1597-
argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined
1598-
return createErrorValue(name, arg === undefined ? "" : coerceToString(arg))
1599-
})
1620+
return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node))
16001621
}
16011622
if (valueConstructors.has(name)) {
16021623
return Effect.gen(function* () {
@@ -2066,7 +2087,11 @@ class Interpreter<R> {
20662087
}
20672088
// `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
20682089
if (callable instanceof ErrorConstructorReference) {
2069-
return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0]))
2090+
return constructErrorValue(callable.name, args, node)
2091+
}
2092+
if (callable instanceof PromiseCapabilityFunction) {
2093+
callable.settle(args[0])
2094+
return undefined
20702095
}
20712096
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
20722097
})
@@ -2255,8 +2280,8 @@ class Interpreter<R> {
22552280
return this.createPromise(Effect.fail(new ProgramThrow(args[0])))
22562281
}
22572282

2258-
const items = spreadItems(args[0])
2259-
if (items === undefined) {
2283+
const spread = spreadItems(args[0])
2284+
if (spread === undefined) {
22602285
return this.createPromise(
22612286
Effect.fail(
22622287
new InterpreterRuntimeError(
@@ -2266,6 +2291,8 @@ class Interpreter<R> {
22662291
),
22672292
)
22682293
}
2294+
// Densify: JS combinator iteration reads sparse holes as undefined members; .map would skip them.
2295+
const items = Array.from(spread)
22692296

22702297
// JS makes combinator members "handled" synchronously at the call - their rejections
22712298
// belong to the aggregate from this moment, even ones settling before it runs.
@@ -2331,6 +2358,29 @@ class Interpreter<R> {
23312358
// and is interrupted at normal completion (already observed) or by teardown.
23322359
return this.createPromise(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
23332360
}
2361+
case "any": {
2362+
// De Morgan dual of Promise.all: members are flipped so the first fulfillment
2363+
// short-circuits fail-fast Effect.all, and all-rejected completes with the reasons
2364+
// in input order for the AggregateError.
2365+
const flipped = items.map((item) =>
2366+
item instanceof SandboxPromise
2367+
? Effect.flatMap(this.promises.await(item), (exit) => {
2368+
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
2369+
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
2370+
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
2371+
})
2372+
: Effect.fail(new PromiseAnyFulfilled(item)),
2373+
)
2374+
const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe(
2375+
Effect.flatMap((reasons) =>
2376+
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
2377+
),
2378+
Effect.catch((error) =>
2379+
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
2380+
),
2381+
)
2382+
return this.createPromise(settleAfterTurn(body))
2383+
}
23342384
}
23352385
}
23362386

@@ -2405,6 +2455,43 @@ class Interpreter<R> {
24052455
)
24062456
}
24072457

2458+
// new Promise(executor): the promise's fiber awaits a Deferred that resolve/reject settle
2459+
// exactly once. The executor runs synchronously; its throw rejects the promise unless it
2460+
// already settled (JS swallows post-settlement executor throws).
2461+
private constructPromise(executor: unknown, node: AstNode): Effect.Effect<SandboxPromise, unknown, R> {
2462+
if (!(executor instanceof CodeModeFunction)) {
2463+
throw new InterpreterRuntimeError(
2464+
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
2465+
node,
2466+
).as("TypeError")
2467+
}
2468+
const self = this
2469+
return Effect.gen(function* () {
2470+
const deferred = Deferred.makeUnsafe<unknown, unknown>()
2471+
const box: { own?: SandboxPromise } = {}
2472+
const promise = yield* self.createPromise(
2473+
Effect.flatMap(Deferred.await(deferred), (value) => {
2474+
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
2475+
if (value === box.own) return Effect.fail(selfResolutionError(node))
2476+
return self.settlePromise(value)
2477+
}),
2478+
)
2479+
box.own = promise
2480+
const resolve = new PromiseCapabilityFunction((value) => {
2481+
Deferred.doneUnsafe(deferred, Exit.succeed(value))
2482+
})
2483+
const reject = new PromiseCapabilityFunction((value) => {
2484+
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value)))
2485+
})
2486+
const executed = yield* Effect.exit(self.invokeFunction(executor, [resolve, reject]))
2487+
if (!Exit.isSuccess(executed)) {
2488+
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
2489+
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
2490+
}
2491+
return promise
2492+
})
2493+
}
2494+
24082495
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
24092496
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits)
24102497
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
@@ -2568,7 +2655,8 @@ class Interpreter<R> {
25682655
if (
25692656
!(callback instanceof CodeModeFunction) &&
25702657
!(callback instanceof CoercionFunction) &&
2571-
!(callback instanceof UriFunction)
2658+
!(callback instanceof UriFunction) &&
2659+
!(callback instanceof PromiseCapabilityFunction)
25722660
) {
25732661
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
25742662
}
@@ -2577,7 +2665,9 @@ class Interpreter<R> {
25772665
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
25782666
: callback instanceof UriFunction
25792667
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
2580-
: this.invokeFunction(callback, callbackArgs)
2668+
: callback instanceof PromiseCapabilityFunction
2669+
? Effect.sync(() => callback.settle(callbackArgs[0]))
2670+
: this.invokeFunction(callback, callbackArgs)
25812671
}
25822672

25832673
private invokeMapMethod(
@@ -3185,7 +3275,7 @@ class Interpreter<R> {
31853275
return new PromiseMethodReference(key as PromiseMethodName)
31863276
}
31873277
throw new InterpreterRuntimeError(
3188-
`Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`,
3278+
`Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.any, Promise.resolve, and Promise.reject; consume promises with await.`,
31893279
propertyNode,
31903280
)
31913281
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { PromiseMethodName } from "../interpreter/model.js"
22

3-
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "resolve", "reject"])
3+
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "any", "resolve", "reject"])
44

55
/** Maximum number of eagerly forked tool calls that may run concurrently. */
66
export const TOOL_CALL_CONCURRENCY = 8

0 commit comments

Comments
 (0)