@zhin.js/harness 0.0.0-stage → 0.0.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/lib/authority.d.ts +37 -0
  4. package/lib/authority.js +161 -0
  5. package/lib/budget.d.ts +48 -0
  6. package/lib/budget.js +154 -0
  7. package/lib/contracts.d.ts +643 -0
  8. package/lib/contracts.js +86 -0
  9. package/lib/guard.d.ts +25 -0
  10. package/lib/guard.js +213 -0
  11. package/lib/harness.d.ts +197 -0
  12. package/lib/harness.js +731 -0
  13. package/lib/index.d.ts +8 -0
  14. package/lib/index.js +8 -0
  15. package/lib/journal.d.ts +86 -0
  16. package/lib/journal.js +712 -0
  17. package/lib/mailbox.d.ts +6 -0
  18. package/lib/mailbox.js +422 -0
  19. package/lib/policies/admission.d.ts +48 -0
  20. package/lib/policies/admission.js +118 -0
  21. package/lib/policies/groups.d.ts +47 -0
  22. package/lib/policies/groups.js +91 -0
  23. package/lib/policies/index.d.ts +5 -0
  24. package/lib/policies/index.js +5 -0
  25. package/lib/policies/live.d.ts +28 -0
  26. package/lib/policies/live.js +44 -0
  27. package/lib/policies/markers.d.ts +23 -0
  28. package/lib/policies/markers.js +42 -0
  29. package/lib/policies/merge.d.ts +49 -0
  30. package/lib/policies/merge.js +101 -0
  31. package/package.json +39 -3
  32. package/src/authority.ts +194 -0
  33. package/src/budget.ts +181 -0
  34. package/src/contracts.ts +762 -0
  35. package/src/guard.ts +305 -0
  36. package/src/harness.ts +975 -0
  37. package/src/index.ts +8 -0
  38. package/src/journal.ts +833 -0
  39. package/src/mailbox.ts +467 -0
  40. package/src/policies/admission.ts +164 -0
  41. package/src/policies/groups.ts +137 -0
  42. package/src/policies/index.ts +5 -0
  43. package/src/policies/live.ts +68 -0
  44. package/src/policies/markers.ts +58 -0
  45. package/src/policies/merge.ts +121 -0
