mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 10:13:24 +00:00
fix(codemode): return promises from combinators
This commit is contained in:
parent
da68e2865e
commit
326dd7e8e7
3 changed files with 175 additions and 67 deletions
|
|
@ -63,8 +63,10 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
|
|||
### Tool execution
|
||||
|
||||
Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
|
||||
awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently.
|
||||
Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
|
||||
awaited directly or through `Promise.all`, `Promise.allSettled`, and `Promise.race`. These combinators also return eager,
|
||||
run-once sandbox promises, so independent aggregate batches overlap and rejection is observed at the eventual `await`.
|
||||
`Promise.resolve` and `Promise.reject` use the same tracked lifecycle. At most eight tool calls execute concurrently.
|
||||
Unfinished promises are drained before successful program completion, and an unhandled failure becomes a diagnostic.
|
||||
|
||||
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
|
||||
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
|
||||
|
|
|
|||
|
|
@ -2212,19 +2212,21 @@ class Interpreter<R> {
|
|||
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
|
||||
// promise already fulfilled with the value.
|
||||
const value = args[0]
|
||||
return Effect.succeed(
|
||||
value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)),
|
||||
)
|
||||
return value instanceof SandboxPromise ? Effect.succeed(value) : this.createPromise(Effect.succeed(value))
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
|
||||
return this.createPromise(Effect.fail(new ProgramThrow(args[0])))
|
||||
}
|
||||
|
||||
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
|
||||
if (items === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
||||
node,
|
||||
return this.createPromise(
|
||||
Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
||||
node,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2238,7 +2240,7 @@ class Interpreter<R> {
|
|||
? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit }))
|
||||
: Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const aggregate = Effect.gen(function* () {
|
||||
const remaining = [...observations]
|
||||
const values: Array<unknown> = []
|
||||
values.length = items.length
|
||||
|
|
@ -2250,19 +2252,25 @@ class Interpreter<R> {
|
|||
values[winner.index] = winner.exit.value
|
||||
continue
|
||||
}
|
||||
yield* self.createPromise(
|
||||
Effect.asVoid(
|
||||
Effect.forEach(
|
||||
items,
|
||||
(item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* self.unwrapPromiseExit(winner.item, winner.exit, node)
|
||||
}
|
||||
return values
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const promise = yield* self.createPromise(aggregate)
|
||||
// Keep observing every member after fail-fast settlement without tying the drain
|
||||
// fiber to the aggregate fiber, whose completion interrupts its own children.
|
||||
yield* self.createPromise(
|
||||
Effect.asVoid(
|
||||
Effect.forEach(
|
||||
items,
|
||||
(item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
)
|
||||
return promise
|
||||
})
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
|
|
@ -2270,42 +2278,48 @@ class Interpreter<R> {
|
|||
? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
|
||||
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const outcomes: Array<unknown> = []
|
||||
for (const observation of observations) {
|
||||
const { exit, promise } = yield* observation
|
||||
if (Exit.isSuccess(exit)) {
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
|
||||
if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
|
||||
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
const thrown = raceInterrupted
|
||||
? new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
return this.createPromise(
|
||||
Effect.gen(function* () {
|
||||
const outcomes: Array<unknown> = []
|
||||
for (const observation of observations) {
|
||||
const { exit, promise } = yield* observation
|
||||
if (Exit.isSuccess(exit)) {
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
: Cause.squash(exit.cause)
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(thrown),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return outcomes
|
||||
})
|
||||
continue
|
||||
}
|
||||
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
|
||||
if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
|
||||
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
const thrown = raceInterrupted
|
||||
? new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
)
|
||||
: Cause.squash(exit.cause)
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(thrown),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return outcomes
|
||||
}),
|
||||
)
|
||||
}
|
||||
case "race": {
|
||||
if (items.length === 0) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
return this.createPromise(
|
||||
Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
const observations = items.map((item, index) =>
|
||||
|
|
@ -2313,22 +2327,24 @@ class Interpreter<R> {
|
|||
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
||||
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
|
||||
// racing them yields exactly that. Losing in-flight calls are then interrupted.
|
||||
const winner = yield* Effect.raceAll(observations)
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
|
||||
item.interrupted = true
|
||||
yield* Fiber.interrupt(item.fiber)
|
||||
}
|
||||
const winningItem = items[winner.index]
|
||||
return yield* self.unwrapPromiseExit(
|
||||
winningItem instanceof SandboxPromise ? winningItem : undefined,
|
||||
winner.exit,
|
||||
node,
|
||||
)
|
||||
})
|
||||
return this.createPromise(
|
||||
Effect.gen(function* () {
|
||||
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
|
||||
// racing them yields exactly that. Losing in-flight calls are then interrupted.
|
||||
const winner = yield* Effect.raceAll(observations)
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
|
||||
item.interrupted = true
|
||||
yield* Fiber.interrupt(item.fiber)
|
||||
}
|
||||
const winningItem = items[winner.index]
|
||||
return yield* self.unwrapPromiseExit(
|
||||
winningItem instanceof SandboxPromise ? winningItem : undefined,
|
||||
winner.exit,
|
||||
node,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,6 +270,59 @@ describe("promises at data boundaries", () => {
|
|||
})
|
||||
|
||||
describe("Promise.all over arbitrary arrays", () => {
|
||||
test("combinators return promises that can be assigned and awaited later", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const all = Promise.all([Promise.resolve(1)])
|
||||
const settled = Promise.allSettled([Promise.reject("no")])
|
||||
const race = Promise.race([Promise.resolve(2)])
|
||||
const promises = [all instanceof Promise, settled instanceof Promise, race instanceof Promise]
|
||||
return [promises, await all, await settled, await race]
|
||||
`),
|
||||
).toEqual([[true, true, true], [1], [{ status: "rejected", reason: "no" }], 2])
|
||||
})
|
||||
|
||||
test("separately-created aggregate batches overlap before either is awaited", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(
|
||||
`
|
||||
const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })])
|
||||
const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
|
||||
return [await first, await second]
|
||||
`,
|
||||
{ trace },
|
||||
),
|
||||
).toEqual([[1], [2]])
|
||||
expect(trace.starts).toEqual([1, 2])
|
||||
expect(trace.maxActive).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("an aggregate created before a try block rejects at its later await", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const aggregate = Promise.all([tools.host.fail({})])
|
||||
try {
|
||||
await aggregate
|
||||
return "no"
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
})
|
||||
|
||||
test("awaiting an aggregate repeatedly does not rerun its members", async () => {
|
||||
const result = await run(`
|
||||
const aggregate = Promise.all([tools.host.sleepy({ id: 7 })])
|
||||
return [await aggregate, await aggregate]
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toEqual([[7], [7]])
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
|
||||
})
|
||||
|
||||
test("mixes promises and plain values, preserving order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
|
|
@ -374,6 +427,30 @@ describe("Promise.all over arbitrary arrays", () => {
|
|||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
|
||||
test("drains a later sibling rejection after failing fast", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(
|
||||
`
|
||||
const failLater = async () => {
|
||||
await tools.host.sleepy({ id: 1, ms: 40 })
|
||||
throw new Error("later")
|
||||
}
|
||||
const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
|
||||
try {
|
||||
await aggregate
|
||||
return "no"
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
`,
|
||||
{ trace },
|
||||
),
|
||||
).toBe("first")
|
||||
expect(trace.completed).toBe(1)
|
||||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
|
||||
test("a non-collection argument is a clear error", async () => {
|
||||
const diagnostic = await error(`return await Promise.all(42)`)
|
||||
expect(diagnostic.message).toContain("Promise.all expects an array")
|
||||
|
|
@ -484,6 +561,9 @@ describe("Promise.resolve / Promise.reject", () => {
|
|||
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
|
||||
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
|
||||
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
|
||||
expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test("reject produces a promise whose await throws the reason", async () => {
|
||||
|
|
@ -498,6 +578,16 @@ describe("Promise.resolve / Promise.reject", () => {
|
|||
`),
|
||||
).toBe("nope")
|
||||
})
|
||||
|
||||
test("an abandoned rejected promise is reported as unhandled", async () => {
|
||||
const diagnostic = await error(`
|
||||
Promise.reject(new Error("abandoned"))
|
||||
return "done"
|
||||
`)
|
||||
expect(diagnostic.kind).toBe("ExecutionFailure")
|
||||
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
|
||||
expect(diagnostic.message).toContain("abandoned")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timeout interruption of forked calls", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue