-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagentDriver.ts
More file actions
586 lines (541 loc) · 24.3 KB
/
Copy pathagentDriver.ts
File metadata and controls
586 lines (541 loc) · 24.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
/**
* The turn/step machine — AMC running an agent (plan P3.2).
*
* Ported from dsh's `ReactLoopAgent` (`packages/core/agent-loop/src/agent.ts`),
* driven entirely through `SessionService` so that every fact the loop produces
* is a signed, hash-chained evidence row rather than a log line.
*
* THE SHAPE, AND WHY IT IS THIS SHAPE.
*
* A TURN is a bracket around ONE claim from the inbox. It opens with
* `turn/start` and closes with `turn/end` IN A FINALLY — on every exit path,
* including a throw and including a cancellation. That finally is not
* defensive tidiness: it is the entire mechanism by which a cancelled turn is
* BALANCED rather than truncated. A truncated turn is indistinguishable from a
* crashed one, and this project's whole position is that "someone stopped this
* agent" and "this agent died" are different facts that a log must keep apart.
*
* A STEP is one model request plus the tool calls it asked for. It opens with
* `step/start` and closes with `step/end`, also in a finally, so the same
* balance holds one level down — crash repair synthesises a `step/end` before
* a `turn/end` for exactly this reason, and a live loop that skipped it would
* produce a shape recovery never produces.
*
* EVERY ENDING IS ASSIGNED AT A DEFINITE SITE. `complete` when the model asked
* for nothing more or a tool concluded the turn; `max_tokens` when a response
* hit the output ceiling (and it is STICKY — a later clean step must not erase
* the fact that output was truncated); `max_steps` at the loop's own cap;
* `blocked` when a pre-step listener vetoed; `error` on any non-abort throw;
* `cancelled` when the signal aborted, carrying the cause the canceller named.
* `interrupted` is unreachable from here by construction: the spine's writer
* refuses it from a live origin.
*
* AN UNWRITABLE `turn/end` IS FATAL. If the spine cannot commit the closer, the
* evidence is already incomplete, and continuing would layer a second turn onto
* a log whose first turn is open. The driver enters a terminal `failed` state
* and does NOT seal — sealing a window with no `turn/end` is the one shape that
* reads as tidy while hiding a gap. The open turn is left exactly as a crash
* would leave it, for `recoverSession` to close as `interrupted`, which is what
* it in fact is from the log's point of view: this process stopped being able to
* write.
*/
import type { SessionEventRef, SessionService } from "../session/sessionService.js";
import type { TokenUsage, TurnCancelCause, TurnTrigger } from "../session/sessionTypes.js";
import { isTurnCancelCause } from "../session/sessionTypes.js";
import { LoopInbox } from "./inbox.js";
import type { NativeImageInput } from "../attachments/nativeImageInput.js";
import { recordNativeImageMessage } from "./nativeImageMessage.js";
import type { NativeInputPart } from "../attachments/nativeOrderedInput.js";
import { assertOrderedClaimDecision, recordNativeOrderedMessage } from "./nativeOrderedMessage.js";
import type { NativeAudioPart } from "../attachments/nativeAudioInput.js";
import { assertAudioClaimDecision, recordNativeAudioMessage } from "./nativeAudioMessage.js";
import { DEFAULT_RETRY_RUNTIME } from "./requestRetry.js";
import {
DEFAULT_AGENT_LOOP_CONFIG,
NO_HOOKS,
toTurnEndParams,
type AgentLoopConfig,
type AgentStatus,
type CancelOptions,
type InboxMessage,
type InboxReceipt,
type InboxTarget,
type LoopHooks,
type LoopNotification,
type LoopRetryRuntime,
type PreStepDecision,
type TurnEnding
} from "./loopTypes.js";
import { runStep, type LoopLlm, type LoopRoute, type StepRunnerInit } from "./stepRunner.js";
import { EMPTY_TOOL_SEAM, type AgentToolSeam } from "./toolSeam.js";
import { freezeNativeValidationPlan, NativeValidationTurn, type NativeValidationPlan } from "./nativeValidation.js";
export interface AgentDriverInit {
readonly session: SessionService;
readonly llm: LoopLlm;
readonly route: LoopRoute;
/** The `system/prompt` row this run cites. P3.3 replaces it with an assembly seam. */
readonly systemPromptEventId: string;
readonly tools?: AgentToolSeam;
readonly validation?: NativeValidationPlan;
readonly hooks?: LoopHooks;
readonly config?: Partial<AgentLoopConfig>;
/**
* How a request-boundary retry waits and jitters.
*
* Injected rather than defaulted at the point of use, so a test can pin both
* and assert the delay the signed `loop/retry` row records. Production leaves
* it out and gets {@link DEFAULT_RETRY_RUNTIME}.
*/
readonly retryRuntime?: LoopRetryRuntime;
}
/** Two live states plus one terminal one. */
type Phase =
| { readonly kind: "idle"; readonly lastTurn: number }
| { kind: "running"; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
| { readonly kind: "failed"; readonly cause: unknown };
/** A pre-step that decided to enter, with the batch it decided to enter with. */
type PreparedStep = Extract<PreStepDecision, { kind: "enter" }>;
export class AgentDriver {
readonly inbox: LoopInbox;
private readonly session: SessionService;
private readonly hooks: LoopHooks;
private readonly config: AgentLoopConfig;
private readonly stepInit: StepRunnerInit;
private readonly validation: NativeValidationPlan | undefined;
private phase: Phase = { kind: "idle", lastTurn: 0 };
private activityDone: Promise<void> = Promise.resolve();
/** The last row this driver committed. Named by a `loop/cancel` as its observed head. */
private head: SessionEventRef | null = null;
constructor(init: AgentDriverInit) {
this.session = init.session;
this.validation = init.validation === undefined ? undefined : freezeNativeValidationPlan(init.validation);
this.hooks = init.hooks ?? NO_HOOKS;
this.config = { ...DEFAULT_AGENT_LOOP_CONFIG, ...init.config };
this.inbox = new LoopInbox(init.session, (notification) => {
this.hooks.notify(notification);
});
this.stepInit = {
session: init.session,
llm: init.llm,
tools: init.tools ?? EMPTY_TOOL_SEAM,
route: init.route,
systemPromptEventId: init.systemPromptEventId,
config: this.config,
retryRuntime: init.retryRuntime ?? DEFAULT_RETRY_RUNTIME,
// Tool-produced context enters the SAME durable inbox a human steer does,
// so there is one path and one audit story for anything that speaks to the
// model between steps.
acceptContext: (text: string) => {
this.inbox.insert("next-step", text, "tool", { wake: false, demotedFrom: null });
},
notify: (notification: LoopNotification) => {
this.hooks.notify(notification);
}
};
}
get status(): AgentStatus {
return this.phase.kind;
}
get sessionId(): string {
return this.session.sessionId;
}
/**
* Route input to an inbox lane and optionally wake the driver.
*
* Waking input cannot join an activity that is already aborting — it would be
* claimed by a turn on its way out — so it starts the next turn instead. The
* classification is captured BEFORE the insertion so that a cancel triggered
* re-entrantly by an observer of the insertion cannot reclassify it.
*/
send(text: string, target: InboxTarget, wakeup: boolean, images?: readonly NativeImageInput[]): InboxReceipt {
return this.enqueue(text, target, wakeup, images);
}
/** Preserve the same lane, wake, abort-demotion and durable admission machinery. */
sendParts(parts: readonly NativeInputPart[], target: InboxTarget, wakeup: boolean): InboxReceipt {
return this.enqueue("", target, wakeup, undefined, parts);
}
sendAudioParts(parts: readonly NativeAudioPart[], target: InboxTarget, wakeup: boolean): InboxReceipt {
return this.enqueue("", target, wakeup, undefined, undefined, parts);
}
private enqueue(text: string, target: InboxTarget, wakeup: boolean, images?: readonly NativeImageInput[], parts?: readonly NativeInputPart[], audioParts?: readonly NativeAudioPart[]): InboxReceipt {
this.assertUsable();
const wakingAfterAbort =
wakeup && this.phase.kind === "running" && this.phase.abort.signal.aborted;
const resolved: InboxTarget = wakingAfterAbort ? "next-turn" : target;
const receipt = this.inbox.insert(resolved, text, originFor(target, wakeup), {
wake: wakeup,
demotedFrom: wakingAfterAbort ? target : null,
...(images === undefined ? {} : { images }),
...(parts === undefined ? {} : { parts }),
...(audioParts === undefined ? {} : { audioParts })
});
if (wakeup) this.wakeDriver(wakingAfterAbort);
return receipt;
}
/** Queue a prompt that gets its own turn, and wake the driver. */
followup(text: string, images?: readonly NativeImageInput[]): InboxReceipt {
return this.send(text, "next-turn", true, images);
}
followupParts(parts: readonly NativeInputPart[]): InboxReceipt {
return this.sendParts(parts, "next-turn", true);
}
followupAudioParts(parts: readonly NativeAudioPart[]): InboxReceipt {
return this.sendAudioParts(parts, "next-turn", true);
}
/** Steer the nearest step boundary. An idle driver starts a turn for it. */
steer(text: string): InboxReceipt {
return this.send(text, "next-step", true);
}
/** Queue model-facing context for the nearest step boundary WITHOUT waking. */
inject(text: string): InboxReceipt {
return this.send(text, "next-step", false);
}
/**
* Stop the active turn, and — unless `keepInbox` — drop pending work.
*
* The REQUEST is recorded before anything unwinds. If the process dies during
* the unwind, this row is the only thing that will ever say who stopped the
* agent: the `turn/end` would then be written by crash repair, which knows
* nothing about the request and honestly reports `interrupted`.
*
* A failure to record the request never blocks the abort. Safety does not
* depend on the row; only ATTRIBUTION does, and an agent that could not be
* stopped because its log was unwritable would be a worse outcome than a stop
* whose requester is unnamed.
*/
cancel(cause: TurnCancelCause, options: CancelOptions = {}): void {
if (!isTurnCancelCause(cause)) {
throw new Error("AgentDriver.cancel: a cancellation must name its cause");
}
const keepInbox = options.keepInbox === true;
try {
this.head = this.session.recordLoopEvent({
kind: "cancel",
cause,
keepInbox,
requestedBy: options.by ?? "local",
observedHeadEventId: this.head?.eventId ?? null,
phase: this.phase.kind
});
} catch (error: unknown) {
this.notifyError(error);
}
if (!keepInbox) {
this.inbox.clear();
if (this.phase.kind === "running") this.phase.wakeRequested = false;
}
// The cause IS the abort reason, so the turn's catch reads back exactly what
// the canceller named rather than re-deriving it from somewhere else.
if (this.phase.kind === "running") this.phase.abort.abort(cause);
}
/**
* Settle when no driver activity remains.
*
* Re-reads the activity promise after each await because a latched wake can
* replace the driver this caller was following; without the loop, `whenIdle`
* would return while the replacement was still running.
*/
async whenIdle(): Promise<void> {
let activity: Promise<void>;
do {
activity = this.activityDone;
await activity;
} while (activity !== this.activityDone);
}
private assertUsable(): void {
if (this.phase.kind === "failed") {
throw new Error(
"AgentDriver is in a terminal failed state: its session has an open turn that only recovery may close"
);
}
}
private setPhase(next: Phase): void {
const before = this.status;
this.phase = next;
if (this.status !== before) this.hooks.notify({ kind: "status", status: this.status });
}
private requireRunning(): Extract<Phase, { kind: "running" }> {
if (this.phase.kind !== "running") {
throw new Error(`AgentDriver: expected a running driver, found "${this.phase.kind}"`);
}
return this.phase;
}
private notifyError(error: unknown): void {
const turn = this.phase.kind === "running" ? this.phase.turn : 0;
const step = this.phase.kind === "running" ? this.phase.step : 0;
this.hooks.notify({ kind: "error", turn, step, error });
}
/**
* Start a driver, or latch the wake behind one that is already aborting.
*
* A wake that arrives while idle always opens its turn boundary, even if its
* message is cleared before the claim: the boundary is what the sender was
* promised. A wake that arrives against an aborting driver is latched and
* replayed at convergence — unless the cancel cause is `disposed`, because
* teardown must never wait on a model turn.
*/
private wakeDriver(wakeAfterAbort = false): void {
if (this.phase.kind === "failed") return;
if (this.phase.kind === "running") {
const reason: unknown = this.phase.abort.signal.reason;
const disposed = isTurnCancelCause(reason) && reason.kind === "disposed";
if (!disposed && wakeAfterAbort) this.phase.wakeRequested = true;
return;
}
const driver = withResolvers();
this.activityDone = driver.promise;
this.setPhase({
kind: "running",
abort: new AbortController(),
turn: this.phase.lastTurn,
step: 0,
wakeRequested: false
});
this.kick().then(driver.resolve, driver.resolve);
}
/** The driver boundary: turns run until none is left, and nothing escapes. */
private async kick(): Promise<void> {
try {
while (await this.runTurn()) {
// Each iteration is one turn; runTurn says whether another is owed.
}
} catch {
// Reported failures and cancellations are contained here. They were already
// recorded as signed rows and surfaced through `notify`; rethrowing would
// only reject a promise nobody is holding.
} finally {
if (this.phase.kind === "running") {
const { turn, wakeRequested } = this.phase;
this.setPhase({ kind: "idle", lastTurn: turn });
if (wakeRequested && this.inbox.hasPending) this.wakeDriver();
}
}
}
/**
* One turn. Returns whether another is owed.
*
* The structure — and in particular which statements sit inside which `try` —
* is dsh's, kept deliberately: the two finallys are what make a cancelled turn
* balanced, and moving either one changes what a cancelled log looks like.
*/
private async runTurn(): Promise<boolean> {
const phase = this.requireRunning();
const { signal } = phase.abort;
signal.throwIfAborted();
const turnRef = this.session.startTurn({ trigger: this.triggerForNextTurn(phase.turn) });
this.head = turnRef;
phase.turn = turnRef.turn;
let ending: TurnEnding | null = null;
let target: InboxTarget = "next-turn";
let validation: NativeValidationTurn | undefined;
try {
if (this.validation) validation = new NativeValidationTurn({ session: this.session, tools: this.stepInit.tools,
plan: this.validation, turn: turnRef.turn, signal, abandonGraceMs: this.config.toolAbandonGraceMs });
// Inside the try, NOT between startTurn and it. The turn/start row is
// already durable at this point, so an observer that throws here would
// otherwise leave a turn/start with no turn/end — the truncated turn this
// whole finally exists to make impossible. A hook is untrusted code; it
// must not be able to strand a turn just by failing.
this.hooks.notify({ kind: "turn-start", turn: turnRef.turn });
while (true) {
signal.throwIfAborted();
const step = phase.step + 1;
if (step > this.config.maxStepsPerTurn) {
ending = { reason: "max_steps" };
break;
}
const decision = await this.preStep(target, turnRef.turn, step);
if (decision.kind === "reject") {
ending = { reason: "blocked" };
return false;
}
// A turn that has already decided how it ends, and claimed nothing new,
// is finished.
if (ending !== null && decision.messages.length === 0) break;
// A waking message that was cleared before the claim still owns the turn
// boundary it was promised — but it spends no model call.
if (phase.step === 0 && decision.messages.length === 0) {
ending = { reason: "complete" };
return false;
}
signal.throwIfAborted();
this.head = this.session.startStep();
phase.step = step;
this.hooks.notify({ kind: "step-start", turn: turnRef.turn, step });
let stopReason: string | null = null;
let usage: TokenUsage | null = null;
try {
for (const message of decision.messages) {
if (message.audioParts !== undefined) {
this.head = recordNativeAudioMessage(this.session, message);
continue;
}
if (message.parts !== undefined) {
this.head = recordNativeOrderedMessage(this.session, message);
continue;
}
// The signed inbox already records an image-only input's empty text.
// Do not manufacture an empty provider text block beside its image;
// historical rows/encoders and ordinary text-only behavior stay intact.
if (message.text.length > 0 || !message.images?.length) {
this.head = this.session.recordUserMessage(message.text);
}
this.head = recordNativeImageMessage(this.session, message) ?? this.head;
}
const outcome = await runStep(this.stepInit, turnRef.turn, step, signal);
stopReason = outcome.stopReason;
usage = outcome.usage;
// `max_tokens` is sticky: once any step hit the ceiling, a later step
// that completes normally must not downgrade the turn's outcome.
if (ending === null || ending.reason !== "max_tokens") ending = outcome.ending;
} catch (error: unknown) {
stopReason = signal.aborted ? "cancelled" : "error";
throw error;
} finally {
// ALWAYS. This is what keeps a cancelled turn balanced at step
// granularity, and it is the shape crash repair also produces.
this.head = this.session.endStep({ stopReason, usage });
this.hooks.notify({ kind: "step-end", turn: turnRef.turn, step });
}
signal.throwIfAborted();
// A listener that objects to the turn stopping says so by steering; the
// inbox is re-read after it runs, so listener ORDER cannot change the
// outcome.
if (ending !== null && this.inbox.nextStep.length === 0) {
await this.hooks.turnStopping({ turn: turnRef.turn, signal });
signal.throwIfAborted();
}
if (ending !== null && this.inbox.nextStep.length === 0) break;
target = "next-step";
}
if (ending?.reason === "complete") await validation?.run(phase.step);
} catch (error: unknown) {
if (signal.aborted) {
ending = { reason: "cancelled", cause: cancelCauseOf(signal) };
throw error;
}
ending = { reason: "error", error };
this.notifyError(error);
throw error;
} finally {
try { validation?.finish(signal.aborted ? "cancelled" : `turn-${ending?.reason ?? "incomplete"}`); }
catch (error) { ending = { reason: "error", error }; this.notifyError(error); }
this.closeTurn(turnRef.turn, ending);
}
if (!this.inbox.hasPending) return false;
// A fresh controller makes any latch on the old one stale: the live driver
// claims the queue itself rather than replaying a wake for work it can see.
phase.abort = new AbortController();
phase.wakeRequested = false;
phase.step = 0;
return true;
}
/**
* Close the turn, then seal it.
*
* Two calls, because `endTurn` records how the turn finished and `sealTurn`
* commits the Merkle window over everything in it — and only the seal releases
* the spine's turn slot. If the closer cannot be written the driver stops for
* good and does NOT seal: see this module's header for why an unsealed open
* turn is the honest outcome there.
*/
private closeTurn(turn: number, ending: TurnEnding | null): void {
// Unreachable: every exit above assigns an ending. Kept, and labelled, so
// that a future edit which introduces an unassigned path produces a signed
// `error` turn a reader can find rather than a crash inside a finally.
const resolved: TurnEnding = ending ?? {
reason: "error",
error: new Error("agent loop: turn ended with no ending assigned")
};
try {
this.head = this.session.endTurn(toTurnEndParams(resolved));
} catch (error: unknown) {
this.setPhase({ kind: "failed", cause: error });
this.notifyError(error);
return;
}
this.hooks.notify({ kind: "turn-end", turn, ending: resolved });
try {
this.head = this.session.sealTurn();
} catch (error: unknown) {
this.setPhase({ kind: "failed", cause: error });
this.notifyError(error);
}
}
/**
* Claim a batch, then let listeners decide whether the step is entered.
*
* The claim is DURABLE BEFORE the waterfall runs — dsh's ordering, kept — so a
* veto consumes the messages it was handed. That loss is real, and AMC records
* it rather than hiding it: the `loop/veto` row names the vetoing party and
* every message id the veto burned.
*/
private async preStep(target: InboxTarget, turn: number, step: number): Promise<PreStepDecision> {
const signal = this.requireRunning().abort.signal;
const claimed: readonly InboxMessage[] = this.inbox.claim(target);
// A hook may mutate the batch array, but cannot erase the captured v2 claims.
const orderedClaims = claimed.filter(message => message.parts !== undefined);
const audioClaims = claimed.filter(message => message.audioParts !== undefined);
const decision = await this.hooks.preStep(
{ turn, step, target, messages: claimed, signal },
(): Promise<PreparedStep> => Promise.resolve({ kind: "enter", messages: claimed })
);
signal.throwIfAborted();
if (decision.kind === "reject") {
this.head = this.session.recordLoopEvent({
kind: "veto",
turn,
step,
by: decision.by,
claimedMessageIds: claimed.map((message) => message.messageId)
});
}
if (decision.kind === "enter") assertOrderedClaimDecision(orderedClaims, decision.messages);
if (decision.kind === "enter") assertAudioClaimDecision(audioClaims, decision.messages);
return decision;
}
/**
* Which lane opened this turn.
*
* The distinction the spine's vocabulary asks for is the one a reader wants:
* `user` is the prompt that started the conversation, `followup` is a later
* prompt, `steer` is a turn opened by steering alone, and `resume` is a driver
* that woke with nothing queued.
*/
private triggerForNextTurn(lastTurn: number): TurnTrigger {
if (this.inbox.nextTurn.length > 0) return lastTurn === 0 ? "user" : "followup";
if (this.inbox.nextStep.length > 0) return "steer";
return "resume";
}
}
/** How a message is labelled in the log, from how it was sent. */
function originFor(target: InboxTarget, wakeup: boolean): "followup" | "steer" | "inject" {
if (target === "next-turn") return "followup";
return wakeup ? "steer" : "inject";
}
/**
* Read back the cause the canceller named.
*
* Falls back to `{kind:"user"}` only for an abort raised by something that is
* not this driver's `cancel` — which cannot happen through the public surface,
* since the controller is private. An unattributed cancel is refused by the
* spine's writer, so guessing here would only move the failure; naming the
* broadest plausible actor keeps the turn closable and leaves the `loop/cancel`
* row (or its absence) as the authority on who actually asked.
*/
function cancelCauseOf(signal: AbortSignal): TurnCancelCause {
const reason: unknown = signal.reason;
return isTurnCancelCause(reason) ? reason : { kind: "user" };
}
/** `Promise.withResolvers` is Node 22+; this keeps the Node 20 engine floor. */
function withResolvers(): { promise: Promise<void>; resolve: () => void } {
let resolve: () => void = () => {
// Replaced synchronously by the executor below.
};
const promise = new Promise<void>((settle) => {
resolve = () => {
settle();
};
});
return { promise, resolve };
}