package/lib/harness.js ADDED
@@ -0,0 +1,731 @@
1
+ /**
2
+ * ADR 0048 Agent Harness — composition (H1).
3
+ *
4
+ * Wires the components behind the contracts: every mail is attenuated,
5
+ * funded from its sender's grant, admitted by the guard kernel, journaled,
6
+ * and only then posted to the mailbox. The agent layer supplies the turn
7
+ * runner and calls `send` from its collaboration tools.
8
+ *
9
+ * Semantics:
10
+ * - `ask` is synchronous for the asker: `send` resolves with the target's
11
+ * reply; the reply is journaled as a `result` mail but not posted to the
12
+ * asker's mailbox (the asker's turn is still running and consumes it).
13
+ * - `delegate` is asynchronous: `send` resolves once the work is admitted;
14
+ * the target's reply is posted to the delegator's mailbox as a `result`
15
+ * mail, which wakes it with a new turn.
16
+ * - `tell` expects nothing back.
17
+ * - An `ask` whose target is (transitively) waiting on the asker would
18
+ * deadlock the serial mailbox; it is denied as `wait_cycle` and escalated.
19
+ * - A denial with `escalate: true` freezes the whole engagement: an
20
+ * `escalated` event is journaled, `onEscalate` is called once so the host
21
+ * can ask the principal, every outstanding ask / delegate is cancelled
22
+ * (askers get a `cancelled` reply), and later sends are denied as `frozen`.
23
+ * - An engagement closes when it has no running or queued turns and no
24
+ * outstanding ask / delegate; its root grant is closed then.
25
+ * - Inbound mail (`human` / `timer` / `event`) enters through `deliver`
26
+ * (`open` is the same call): it opens an engagement and posts the root mail
27
+ * into the entry actor's inbox lane (D1). The inbox policy (`inbox.policy`,
28
+ * D2) decides what the mail does to that lane, also while a turn of the
29
+ * lane is running (mid-turn): queue, merge, supersede the running turn,
30
+ * stop it, or reject. Every turn of every actor runs inside the mailbox;
31
+ * there is no externally running root turn (H3, Q6).
32
+ * - A merged batch (Q2) may span several engagements (one per human
33
+ * message); each engagement is accounted for by its own mails.
34
+ * - A `result` for a mail that ran on an explicit lane returns on that lane,
35
+ * so delegated results wait for the lane's running turn (the root turn)
36
+ * instead of running beside it.
37
+ */
38
+ import { ALL, DEFAULT_GUARD_LIMITS, } from './contracts.js';
39
+ import { agentOf } from './authority.js';
40
+ import { reconstructBoardFromEvents } from './journal.js';
41
+ export function createHarness(options) {
42
+ const limits = options.limits ?? DEFAULT_GUARD_LIMITS;
43
+ const clock = options.clock ?? Date.now;
44
+ let counter = 0;
45
+ const newId = options.newId ?? (() => `m${++counter}`);
46
+ const engagements = new Map();
47
+ const allEngagements = new Map();
48
+ const sentDelegations = new Map();
49
+ const boards = new Map();
50
+ const askWaiters = new Map();
51
+ /** asker actor → actors it is currently waiting on (per engagement). */
52
+ const waitingOn = new Map();
53
+ let closed = false;
54
+ const reportError = (error) => {
55
+ try {
56
+ options.onError?.(error);
57
+ }
58
+ catch { /* reporting must not throw */ }
59
+ };
60
+ const mailbox = options.createMailbox({
61
+ run: async (actor, batch, signal, control) => {
62
+ const engagementId = batch[0]?.engagementId;
63
+ if (engagementId) {
64
+ await record(engagementId, 'turn_started', { actor, mails: batch.map((mail) => mail.id) }).catch(reportError);
65
+ }
66
+ return options.runTurn(actor, batch, signal, control);
67
+ },
68
+ maxConcurrentTurns: options.maxConcurrentTurns ?? 8,
69
+ maxQueuedPerKey: options.maxQueuedPerKey ?? 16,
70
+ ...(options.inbox?.policy ? { policy: options.inbox.policy } : {}),
71
+ ...(options.inbox?.queueTtlMs !== undefined ? { queueTtlMs: options.inbox.queueTtlMs } : {}),
72
+ ...(options.inbox?.now ? { now: options.inbox.now } : {}),
73
+ ...(options.inbox?.setTimer ? { setTimer: options.inbox.setTimer } : {}),
74
+ onTurnFinished: (key, batch, result) => onTurnFinished(key, batch, result),
75
+ });
76
+ function graphOf(state) {
77
+ return {
78
+ engagement: state.engagement,
79
+ get mails() { return state.mails; },
80
+ get: (id) => state.byId.get(id),
81
+ };
82
+ }
83
+ async function record(engagementId, type, data) {
84
+ await options.journal.append({ engagementId, at: clock(), type, data });
85
+ }
86
+ function admit(state, mail) {
87
+ state.mails.push(mail);
88
+ state.byId.set(mail.id, mail);
89
+ }
90
+ function waitKey(engagementId, actor) {
91
+ return `${engagementId}\u0000${actor}`;
92
+ }
93
+ /** True when `target` is, directly or transitively, waiting on `asker`. */
94
+ function wouldCycle(engagementId, asker, target) {
95
+ if (asker === target)
96
+ return true;
97
+ const seen = new Set();
98
+ const stack = [target];
99
+ for (let actor = stack.pop(); actor !== undefined; actor = stack.pop()) {
100
+ if (actor === asker)
101
+ return true;
102
+ if (seen.has(actor))
103
+ continue;
104
+ seen.add(actor);
105
+ for (const next of waitingOn.get(waitKey(engagementId, actor)) ?? [])
106
+ stack.push(next);
107
+ }
108
+ return false;
109
+ }
110
+ function mailSummary(mail) {
111
+ return {
112
+ mailId: mail.id,
113
+ kind: mail.kind,
114
+ from: mail.from.kind === 'actor' ? mail.from.actor : mail.from.kind === 'human' ? `human:${mail.from.principal.id}` : `system:${mail.from.source}`,
115
+ to: mail.to,
116
+ causationId: mail.causationId ?? null,
117
+ grantId: mail.grantId,
118
+ tools: mail.authority.tools.kind === 'all' ? '*' : [...mail.authority.tools.names],
119
+ trust: mail.authority.trust,
120
+ };
121
+ }
122
+ async function escalate(state, mail, code, reason) {
123
+ if (state.frozen)
124
+ return;
125
+ state.frozen = { code, reason };
126
+ await record(state.engagement.id, 'escalated', { ...mailSummary(mail), code, reason });
127
+ // Stop every branch still in flight; their askers receive a cancelled reply.
128
+ for (const id of [...state.outstanding]) {
129
+ if (!mailbox.cancel(id, `engagement frozen: ${reason}`)) {
130
+ const waiter = askWaiters.get(id);
131
+ if (waiter) {
132
+ askWaiters.delete(id);
133
+ state.outstanding.delete(id);
134
+ waiter.resolve({ status: 'cancelled', content: `engagement frozen: ${reason}` });
135
+ }
136
+ }
137
+ }
138
+ try {
139
+ await options.onEscalate?.({ engagement: state.engagement, mail, code, reason });
140
+ }
141
+ catch (error) {
142
+ reportError(error);
143
+ }
144
+ }
145
+ async function maybeCloseEngagement(state) {
146
+ if (state.closed || state.pendingTurns > 0 || state.outstanding.size > 0)
147
+ return;
148
+ state.closed = true;
149
+ const snapshot = options.ledger.close(state.engagement.rootGrantId);
150
+ await record(state.engagement.id, 'engagement_closed', {
151
+ status: state.frozen ? 'escalated' : 'completed',
152
+ ...(state.frozen ? { reason: state.frozen.reason } : {}),
153
+ budget: snapshot.ok ? { spent: snapshot.value.spent, granted: snapshot.value.granted } : null,
154
+ });
155
+ await options.journal.compact?.(state.engagement.id).catch(reportError);
156
+ engagements.delete(state.engagement.id);
157
+ try {
158
+ options.onEngagementClosed?.(state.engagement);
159
+ }
160
+ catch (error) {
161
+ reportError(error);
162
+ }
163
+ }
164
+ function post(state, mail) {
165
+ const posted = mailbox.post(mail);
166
+ if (!posted.accepted) {
167
+ return { ok: false, code: posted.code === 'mailbox_closed' ? 'closed' : posted.code, reason: posted.message };
168
+ }
169
+ // A stop request is consumed by the inbox: it never becomes a turn.
170
+ if (posted.disposition !== 'stopped')
171
+ state.pendingTurns += 1;
172
+ return { ok: true, disposition: posted.disposition, position: posted.position };
173
+ }
174
+ async function onTurnFinished(key, batch, result) {
175
+ // A merged batch (Q2) may carry mails of several engagements.
176
+ const byEngagement = new Map();
177
+ for (const mail of batch) {
178
+ const list = byEngagement.get(mail.engagementId);
179
+ if (list)
180
+ list.push(mail);
181
+ else
182
+ byEngagement.set(mail.engagementId, [mail]);
183
+ }
184
+ for (const [engagementId, mails] of byEngagement) {
185
+ const state = engagements.get(engagementId);
186
+ if (!state)
187
+ continue;
188
+ state.pendingTurns -= mails.length;
189
+ try {
190
+ await record(state.engagement.id, 'turn_finished', {
191
+ actor: key.actor,
192
+ mails: mails.map((mail) => mail.id),
193
+ status: result.status,
194
+ ...(result.reason ? { reason: result.reason } : {}),
195
+ ...(engagementId !== key.engagementId ? { carriedBy: key.engagementId } : {}),
196
+ });
197
+ for (const mail of mails) {
198
+ if (mail.body.kind === 'tell')
199
+ options.ledger.close(mail.grantId);
200
+ if (mail.body.kind !== 'ask' && mail.body.kind !== 'delegate')
201
+ continue;
202
+ await reply(state, mail, result);
203
+ }
204
+ }
205
+ catch (error) {
206
+ reportError(error);
207
+ }
208
+ finally {
209
+ await maybeCloseEngagement(state).catch(reportError);
210
+ }
211
+ }
212
+ }
213
+ /** Sends the target's reply for an ask / delegate back to its sender. */
214
+ async function reply(state, request, result) {
215
+ if (request.from.kind !== 'actor')
216
+ return;
217
+ const requester = request.from.actor;
218
+ const content = result.content ?? '';
219
+ const parent = request.causationId ? state.byId.get(request.causationId) : undefined;
220
+ const back = Object.freeze({
221
+ id: newId(),
222
+ kind: 'result',
223
+ engagementId: state.engagement.id,
224
+ causationId: request.id,
225
+ from: Object.freeze({ kind: 'actor', actor: request.to }),
226
+ to: requester,
227
+ // Back on the requester's lane, behind the turn that asked (it may still be running).
228
+ ...(parent?.lane !== undefined && parent.to === requester ? { lane: parent.lane } : {}),
229
+ // The reply runs with the requester's own authority and grant, never the replier's.
230
+ authority: parent?.authority ?? request.authority,
231
+ grantId: parent?.grantId ?? request.grantId,
232
+ body: Object.freeze({ kind: 'result', inReplyTo: request.id, status: result.status, content }),
233
+ createdAt: clock(),
234
+ });
235
+ options.ledger.close(request.grantId);
236
+ state.outstanding.delete(request.id);
237
+ const ceiling = options.ceilingOf(agentOf(requester), state.engagement.id);
238
+ const decision = ceiling
239
+ ? options.guard.admitMail(back, { graph: graphOf(state), targetCeiling: ceiling, limits })
240
+ : { allow: false, code: 'not_accepted_by_target', reason: `unknown agent ${requester}`, escalate: false };
241
+ if (!decision.allow) {
242
+ await record(state.engagement.id, 'mail_denied', { ...mailSummary(back), code: decision.code, reason: decision.reason });
243
+ askWaiters.get(request.id)?.resolve({ status: 'failed', content: decision.reason });
244
+ askWaiters.delete(request.id);
245
+ return;
246
+ }
247
+ admit(state, back);
248
+ await record(state.engagement.id, 'mail_admitted', mailSummary(back));
249
+ const waiter = askWaiters.get(request.id);
250
+ if (waiter) {
251
+ askWaiters.delete(request.id);
252
+ waitingOn.get(waitKey(state.engagement.id, waiter.asker))?.delete(request.to);
253
+ waiter.resolve({ status: result.status, content });
254
+ return;
255
+ }
256
+ const posted = post(state, back);
257
+ if (!posted.ok)
258
+ await record(state.engagement.id, 'mail_denied', { ...mailSummary(back), code: posted.code, reason: posted.reason });
259
+ }
260
+ async function deliver(input) {
261
+ if (closed)
262
+ return { ok: false, code: 'closed', reason: 'harness is closed' };
263
+ const engagementId = newId();
264
+ const ceiling = options.ceilingOf(agentOf(input.entry), engagementId);
265
+ if (!ceiling)
266
+ return { ok: false, code: 'unknown_agent', reason: `unknown agent ${agentOf(input.entry)}` };
267
+ const rootGrantId = options.ledger.openRoot(input.budget);
268
+ const rootMailId = newId();
269
+ const engagement = Object.freeze({
270
+ id: engagementId,
271
+ principal: input.principal,
272
+ rootMailId,
273
+ rootGrantId,
274
+ venue: input.venue,
275
+ openedAt: clock(),
276
+ });
277
+ const from = input.body.kind === 'human'
278
+ ? { kind: 'human', principal: input.principal }
279
+ : { kind: 'system', source: input.body.kind === 'timer' ? 'timer' : 'event' };
280
+ const root = Object.freeze({
281
+ id: rootMailId,
282
+ kind: input.body.kind,
283
+ engagementId,
284
+ from: Object.freeze(from),
285
+ to: input.entry,
286
+ ...(input.lane !== undefined ? { lane: input.lane } : {}),
287
+ authority: options.rootAuthority(input.principal, ceiling, { approvers: input.approvers }),
288
+ grantId: rootGrantId,
289
+ body: input.body,
290
+ createdAt: clock(),
291
+ });
292
+ const state = {
293
+ engagement,
294
+ initialBudget: input.budget,
295
+ mails: [],
296
+ byId: new Map(),
297
+ pendingTurns: 0,
298
+ outstanding: new Set(),
299
+ closed: false,
300
+ };
301
+ engagements.set(engagementId, state);
302
+ allEngagements.set(engagementId, state);
303
+ await record(engagementId, 'engagement_opened', {
304
+ principal: input.principal.id,
305
+ initiator: input.principal,
306
+ ...(input.venue ? { venue: input.venue } : {}),
307
+ entry: input.entry,
308
+ rootMailId,
309
+ rootGrantId,
310
+ budget: input.budget,
311
+ ...(input.lane !== undefined ? { lane: input.lane } : {}),
312
+ });
313
+ const decision = options.guard.admitMail(root, { graph: graphOf(state), targetCeiling: ceiling, limits });
314
+ if (!decision.allow) {
315
+ await record(engagementId, 'mail_denied', { ...mailSummary(root), code: decision.code, reason: decision.reason });
316
+ await maybeCloseEngagement(state);
317
+ return { ok: false, code: decision.code, reason: decision.reason };
318
+ }
319
+ admit(state, root);
320
+ await record(engagementId, 'mail_admitted', mailSummary(root));
321
+ if (closed) {
322
+ await maybeCloseEngagement(state);
323
+ return { ok: false, code: 'closed', reason: 'harness is closed' };
324
+ }
325
+ try {
326
+ input.onPosting?.(root);
327
+ }
328
+ catch (error) {
329
+ reportError(error);
330
+ }
331
+ const posted = post(state, root);
332
+ if (!posted.ok) {
333
+ await record(engagementId, 'mail_denied', { ...mailSummary(root), code: posted.code, reason: posted.reason });
334
+ await maybeCloseEngagement(state);
335
+ return { ok: false, code: posted.code, reason: posted.reason };
336
+ }
337
+ if (posted.disposition === 'stopped')
338
+ await maybeCloseEngagement(state);
339
+ return { ok: true, engagement, root, disposition: posted.disposition, position: posted.position };
340
+ }
341
+ async function sendInternal(from, current, request, overrides) {
342
+ const state = engagements.get(current.engagementId);
343
+ const deny = async (code, reason, mail, escalateIt = false) => {
344
+ if (state && mail) {
345
+ await record(state.engagement.id, 'mail_denied', { ...mailSummary(mail), code, reason });
346
+ if (escalateIt)
347
+ await escalate(state, mail, code, reason);
348
+ }
349
+ return { ok: false, accepted: false, code, reason, escalated: escalateIt && Boolean(state && mail) };
350
+ };
351
+ if (closed)
352
+ return deny('closed', 'harness is closed');
353
+ if (!state || state.closed)
354
+ return deny('unknown_engagement', `engagement ${current.engagementId} is not open`);
355
+ if (state.frozen) {
356
+ return {
357
+ ok: false,
358
+ accepted: false,
359
+ code: 'frozen',
360
+ reason: `collaboration is paused for the human to decide: ${state.frozen.reason}`,
361
+ escalated: true,
362
+ };
363
+ }
364
+ if (current.to !== from || !state.byId.has(current.id)) {
365
+ return deny('authority_not_attenuated', `${from} is not processing mail ${current.id}`);
366
+ }
367
+ const isRemote = Boolean(options.remoteTransport?.canRoute(request.to));
368
+ const ceiling = options.ceilingOf(agentOf(request.to), state.engagement.id)
369
+ ?? (isRemote
370
+ ? {
371
+ agent: agentOf(request.to),
372
+ tools: ALL,
373
+ contacts: ALL,
374
+ maxTrust: 'master',
375
+ egress: { network: true, hosts: ALL },
376
+ acceptsFrom: ALL,
377
+ }
378
+ : undefined);
379
+ if (!ceiling)
380
+ return deny('unknown_agent', `unknown agent ${agentOf(request.to)}`);
381
+ const budgetRequest = request.budget ?? ceiling.defaultBudget ?? {};
382
+ const granted = options.ledger.grant(current.grantId, budgetRequest);
383
+ const grantId = granted.ok ? granted.value : current.grantId;
384
+ const body = request.kind === 'ask'
385
+ ? { kind: 'ask', question: request.text }
386
+ : request.kind === 'delegate'
387
+ ? { kind: 'delegate', brief: request.text, ...(request.deliverable ? { deliverable: request.deliverable } : {}) }
388
+ : { kind: 'tell', content: request.text };
389
+ const mail = Object.freeze({
390
+ id: newId(),
391
+ kind: request.kind,
392
+ engagementId: state.engagement.id,
393
+ causationId: overrides?.causationId ?? current.id,
394
+ from: Object.freeze({ kind: 'actor', actor: from }),
395
+ to: request.to,
396
+ authority: overrides?.authority ?? options.attenuate(current.authority, request.narrowing ?? {}, ceiling, from),
397
+ grantId,
398
+ body: Object.freeze(body),
399
+ createdAt: clock(),
400
+ });
401
+ if (!granted.ok)
402
+ return deny('budget_exceeded', granted.message, mail);
403
+ const decision = options.guard.admitMail(mail, {
404
+ graph: graphOf(state),
405
+ targetCeiling: ceiling,
406
+ senderAuthority: current.authority,
407
+ limits,
408
+ });
409
+ if (!decision.allow) {
410
+ options.ledger.close(grantId);
411
+ return deny(decision.code, decision.reason, mail, decision.escalate);
412
+ }
413
+ if (request.kind === 'ask' && wouldCycle(state.engagement.id, from, request.to)) {
414
+ options.ledger.close(grantId);
415
+ return deny('wait_cycle', `${request.to} is waiting on ${from}; asking it back would deadlock`, mail, true);
416
+ }
417
+ admit(state, mail);
418
+ await record(state.engagement.id, 'mail_admitted', mailSummary(mail));
419
+ const snapshot = options.ledger.snapshot(grantId);
420
+ if (granted.ok && request.kind !== 'tell') {
421
+ await record(state.engagement.id, 'budget_granted', {
422
+ grantId,
423
+ parentId: current.grantId,
424
+ granted: snapshot?.granted ?? null,
425
+ });
426
+ }
427
+ if (request.kind === 'ask' || request.kind === 'delegate') {
428
+ sentDelegations.set(mail.id, {
429
+ id: mail.id,
430
+ engagementId: state.engagement.id,
431
+ from,
432
+ to: request.to,
433
+ authority: mail.authority,
434
+ parentMail: current,
435
+ ...(current.lane !== undefined ? { lane: current.lane } : {}),
436
+ });
437
+ }
438
+ if (isRemote && options.remoteTransport) {
439
+ const remoteTransport = options.remoteTransport;
440
+ const grantedAmounts = snapshot?.granted ?? {
441
+ modelCalls: 0,
442
+ tokens: 0,
443
+ wallMs: 0,
444
+ sideEffects: 0,
445
+ };
446
+ if (request.kind === 'tell') {
447
+ void Promise.resolve()
448
+ .then(() => remoteTransport.send(mail, grantedAmounts))
449
+ .then(async (remoteRes) => {
450
+ if (remoteRes?.consumed && Object.keys(remoteRes.consumed).length > 0) {
451
+ const ch = options.ledger.charge(grantId, remoteRes.consumed);
452
+ await record(state.engagement.id, ch.ok ? 'budget_charged' : 'budget_exceeded', {
453
+ grantId,
454
+ mailId: mail.id,
455
+ usage: remoteRes.consumed,
456
+ ...(ch.ok ? {} : { exhausted: ch.exhausted ?? [] }),
457
+ });
458
+ }
459
+ })
460
+ .catch(reportError)
461
+ .finally(() => {
462
+ options.ledger.close(grantId);
463
+ void maybeCloseEngagement(state).catch(reportError);
464
+ });
465
+ return { ok: true, accepted: true, mailId: mail.id, mail };
466
+ }
467
+ state.outstanding.add(mail.id);
468
+ let remoteResult;
469
+ try {
470
+ const sendPromise = options.remoteTransport.send(mail, grantedAmounts);
471
+ if (request.timeoutMs !== undefined && request.timeoutMs > 0) {
472
+ let timer;
473
+ const timeoutPromise = new Promise((resolve) => {
474
+ timer = setTimeout(() => {
475
+ resolve({
476
+ status: 'cancelled',
477
+ content: `remote mail ${mail.id} timed out after ${request.timeoutMs}ms`,
478
+ });
479
+ }, request.timeoutMs);
480
+ timer.unref?.();
481
+ });
482
+ remoteResult = await Promise.race([sendPromise, timeoutPromise]).finally(() => {
483
+ if (timer !== undefined)
484
+ clearTimeout(timer);
485
+ });
486
+ }
487
+ else {
488
+ remoteResult = await sendPromise;
489
+ }
490
+ }
491
+ catch (error) {
492
+ remoteResult = {
493
+ status: 'rejected',
494
+ content: error instanceof Error ? error.message : String(error),
495
+ };
496
+ }
497
+ finally {
498
+ state.outstanding.delete(mail.id);
499
+ }
500
+ const consumed = remoteResult.consumed ?? { modelCalls: 1 };
501
+ const chargeRes = options.ledger.charge(grantId, consumed);
502
+ await record(state.engagement.id, chargeRes.ok ? 'budget_charged' : 'budget_exceeded', {
503
+ grantId,
504
+ mailId: mail.id,
505
+ usage: consumed,
506
+ ...(chargeRes.ok ? {} : { exhausted: chargeRes.exhausted ?? [] }),
507
+ });
508
+ options.ledger.close(grantId);
509
+ await record(state.engagement.id, 'mail_delivered', { mailId: mail.id, actor: request.to });
510
+ if (remoteResult.status === 'completed') {
511
+ await record(state.engagement.id, 'mail_completed', {
512
+ mailId: mail.id,
513
+ actor: request.to,
514
+ status: 'completed',
515
+ content: remoteResult.content,
516
+ });
517
+ }
518
+ else {
519
+ await record(state.engagement.id, 'mail_cancelled', {
520
+ mailId: mail.id,
521
+ actor: request.to,
522
+ status: remoteResult.status,
523
+ reason: remoteResult.content,
524
+ });
525
+ }
526
+ await maybeCloseEngagement(state).catch(reportError);
527
+ const turnStatus = remoteResult.status === 'completed'
528
+ ? 'completed'
529
+ : remoteResult.status === 'cancelled'
530
+ ? 'cancelled'
531
+ : 'failed';
532
+ return {
533
+ ok: true,
534
+ accepted: true,
535
+ mailId: mail.id,
536
+ mail,
537
+ reply: { status: turnStatus, content: remoteResult.content },
538
+ result: { status: remoteResult.status, content: remoteResult.content },
539
+ };
540
+ }
541
+ if (request.kind !== 'tell')
542
+ state.outstanding.add(mail.id);
543
+ let replied;
544
+ if (request.kind === 'ask') {
545
+ replied = new Promise((resolve) => askWaiters.set(mail.id, { asker: from, engagementId: state.engagement.id, resolve }));
546
+ const key = waitKey(state.engagement.id, from);
547
+ const set = waitingOn.get(key) ?? new Set();
548
+ set.add(request.to);
549
+ waitingOn.set(key, set);
550
+ }
551
+ const posted = post(state, mail);
552
+ if (!posted.ok) {
553
+ state.outstanding.delete(mail.id);
554
+ askWaiters.delete(mail.id);
555
+ waitingOn.get(waitKey(state.engagement.id, from))?.delete(request.to);
556
+ options.ledger.close(grantId);
557
+ return deny(posted.code, posted.reason, mail);
558
+ }
559
+ if (!replied)
560
+ return { ok: true, accepted: true, mailId: mail.id, mail };
561
+ const replyVal = await replied;
562
+ return { ok: true, accepted: true, mailId: mail.id, mail, reply: replyVal, result: replyVal };
563
+ }
564
+ return {
565
+ deliver,
566
+ open: deliver,
567
+ remaining(current) {
568
+ return options.ledger.snapshot(current.grantId)?.remaining;
569
+ },
570
+ send(from, current, request) {
571
+ return sendInternal(from, current, request);
572
+ },
573
+ async followUp(input) {
574
+ const original = sentDelegations.get(input.mailId);
575
+ if (!original || original.from !== input.from) {
576
+ return Object.freeze({
577
+ ok: false,
578
+ accepted: false,
579
+ mailId: input.mailId,
580
+ code: 'missing_causation',
581
+ reason: Object.freeze({
582
+ code: 'missing_causation',
583
+ detail: !original
584
+ ? `unknown delegated mail ${input.mailId}`
585
+ : `mail ${input.mailId} was sent by ${original.from}, not ${input.from}`,
586
+ }),
587
+ escalated: false,
588
+ });
589
+ }
590
+ const state = engagements.get(original.engagementId) ?? allEngagements.get(original.engagementId);
591
+ if (!state) {
592
+ return Object.freeze({
593
+ ok: false,
594
+ accepted: false,
595
+ mailId: input.mailId,
596
+ code: 'unknown_engagement',
597
+ reason: Object.freeze({
598
+ code: 'unknown_engagement',
599
+ detail: `engagement ${original.engagementId} does not exist`,
600
+ }),
601
+ escalated: false,
602
+ });
603
+ }
604
+ const wasClosed = state.closed;
605
+ let carrierMail = original.parentMail;
606
+ if (wasClosed) {
607
+ state.closed = false;
608
+ engagements.set(state.engagement.id, state);
609
+ }
610
+ const snap = options.ledger.snapshot(carrierMail.grantId);
611
+ if (!snap || snap.closed) {
612
+ const reopenedGrant = options.ledger.openRoot(state.initialBudget);
613
+ carrierMail = Object.freeze({ ...carrierMail, grantId: reopenedGrant });
614
+ state.byId.set(carrierMail.id, carrierMail);
615
+ }
616
+ const outcome = await sendInternal(input.from, carrierMail, {
617
+ kind: 'ask',
618
+ to: original.to,
619
+ text: `[Follow-up on ${input.mailId}]\n${input.content}`,
620
+ ...(input.budget ? { budget: input.budget } : {}),
621
+ ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
622
+ }, {
623
+ causationId: input.mailId,
624
+ authority: original.authority,
625
+ });
626
+ if (wasClosed) {
627
+ await maybeCloseEngagement(state).catch(reportError);
628
+ }
629
+ if (!outcome.ok) {
630
+ return Object.freeze({
631
+ ok: false,
632
+ accepted: false,
633
+ mailId: input.mailId,
634
+ code: outcome.code,
635
+ reason: Object.freeze({ code: outcome.code, detail: outcome.reason }),
636
+ escalated: outcome.escalated,
637
+ });
638
+ }
639
+ return Object.freeze({
640
+ ok: true,
641
+ accepted: true,
642
+ mailId: outcome.mail.id,
643
+ mail: outcome.mail,
644
+ ...(outcome.reply ? { reply: outcome.reply } : {}),
645
+ ...(outcome.result ? { result: outcome.result } : outcome.reply ? { result: outcome.reply } : {}),
646
+ });
647
+ },
648
+ async writeBoard(engagementId, input) {
649
+ const key = typeof input.key === 'string' ? input.key.trim() : '';
650
+ if (!key)
651
+ throw new TypeError('Board entry key must be a non-empty string');
652
+ const kind = input.kind ?? 'note';
653
+ let board = boards.get(engagementId);
654
+ if (!board) {
655
+ board = new Map();
656
+ boards.set(engagementId, board);
657
+ }
658
+ const existing = board.get(key);
659
+ const version = (existing?.version ?? 0) + 1;
660
+ const updatedAt = clock();
661
+ const entry = Object.freeze({
662
+ key,
663
+ kind,
664
+ content: input.content,
665
+ author: input.author,
666
+ updatedAt,
667
+ version,
668
+ ...(input.status !== undefined ? { status: input.status } : {}),
669
+ });
670
+ board.set(key, entry);
671
+ await record(engagementId, 'board_written', {
672
+ actor: input.author,
673
+ key,
674
+ kind,
675
+ content: input.content,
676
+ ...(input.status !== undefined ? { status: input.status } : {}),
677
+ ...(input.mailId !== undefined ? { mailId: input.mailId } : {}),
678
+ version,
679
+ });
680
+ return entry;
681
+ },
682
+ async readBoard(engagementId, filter) {
683
+ const board = boards.get(engagementId);
684
+ if (board && board.size > 0) {
685
+ const entries = [...board.values()].filter((entry) => {
686
+ if (filter?.key !== undefined && entry.key !== filter.key)
687
+ return false;
688
+ if (filter?.kind !== undefined && entry.kind !== filter.kind)
689
+ return false;
690
+ return true;
691
+ });
692
+ return Object.freeze(entries);
693
+ }
694
+ const records = await options.journal.read(engagementId);
695
+ return reconstructBoardFromEvents(records, filter);
696
+ },
697
+ async charge(current, usage) {
698
+ const result = options.ledger.charge(current.grantId, usage);
699
+ await record(current.engagementId, result.ok ? 'budget_charged' : 'budget_exceeded', {
700
+ grantId: current.grantId,
701
+ mailId: current.id,
702
+ usage,
703
+ ...(result.ok ? {} : { exhausted: result.exhausted ?? [] }),
704
+ });
705
+ return result;
706
+ },
707
+ cancel(mailId, reason) {
708
+ return mailbox.cancel(mailId, reason);
709
+ },
710
+ cancelSender(actor, lane, senderId, reason) {
711
+ return mailbox.cancelSender({ actor, lane }, senderId, reason);
712
+ },
713
+ engagement(id) {
714
+ return engagements.get(id)?.engagement;
715
+ },
716
+ graph(id) {
717
+ const state = engagements.get(id) ?? allEngagements.get(id);
718
+ return state ? graphOf(state) : undefined;
719
+ },
720
+ idle() {
721
+ return mailbox.idle();
722
+ },
723
+ async close(reason) {
724
+ closed = true;
725
+ await mailbox.close(reason);
726
+ for (const waiter of askWaiters.values())
727
+ waiter.resolve({ status: 'cancelled', content: reason ?? 'harness closed' });
728
+ askWaiters.clear();
729
+ },
730
+ };
731
+ }