@kuralle-syrinx/core 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { PipelineBus } from "../pipeline-bus.js";
4
+ import type { InteractionDecision, InteractionObservation, InteractionPolicy } from "../interaction-policy.js";
5
+ import { PrimarySpeakerGate } from "../primary-speaker-gate.js";
6
+ import { TtsPlayoutClock } from "../tts-playout-clock.js";
7
+ import { TurnArbiter } from "../turn-arbiter.js";
8
+
9
+ export interface RuleBasedInteractionPolicyDeps {
10
+ readonly bus: PipelineBus;
11
+ readonly primarySpeakerGate: PrimarySpeakerGate;
12
+ readonly ttsPlayout: TtsPlayoutClock;
13
+ readonly minInterruptionMs: number;
14
+ }
15
+
16
+ export class RuleBasedInteractionPolicy implements InteractionPolicy {
17
+ readonly arbiter: TurnArbiter;
18
+ private readonly ttsPlayout: TtsPlayoutClock;
19
+ private pending: InteractionDecision[] = [];
20
+ private bargeInAudioConsumed = false;
21
+ private delegateGapOpen = false;
22
+ private delegateCuePlayed = false;
23
+ private userSpeaking = false;
24
+
25
+ constructor(deps: RuleBasedInteractionPolicyDeps) {
26
+ this.ttsPlayout = deps.ttsPlayout;
27
+ this.arbiter = new TurnArbiter({
28
+ ...deps,
29
+ onInterrupt: (id) => this.pending.push({ kind: "interrupt", interruptedContextId: id }),
30
+ });
31
+ }
32
+
33
+ observe(obs: InteractionObservation): readonly InteractionDecision[] {
34
+ switch (obs.kind) {
35
+ case "vad_speech_started":
36
+ this.userSpeaking = true;
37
+ if (obs.interruptedContextId) this.arbiter.onSpeechStarted(
38
+ {
39
+ kind: "vad.speech_started",
40
+ contextId: obs.contextId,
41
+ timestampMs: obs.timestampMs,
42
+ confidence: obs.confidence,
43
+ },
44
+ obs.interruptedContextId,
45
+ );
46
+ break;
47
+ case "vad_speech_activity":
48
+ this.arbiter.onSpeechActivity({
49
+ kind: "vad.speech_activity",
50
+ contextId: obs.contextId,
51
+ timestampMs: obs.timestampMs,
52
+ isAsync: true,
53
+ });
54
+ break;
55
+ case "vad_speech_ended":
56
+ this.userSpeaking = false;
57
+ this.arbiter.onSpeechEnded(
58
+ {
59
+ kind: "vad.speech_ended",
60
+ contextId: obs.contextId,
61
+ timestampMs: obs.timestampMs,
62
+ },
63
+ obs.hasActiveTts,
64
+ );
65
+ break;
66
+ case "vad_barge_in_audio":
67
+ this.bargeInAudioConsumed = this.arbiter.observeBargeInAudio({
68
+ kind: "vad.audio",
69
+ contextId: obs.contextId,
70
+ timestampMs: obs.timestampMs,
71
+ audio: obs.audio,
72
+ });
73
+ break;
74
+ case "stt_partial":
75
+ this.arbiter.noteInterimEvidence(obs.text);
76
+ if (obs.interruptedContextId) {
77
+ this.arbiter.onProviderSttEvidence(obs.contextId, obs.timestampMs, obs.interruptedContextId);
78
+ }
79
+ break;
80
+ case "stt_final":
81
+ this.arbiter.noteInterimEvidence(obs.text, obs.confidence);
82
+ if (obs.interruptedContextId) {
83
+ this.arbiter.onProviderSttEvidence(obs.contextId, obs.timestampMs, obs.interruptedContextId);
84
+ }
85
+ break;
86
+ case "delegate_state":
87
+ this.pending.push(...this.handleDelegateState(obs));
88
+ break;
89
+ default:
90
+ break;
91
+ }
92
+ if (this.pending.length === 0) return [];
93
+ const out = this.pending;
94
+ this.pending = [];
95
+ return out;
96
+ }
97
+
98
+ private handleDelegateState(obs: InteractionObservation & { kind: "delegate_state" }): InteractionDecision[] {
99
+ const phase = obs.toolCallPhase;
100
+ if (!phase) return [];
101
+
102
+ switch (phase) {
103
+ case "started":
104
+ this.delegateGapOpen = true;
105
+ this.delegateCuePlayed = false;
106
+ return [];
107
+ case "delayed": {
108
+ if (!this.delegateGapOpen || this.delegateCuePlayed) return [];
109
+ if (this.depsTtsActive()) return [];
110
+ if (this.userSpeaking) return [];
111
+ this.delegateCuePlayed = true;
112
+ return [{ kind: "backchannel", cue: "mm_hmm" }];
113
+ }
114
+ case "complete":
115
+ case "failed":
116
+ this.delegateGapOpen = false;
117
+ this.delegateCuePlayed = false;
118
+ return [];
119
+ default:
120
+ return [];
121
+ }
122
+ }
123
+
124
+ private depsTtsActive(): boolean {
125
+ return this.ttsPlayout.activeContexts().length > 0;
126
+ }
127
+
128
+ reset(_contextId: string): void {
129
+ this.delegateGapOpen = false;
130
+ this.delegateCuePlayed = false;
131
+ this.userSpeaking = false;
132
+ this.arbiter.clear();
133
+ }
134
+
135
+ takeBargeInAudioConsumed(): boolean {
136
+ const consumed = this.bargeInAudioConsumed;
137
+ this.bargeInAudioConsumed = false;
138
+ return consumed;
139
+ }
140
+ }
@@ -0,0 +1,361 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { PipelineBusImpl } from "./pipeline-bus.js";
6
+ import type { ConversationMetricPacket } from "./packets.js";
7
+ import type { Reasoner, ReasonerTurn, ReasoningPart } from "./reasoner.js";
8
+ import { HedgedReasoner } from "./reasoner-hedge.js";
9
+ import type { ScheduledCallback, Scheduler } from "./scheduler.js";
10
+
11
+ class FakeScheduler implements Scheduler {
12
+ private readonly callbacks = new Map<string, ScheduledCallback>();
13
+
14
+ schedule(key: string, _delayMs: number, cb: ScheduledCallback): void {
15
+ this.callbacks.set(key, cb);
16
+ }
17
+
18
+ cancel(key: string): void {
19
+ this.callbacks.delete(key);
20
+ }
21
+
22
+ fire(key: string): void {
23
+ const cb = this.callbacks.get(key);
24
+ if (!cb) return;
25
+ this.callbacks.delete(key);
26
+ void cb();
27
+ }
28
+
29
+ has(key: string): boolean {
30
+ return this.callbacks.has(key);
31
+ }
32
+ }
33
+
34
+ class ControllableReasoner implements Reasoner {
35
+ streamInvoked = false;
36
+ capturedSignal: AbortSignal | undefined;
37
+ private deliver: ((part: ReasoningPart) => void) | null = null;
38
+
39
+ stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
40
+ this.streamInvoked = true;
41
+ this.capturedSignal = turn.signal;
42
+ const queue: ReasoningPart[] = [];
43
+ let wake: (() => void) | null = null;
44
+
45
+ this.deliver = (part: ReasoningPart) => {
46
+ queue.push(part);
47
+ wake?.();
48
+ wake = null;
49
+ };
50
+
51
+ async function* generator(): AsyncGenerator<ReasoningPart> {
52
+ while (!turn.signal.aborted) {
53
+ if (queue.length === 0) {
54
+ await new Promise<void>((resolve) => {
55
+ if (turn.signal.aborted) {
56
+ resolve();
57
+ return;
58
+ }
59
+ wake = resolve;
60
+ turn.signal.addEventListener("abort", () => resolve(), { once: true });
61
+ });
62
+ }
63
+ if (turn.signal.aborted) return;
64
+ const part = queue.shift();
65
+ if (part === undefined) return;
66
+ yield part;
67
+ if (part.type === "error" || part.type === "suspended" || part.type === "finish") return;
68
+ }
69
+ }
70
+
71
+ return generator();
72
+ }
73
+
74
+ emit(part: ReasoningPart): void {
75
+ this.deliver?.(part);
76
+ }
77
+ }
78
+
79
+ function baseTurn(signal = new AbortController().signal): ReasonerTurn {
80
+ return { userText: "hi", messages: [{ role: "user", content: "hi" }], signal };
81
+ }
82
+
83
+ async function collect(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
84
+ const parts: ReasoningPart[] = [];
85
+ for await (const part of reasoner.stream(turn)) {
86
+ parts.push(part);
87
+ }
88
+ return parts;
89
+ }
90
+
91
+ function silentReasoner(hook?: (turn: ReasonerTurn) => void): Reasoner {
92
+ return {
93
+ stream(turn) {
94
+ hook?.(turn);
95
+ return (async function*() {
96
+ await new Promise<void>(() => {});
97
+ })();
98
+ },
99
+ };
100
+ }
101
+
102
+ function scriptedReasoner(
103
+ parts: readonly ReasoningPart[],
104
+ hook?: (turn: ReasonerTurn) => void,
105
+ ): Reasoner {
106
+ return {
107
+ stream(turn) {
108
+ hook?.(turn);
109
+ return (async function*() {
110
+ for (const part of parts) {
111
+ if (turn.signal.aborted) return;
112
+ yield part;
113
+ }
114
+ })();
115
+ },
116
+ };
117
+ }
118
+
119
+ describe("HedgedReasoner", () => {
120
+ it("(a) primary fast — backup never started", async () => {
121
+ const primary = new ControllableReasoner();
122
+ const backup = new ControllableReasoner();
123
+ const scheduler = new FakeScheduler();
124
+ const hedge = new HedgedReasoner({
125
+ primary,
126
+ backup,
127
+ hedgeAfterMs: 50,
128
+ scheduler,
129
+ });
130
+
131
+ const streamPromise = collect(hedge, baseTurn());
132
+ primary.emit({ type: "text-delta", text: "hello" });
133
+ primary.emit({ type: "finish", reason: "stop", text: "hello" });
134
+
135
+ const parts = await streamPromise;
136
+
137
+ expect(backup.streamInvoked).toBe(false);
138
+ expect(scheduler.has("hedge")).toBe(false);
139
+ expect(parts).toEqual([
140
+ { type: "text-delta", text: "hello" },
141
+ { type: "finish", reason: "stop", text: "hello" },
142
+ ]);
143
+ });
144
+
145
+ it("(b) hedge fires, primary still wins", async () => {
146
+ const primary = new ControllableReasoner();
147
+ const backup = new ControllableReasoner();
148
+ const scheduler = new FakeScheduler();
149
+ const bus = new PipelineBusImpl();
150
+ const started = bus.start();
151
+ const metrics: ConversationMetricPacket[] = [];
152
+ bus.on("metric.conversation", (pkt) => {
153
+ metrics.push(pkt as ConversationMetricPacket);
154
+ });
155
+
156
+ const hedge = new HedgedReasoner({
157
+ primary,
158
+ backup,
159
+ hedgeAfterMs: 50,
160
+ scheduler,
161
+ bus,
162
+ contextId: "ctx-1",
163
+ });
164
+
165
+ const streamPromise = collect(hedge, baseTurn());
166
+ await Promise.resolve();
167
+ scheduler.fire("hedge");
168
+ await Promise.resolve();
169
+ expect(backup.streamInvoked).toBe(true);
170
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.fired", value: "1" }));
171
+
172
+ primary.emit({ type: "text-delta", text: "primary" });
173
+ backup.emit({ type: "text-delta", text: "backup" });
174
+ primary.emit({ type: "finish", reason: "stop", text: "primary" });
175
+
176
+ const parts = await streamPromise;
177
+
178
+ expect(parts).toEqual([
179
+ { type: "text-delta", text: "primary" },
180
+ { type: "finish", reason: "stop", text: "primary" },
181
+ ]);
182
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.committed_to", value: "primary" }));
183
+ expect(backup.capturedSignal?.aborted).toBe(true);
184
+
185
+ bus.stop();
186
+ await started;
187
+ });
188
+
189
+ it("(c) backup wins — no interleaving", async () => {
190
+ let primarySignal: AbortSignal | undefined;
191
+ let backupStarted = false;
192
+ const scheduler = new FakeScheduler();
193
+ const hedge = new HedgedReasoner({
194
+ primary: silentReasoner((turn) => {
195
+ primarySignal = turn.signal;
196
+ }),
197
+ backup: scriptedReasoner(
198
+ [
199
+ { type: "text-delta", text: "backup" },
200
+ { type: "finish", reason: "stop", text: "backup" },
201
+ ],
202
+ () => {
203
+ backupStarted = true;
204
+ },
205
+ ),
206
+ hedgeAfterMs: 10,
207
+ scheduler,
208
+ });
209
+
210
+ const streamPromise = collect(hedge, baseTurn());
211
+ await Promise.resolve();
212
+ scheduler.fire("hedge");
213
+ const parts = await streamPromise;
214
+
215
+ expect(backupStarted).toBe(true);
216
+ expect(parts).toEqual([
217
+ { type: "text-delta", text: "backup" },
218
+ { type: "finish", reason: "stop", text: "backup" },
219
+ ]);
220
+ expect(primarySignal?.aborted).toBe(true);
221
+ });
222
+
223
+ it("(d) loser aborted after commit", async () => {
224
+ let primarySignal: AbortSignal | undefined;
225
+ let backupSignal: AbortSignal | undefined;
226
+ const scheduler = new FakeScheduler();
227
+ const hedge = new HedgedReasoner({
228
+ primary: silentReasoner((turn) => {
229
+ primarySignal = turn.signal;
230
+ }),
231
+ backup: scriptedReasoner(
232
+ [
233
+ { type: "text-delta", text: "backup" },
234
+ { type: "finish", reason: "stop", text: "backup" },
235
+ ],
236
+ (turn) => {
237
+ backupSignal = turn.signal;
238
+ },
239
+ ),
240
+ hedgeAfterMs: 5,
241
+ scheduler,
242
+ });
243
+
244
+ const streamPromise = collect(hedge, baseTurn());
245
+ await Promise.resolve();
246
+ scheduler.fire("hedge");
247
+ await streamPromise;
248
+
249
+ expect(primarySignal?.aborted).toBe(true);
250
+ expect(backupSignal?.aborted).toBe(false);
251
+ });
252
+
253
+ it("(e) pre-commit primary error fails over to backup", async () => {
254
+ let backupStarted = false;
255
+ const hedge = new HedgedReasoner({
256
+ primary: scriptedReasoner([
257
+ {
258
+ type: "error",
259
+ cause: new Error("primary down"),
260
+ recoverable: true,
261
+ },
262
+ ]),
263
+ backup: scriptedReasoner(
264
+ [
265
+ { type: "text-delta", text: "recovered" },
266
+ { type: "finish", reason: "stop", text: "recovered" },
267
+ ],
268
+ () => {
269
+ backupStarted = true;
270
+ },
271
+ ),
272
+ hedgeAfterMs: 100,
273
+ scheduler: new FakeScheduler(),
274
+ });
275
+
276
+ const parts = await collect(hedge, baseTurn());
277
+
278
+ expect(backupStarted).toBe(true);
279
+ expect(parts).toEqual([
280
+ { type: "text-delta", text: "recovered" },
281
+ { type: "finish", reason: "stop", text: "recovered" },
282
+ ]);
283
+ expect(parts.some((p) => p.type === "error")).toBe(false);
284
+ });
285
+
286
+ it("(f) post-commit error forwarded verbatim", async () => {
287
+ const primary = new ControllableReasoner();
288
+ const backup = new ControllableReasoner();
289
+ const hedge = new HedgedReasoner({
290
+ primary,
291
+ backup,
292
+ hedgeAfterMs: 100,
293
+ scheduler: new FakeScheduler(),
294
+ });
295
+
296
+ const streamPromise = collect(hedge, baseTurn());
297
+ primary.emit({ type: "text-delta", text: "partial" });
298
+ const err: ReasoningPart = {
299
+ type: "error",
300
+ cause: new Error("mid-stream"),
301
+ recoverable: false,
302
+ };
303
+ primary.emit(err);
304
+ backup.emit({ type: "text-delta", text: "backup-late" });
305
+
306
+ const parts = await streamPromise;
307
+
308
+ expect(parts).toEqual([
309
+ { type: "text-delta", text: "partial" },
310
+ err,
311
+ ]);
312
+ expect(backup.streamInvoked).toBe(false);
313
+ });
314
+
315
+ it("(g) metrics — fired iff backup started, committed_to reflects winner; no bus is safe", async () => {
316
+ const scheduler = new FakeScheduler();
317
+ const bus = new PipelineBusImpl();
318
+ const started = bus.start();
319
+ const metrics: ConversationMetricPacket[] = [];
320
+ bus.on("metric.conversation", (pkt) => {
321
+ metrics.push(pkt as ConversationMetricPacket);
322
+ });
323
+
324
+ const hedge = new HedgedReasoner({
325
+ primary: silentReasoner(),
326
+ backup: scriptedReasoner([
327
+ { type: "text-delta", text: "b" },
328
+ { type: "finish", reason: "stop", text: "b" },
329
+ ]),
330
+ hedgeAfterMs: 10,
331
+ scheduler,
332
+ bus,
333
+ contextId: "ctx-m",
334
+ });
335
+
336
+ const streamPromise = collect(hedge, baseTurn());
337
+ await Promise.resolve();
338
+ scheduler.fire("hedge");
339
+ await streamPromise;
340
+
341
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.fired", value: "1" }));
342
+ expect(metrics).toContainEqual(expect.objectContaining({ name: "hedge.committed_to", value: "backup" }));
343
+
344
+ const noBusPrimary = new ControllableReasoner();
345
+ const noBusBackup = new ControllableReasoner();
346
+ const noBusHedge = new HedgedReasoner({
347
+ primary: noBusPrimary,
348
+ backup: noBusBackup,
349
+ hedgeAfterMs: 10,
350
+ scheduler: new FakeScheduler(),
351
+ });
352
+
353
+ const noBusPromise = collect(noBusHedge, baseTurn());
354
+ noBusPrimary.emit({ type: "text-delta", text: "ok" });
355
+ noBusPrimary.emit({ type: "finish", reason: "stop", text: "ok" });
356
+ await expect(noBusPromise).resolves.toHaveLength(2);
357
+
358
+ bus.stop();
359
+ await started;
360
+ });
361
+ });
@@ -0,0 +1,216 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { Route, type PipelineBus } from "./pipeline-bus.js";
4
+ import * as make from "./packet-factories.js";
5
+ import type { Reasoner, ReasonerTurn, ReasoningPart } from "./reasoner.js";
6
+ import { TimerScheduler, type Scheduler } from "./scheduler.js";
7
+
8
+ export interface HedgedReasonerOptions {
9
+ readonly primary: Reasoner;
10
+ readonly backup: Reasoner;
11
+ readonly hedgeAfterMs: number;
12
+ readonly bus?: PipelineBus;
13
+ readonly contextId?: string;
14
+ readonly scheduler?: Scheduler;
15
+ }
16
+
17
+ type Backend = "primary" | "backup";
18
+
19
+ type RacerResult = { who: Backend; result: IteratorResult<ReasoningPart> };
20
+
21
+ const asRacer = (who: Backend, next: Promise<IteratorResult<ReasoningPart>>): Promise<RacerResult> =>
22
+ next
23
+ .then((result) => ({ who, result }))
24
+ .catch((err): RacerResult => ({
25
+ who,
26
+ result: {
27
+ done: false,
28
+ value: {
29
+ type: "error",
30
+ cause: err instanceof Error ? err : new Error(String(err)),
31
+ recoverable: true,
32
+ },
33
+ },
34
+ }));
35
+
36
+ type CommitResult =
37
+ | { readonly ok: true; readonly winner: Backend; readonly first: ReasoningPart; readonly iter: AsyncIterator<ReasoningPart> }
38
+ | { readonly ok: false; readonly error: ReasoningPart };
39
+
40
+ export class HedgedReasoner implements Reasoner {
41
+ private readonly scheduler: Scheduler;
42
+
43
+ constructor(private readonly opts: HedgedReasonerOptions) {
44
+ this.scheduler = opts.scheduler ?? new TimerScheduler();
45
+ }
46
+
47
+ stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
48
+ return this.doStream(turn);
49
+ }
50
+
51
+ private async *doStream(turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
52
+ const commit = await this.raceToCommit(turn);
53
+ if (!commit.ok) {
54
+ yield commit.error;
55
+ return;
56
+ }
57
+
58
+ yield commit.first;
59
+ let tail = await commit.iter.next();
60
+ while (!tail.done) {
61
+ yield tail.value;
62
+ tail = await commit.iter.next();
63
+ }
64
+ }
65
+
66
+ private async raceToCommit(turn: ReasonerTurn): Promise<CommitResult> {
67
+ const pc = new AbortController();
68
+ const bc = new AbortController();
69
+
70
+ if (turn.signal.aborted) {
71
+ pc.abort();
72
+ bc.abort();
73
+ return { ok: false, error: abortedError() };
74
+ }
75
+
76
+ turn.signal.addEventListener(
77
+ "abort",
78
+ () => {
79
+ pc.abort();
80
+ bc.abort();
81
+ },
82
+ { once: true },
83
+ );
84
+
85
+ let committed = false;
86
+ let primaryExhausted = false;
87
+ let backupExhausted = false;
88
+ let lastError: ReasoningPart | null = null;
89
+
90
+ const primaryIter = this.opts.primary.stream({ ...turn, signal: pc.signal })[Symbol.asyncIterator]();
91
+ let primaryNext: Promise<IteratorResult<ReasoningPart>> = primaryIter.next();
92
+
93
+ let backupIter: AsyncIterator<ReasoningPart> | null = null;
94
+ let backupNext: Promise<IteratorResult<ReasoningPart>> | null = null;
95
+ let repollRace: (() => void) | null = null;
96
+
97
+ const ensureBackup = (): void => {
98
+ if (backupIter) return;
99
+ backupIter = this.opts.backup.stream({ ...turn, signal: bc.signal })[Symbol.asyncIterator]();
100
+ backupNext = backupIter.next();
101
+ this.metric("hedge.fired", "1");
102
+ repollRace?.();
103
+ };
104
+
105
+ this.scheduler.schedule("hedge", this.opts.hedgeAfterMs, () => {
106
+ if (!committed) ensureBackup();
107
+ });
108
+
109
+ while (!committed) {
110
+ const raced = await new Promise<{ who: Backend; result: IteratorResult<ReasoningPart> } | "repoll">(
111
+ (resolve) => {
112
+ const racers: Array<Promise<RacerResult>> = [];
113
+
114
+ if (!primaryExhausted) {
115
+ racers.push(asRacer("primary", primaryNext));
116
+ }
117
+ if (backupNext && !backupExhausted) {
118
+ racers.push(asRacer("backup", backupNext));
119
+ }
120
+
121
+ if (racers.length === 0) {
122
+ repollRace = null;
123
+ resolve("repoll");
124
+ return;
125
+ }
126
+
127
+ repollRace = () => resolve("repoll");
128
+ void Promise.race(racers).then((winner) => {
129
+ repollRace = null;
130
+ resolve(winner);
131
+ });
132
+ },
133
+ );
134
+
135
+ if (raced === "repoll") {
136
+ if (primaryExhausted && (backupExhausted || !backupNext)) {
137
+ return { ok: false, error: lastError ?? abortedError() };
138
+ }
139
+ continue;
140
+ }
141
+
142
+ const { who, result } = raced;
143
+
144
+ if (result.done) {
145
+ if (who === "primary") {
146
+ primaryExhausted = true;
147
+ if (!backupIter) {
148
+ this.scheduler.cancel("hedge");
149
+ ensureBackup();
150
+ } else if (backupExhausted) {
151
+ return { ok: false, error: lastError ?? abortedError() };
152
+ }
153
+ } else {
154
+ backupExhausted = true;
155
+ if (primaryExhausted) {
156
+ return { ok: false, error: lastError ?? abortedError() };
157
+ }
158
+ }
159
+ continue;
160
+ }
161
+
162
+ const part = result.value;
163
+
164
+ if (part.type === "error") {
165
+ lastError = part;
166
+ if (who === "primary") {
167
+ primaryExhausted = true;
168
+ if (!backupIter) {
169
+ this.scheduler.cancel("hedge");
170
+ ensureBackup();
171
+ }
172
+ if (backupExhausted) {
173
+ return { ok: false, error: part };
174
+ }
175
+ } else {
176
+ backupExhausted = true;
177
+ if (primaryExhausted) {
178
+ return { ok: false, error: part };
179
+ }
180
+ }
181
+ continue;
182
+ }
183
+
184
+ committed = true;
185
+ this.scheduler.cancel("hedge");
186
+
187
+ if (who === "primary") {
188
+ bc.abort();
189
+ releaseIterator(backupIter);
190
+ this.metric("hedge.committed_to", "primary");
191
+ return { ok: true, winner: "primary", first: part, iter: primaryIter };
192
+ }
193
+
194
+ pc.abort();
195
+ releaseIterator(primaryIter);
196
+ this.metric("hedge.committed_to", "backup");
197
+ return { ok: true, winner: "backup", first: part, iter: backupIter! };
198
+ }
199
+
200
+ return { ok: false, error: lastError ?? abortedError() };
201
+ }
202
+
203
+ private metric(name: string, value: string): void {
204
+ this.opts.bus?.push(Route.Background, make.metric(this.opts.contextId ?? "", name, value));
205
+ }
206
+ }
207
+
208
+ function releaseIterator(iter: AsyncIterator<ReasoningPart> | null): void {
209
+ if (!iter) return;
210
+ const release = iter.return;
211
+ if (release) void release.call(iter, undefined);
212
+ }
213
+
214
+ function abortedError(): ReasoningPart {
215
+ return { type: "error", cause: new Error("aborted"), recoverable: true };
216
+ }