@zhin.js/harness 0.0.0-stage → 1.0.1

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,6 @@
1
+ import type { Mailbox, MailboxOptions } from './contracts.js';
2
+ export interface MailboxLocalOptions {
3
+ /** Receives swallowed errors from `onTurnFinished` and policy callbacks / invalid batch selections. */
4
+ readonly onError?: (error: unknown, context: string) => void;
5
+ }
6
+ export declare function createMailbox(options: MailboxOptions, local?: MailboxLocalOptions): Mailbox;
package/lib/mailbox.js ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Mailbox scheduler (ADR 0048 §3.1, H3).
3
+ *
4
+ * Semantics:
5
+ * - Key = (mail.to, laneOf(mail)). A lane defaults to the mail's engagement,
6
+ * so without explicit lanes one actor runs one turn per engagement at a
7
+ * time. At most one turn runs per key; different keys run in parallel,
8
+ * bounded by `maxConcurrentTurns` across all keys.
9
+ * - Fairness: when a slot frees, idle keys are offered the slot in global
10
+ * arrival order of their oldest queued mail, so a busy key cannot starve
11
+ * the others. A key whose policy says `wait` does not hold a slot.
12
+ * - Policy (`InboxPolicy`, all members optional): `onPost` decides what a new
13
+ * mail does (enqueue / supersede / stop / reject), `ready` picks the next
14
+ * batch of an idle key or asks to wait until a time (merge and danmaku
15
+ * windows), `staleOf` answers the running turn's pre-send check. Without a
16
+ * policy the mailbox is plain FIFO, one mail per turn.
17
+ * - A `ready` batch must be an in-order subsequence of the key's queue that
18
+ * contains its first mail; anything else is a programming error and only
19
+ * the first mail runs. Selected mails leave the queue.
20
+ * - Supersede and stale discard requeue the running batch (in front of the
21
+ * queue / right before the follow-up's group). Requeued mails are reported
22
+ * to `onTurnFinished` only once, by the turn that finally answers them.
23
+ * - Queued mails older than `queueTtlMs` when their key would start are
24
+ * dropped (`expired`). Dropped and cancelled mails are reported one by one
25
+ * with `{status:'cancelled', reason}`.
26
+ * - `post` never starts a turn synchronously; turns start in a microtask (or
27
+ * when a `wait` timer fires, via the injectable `setTimer`).
28
+ * - A runner rejection becomes `{status:'failed', content: message}`.
29
+ * `onTurnFinished` is awaited before the key (and its slot) is released;
30
+ * errors it throws are swallowed and reported through `onError`.
31
+ * - `cancel` aborts a running batch's signal (a runner that resolves with a
32
+ * non-cancelled status after abort is reported as cancelled), or removes a
33
+ * queued mail and reports it cancelled.
34
+ * - `close` rejects new posts, drops queued mails (reported cancelled), aborts
35
+ * running turns, clears wait timers and resolves once everything settled.
36
+ */
37
+ import { laneOf, senderOf } from './contracts.js';
38
+ const FRESH = Object.freeze({ kind: 'fresh' });
39
+ function defaultSetTimer(callback, delayMs) {
40
+ const handle = setTimeout(callback, delayMs);
41
+ handle.unref?.();
42
+ return () => clearTimeout(handle);
43
+ }
44
+ export function createMailbox(options, local = {}) {
45
+ const policy = options.policy ?? {};
46
+ const now = options.now ?? Date.now;
47
+ const setTimer = options.setTimer ?? defaultSetTimer;
48
+ const ttl = Math.max(0, options.queueTtlMs ?? 0);
49
+ const keys = new Map();
50
+ const lastTurns = new Map();
51
+ const settling = new Set();
52
+ const idleWaiters = [];
53
+ let seq = 0;
54
+ let runningCount = 0;
55
+ let queuedCount = 0;
56
+ let notifying = 0;
57
+ let closed = false;
58
+ let pumpScheduled = false;
59
+ const report = (error, context) => {
60
+ try {
61
+ local.onError?.(error, context);
62
+ }
63
+ catch {
64
+ /* ignore */
65
+ }
66
+ };
67
+ const keyId = (actor, lane) => `${actor}\u0000${lane}`;
68
+ const keyOf = (st, first) => ({
69
+ actor: st.actor,
70
+ lane: st.lane,
71
+ engagementId: (first ?? st.pending[0]?.mail ?? st.running?.batch[0]?.mail)?.engagementId ?? st.lastEngagement,
72
+ });
73
+ const runningView = (r) => ({
74
+ batch: r.batch,
75
+ startedAt: r.startedAt,
76
+ startSeq: r.startSeq,
77
+ delivered: r.delivered,
78
+ acted: r.acted,
79
+ });
80
+ const viewOf = (st) => {
81
+ const last = lastTurns.get(keyId(st.actor, st.lane));
82
+ return {
83
+ key: keyOf(st),
84
+ now: now(),
85
+ pending: st.pending.slice(),
86
+ ...(st.running ? { running: runningView(st.running) } : {}),
87
+ ...(last ? { lastTurn: last } : {}),
88
+ };
89
+ };
90
+ const checkIdle = () => {
91
+ if (runningCount === 0 && queuedCount === 0 && notifying === 0) {
92
+ for (const w of idleWaiters.splice(0))
93
+ w();
94
+ }
95
+ };
96
+ const notify = async (key, batch, result) => {
97
+ try {
98
+ await options.onTurnFinished?.(key, batch, result);
99
+ }
100
+ catch (e) {
101
+ report(e, 'onTurnFinished');
102
+ }
103
+ };
104
+ const notifyDetached = (key, batch, result) => {
105
+ notifying++;
106
+ const p = notify(key, batch, result).finally(() => {
107
+ notifying--;
108
+ settling.delete(p);
109
+ checkIdle();
110
+ });
111
+ settling.add(p);
112
+ };
113
+ /** Removes `entries` from the queue and reports each one cancelled. */
114
+ const drop = (st, entries, reason, content) => {
115
+ if (entries.length === 0)
116
+ return;
117
+ const gone = new Set(entries);
118
+ st.pending = st.pending.filter((e) => !gone.has(e));
119
+ queuedCount -= entries.length;
120
+ for (const e of entries) {
121
+ notifyDetached(keyOf(st, e.mail), [e.mail], { status: 'cancelled', reason, ...(content !== undefined ? { content } : {}) });
122
+ }
123
+ };
124
+ const clearTimer = (st) => {
125
+ st.cancelTimer?.();
126
+ st.cancelTimer = undefined;
127
+ };
128
+ const cleanup = (st) => {
129
+ if (st.pending.length === 0 && !st.running) {
130
+ clearTimer(st);
131
+ const id = keyId(st.actor, st.lane);
132
+ if (keys.get(id) === st)
133
+ keys.delete(id);
134
+ }
135
+ };
136
+ const schedulePump = () => {
137
+ if (pumpScheduled)
138
+ return;
139
+ pumpScheduled = true;
140
+ queueMicrotask(() => {
141
+ pumpScheduled = false;
142
+ pump();
143
+ });
144
+ };
145
+ const pruneMemory = (t) => {
146
+ const memory = Math.max(0, policy.memoryMs ?? 0);
147
+ for (const [id, last] of lastTurns) {
148
+ if (t - last.endedAt >= memory && !keys.get(id)?.running)
149
+ lastTurns.delete(id);
150
+ }
151
+ };
152
+ const decideReady = (st) => {
153
+ const fallback = { kind: 'run', batch: [st.pending[0].mail] };
154
+ if (!policy.ready)
155
+ return fallback;
156
+ let decision;
157
+ try {
158
+ decision = policy.ready(viewOf(st));
159
+ }
160
+ catch (e) {
161
+ report(e, 'batch');
162
+ return fallback;
163
+ }
164
+ if (decision.kind === 'wait')
165
+ return decision;
166
+ let cursor = 0;
167
+ const valid = decision.batch.length > 0 &&
168
+ decision.batch[0] === st.pending[0].mail &&
169
+ decision.batch.every((m) => {
170
+ while (cursor < st.pending.length && st.pending[cursor].mail !== m)
171
+ cursor++;
172
+ if (cursor >= st.pending.length)
173
+ return false;
174
+ cursor++;
175
+ return true;
176
+ });
177
+ if (!valid) {
178
+ report(new Error('invalid batch selection'), 'batch');
179
+ return fallback;
180
+ }
181
+ return decision;
182
+ };
183
+ const pump = () => {
184
+ if (closed)
185
+ return;
186
+ const t = now();
187
+ pruneMemory(t);
188
+ const idle = [...keys.values()].filter((st) => !st.running && st.pending.length > 0);
189
+ for (const st of idle) {
190
+ if (ttl > 0)
191
+ drop(st, st.pending.filter((e) => t - e.postedAt > ttl), 'expired', 'expired');
192
+ }
193
+ const candidates = idle
194
+ .filter((st) => {
195
+ if (st.pending.length > 0)
196
+ return true;
197
+ cleanup(st);
198
+ return false;
199
+ })
200
+ .sort((a, b) => a.pending[0].seq - b.pending[0].seq);
201
+ for (const st of candidates) {
202
+ if (runningCount >= options.maxConcurrentTurns)
203
+ return;
204
+ const decision = decideReady(st);
205
+ if (decision.kind === 'run') {
206
+ start(st, decision.batch);
207
+ continue;
208
+ }
209
+ clearTimer(st);
210
+ if (decision.until !== undefined) {
211
+ st.cancelTimer = setTimer(() => {
212
+ st.cancelTimer = undefined;
213
+ schedulePump();
214
+ }, Math.max(0, decision.until - t));
215
+ }
216
+ }
217
+ };
218
+ const staleCheck = (st, running) => {
219
+ if (running.discarded)
220
+ return running.discarded;
221
+ if (st.running !== running || !policy.staleOf)
222
+ return FRESH;
223
+ let decision;
224
+ try {
225
+ decision = policy.staleOf({ ...viewOf(st), running: runningView(running) });
226
+ }
227
+ catch (e) {
228
+ report(e, 'staleOf');
229
+ return FRESH;
230
+ }
231
+ if (decision.kind !== 'discard')
232
+ return decision;
233
+ const anchor = st.pending.findIndex((e) => e.mail.id === decision.carryInto);
234
+ if (anchor < 0) {
235
+ report(new Error(`stale carry target ${decision.carryInto} is not queued`), 'staleOf');
236
+ return FRESH;
237
+ }
238
+ const t = now();
239
+ const carried = running.batch
240
+ .filter((e) => !running.requeued.has(e.mail.id))
241
+ .map((e) => ({ mail: e.mail, seq: e.seq, postedAt: t, carriedInto: decision.carryInto }));
242
+ st.pending.splice(anchor, 0, ...carried);
243
+ queuedCount += carried.length;
244
+ for (const e of carried)
245
+ running.requeued.add(e.mail.id);
246
+ running.discarded = Object.freeze({ kind: 'discard', senders: Object.freeze([...decision.senders]) });
247
+ return running.discarded;
248
+ };
249
+ const start = (st, selected) => {
250
+ clearTimer(st);
251
+ const chosen = new Set(selected);
252
+ const entries = st.pending.filter((e) => chosen.has(e.mail));
253
+ st.pending = st.pending.filter((e) => !chosen.has(e.mail));
254
+ queuedCount -= entries.length;
255
+ const t = now();
256
+ const running = {
257
+ batch: entries,
258
+ controller: new AbortController(),
259
+ startedAt: t,
260
+ startSeq: seq,
261
+ delivered: false,
262
+ acted: false,
263
+ requeued: new Set(),
264
+ };
265
+ const facts = Object.freeze({
266
+ queuedMs: Math.max(0, t - Math.min(...entries.map((e) => e.postedAt))),
267
+ mergedMessages: entries.length,
268
+ supersededPrevious: entries.some((e) => e.superseded === true),
269
+ carried: entries.some((e) => e.carriedInto !== undefined),
270
+ evicted: st.evicted,
271
+ });
272
+ st.evicted = 0;
273
+ st.running = running;
274
+ runningCount++;
275
+ const batch = entries.map((e) => e.mail);
276
+ const control = {
277
+ facts,
278
+ markDelivered: () => { running.delivered = true; },
279
+ markActed: () => { running.acted = true; },
280
+ staleCheck: () => staleCheck(st, running),
281
+ };
282
+ const p = (async () => {
283
+ let result;
284
+ try {
285
+ result = await options.run(st.actor, batch, running.controller.signal, control);
286
+ }
287
+ catch (e) {
288
+ result = { status: 'failed', content: e instanceof Error ? e.message : String(e) };
289
+ }
290
+ if (running.controller.signal.aborted && result.status !== 'cancelled') {
291
+ result = { status: 'cancelled', content: result.content };
292
+ }
293
+ lastTurns.set(keyId(st.actor, st.lane), { batch, endedAt: now() });
294
+ const answered = batch.filter((m) => !running.requeued.has(m.id));
295
+ if (answered.length > 0)
296
+ await notify(keyOf(st, answered[0]), answered, result);
297
+ })().finally(() => {
298
+ st.running = undefined;
299
+ runningCount--;
300
+ cleanup(st);
301
+ settling.delete(p);
302
+ if (!closed)
303
+ schedulePump();
304
+ checkIdle();
305
+ });
306
+ settling.add(p);
307
+ };
308
+ const decidePost = (st, entry) => {
309
+ if (!policy.onPost)
310
+ return { kind: 'enqueue' };
311
+ try {
312
+ return policy.onPost(entry, viewOf(st));
313
+ }
314
+ catch (e) {
315
+ report(e, 'onPost');
316
+ return { kind: 'enqueue' };
317
+ }
318
+ };
319
+ return {
320
+ post(mail) {
321
+ if (closed)
322
+ return { accepted: false, code: 'mailbox_closed', message: 'mailbox is closed' };
323
+ const lane = laneOf(mail);
324
+ const id = keyId(mail.to, lane);
325
+ const st = keys.get(id) ?? { actor: mail.to, lane, pending: [], evicted: 0, lastEngagement: mail.engagementId };
326
+ const entry = { mail, seq, postedAt: now() };
327
+ const decision = decidePost(st, entry);
328
+ if (decision.kind === 'reject')
329
+ return { accepted: false, code: decision.code, message: decision.message };
330
+ if (decision.kind === 'stop') {
331
+ st.running?.controller.abort('stopped');
332
+ const senders = new Set(decision.cancelSenders);
333
+ drop(st, st.pending.filter((e) => senders.has(senderOf(e.mail))), 'stopped', 'stopped');
334
+ cleanup(st);
335
+ checkIdle();
336
+ return { accepted: true, position: 0, disposition: 'stopped' };
337
+ }
338
+ const evict = decision.kind === 'enqueue' && decision.evict ? new Set(decision.evict) : undefined;
339
+ const evicted = evict ? st.pending.filter((e) => evict.has(e.mail.id)) : [];
340
+ if (st.pending.length - evicted.length >= options.maxQueuedPerKey) {
341
+ return { accepted: false, code: 'queue_full', message: `queue full for ${mail.to} in ${lane}` };
342
+ }
343
+ if (!keys.has(id))
344
+ keys.set(id, st);
345
+ st.lastEngagement = mail.engagementId;
346
+ seq++;
347
+ let position;
348
+ let disposition;
349
+ const running = st.running;
350
+ if (decision.kind === 'supersede' && running) {
351
+ running.controller.abort('superseded');
352
+ const t = entry.postedAt;
353
+ const requeued = running.batch
354
+ .filter((e) => !running.requeued.has(e.mail.id))
355
+ .map((e) => ({ mail: e.mail, seq: e.seq, postedAt: t, superseded: true }));
356
+ for (const e of requeued)
357
+ running.requeued.add(e.mail.id);
358
+ const fresh = { ...entry, superseded: true };
359
+ st.pending.unshift(...requeued, fresh);
360
+ queuedCount += requeued.length + 1;
361
+ position = requeued.length + 1;
362
+ disposition = 'superseded';
363
+ }
364
+ else {
365
+ if (evicted.length > 0) {
366
+ st.evicted += evicted.length;
367
+ drop(st, evicted, 'evicted', 'evicted');
368
+ }
369
+ st.pending.push(entry);
370
+ queuedCount++;
371
+ position = st.pending.length;
372
+ disposition = decision.kind === 'enqueue' && decision.merged ? 'merged' : 'queued';
373
+ }
374
+ schedulePump();
375
+ return { accepted: true, position, disposition };
376
+ },
377
+ cancel(mailId, reason) {
378
+ for (const st of keys.values()) {
379
+ const i = st.pending.findIndex((e) => e.mail.id === mailId);
380
+ if (i >= 0) {
381
+ drop(st, [st.pending[i]], 'cancelled', reason);
382
+ cleanup(st);
383
+ return true;
384
+ }
385
+ const running = st.running;
386
+ if (running && !running.requeued.has(mailId) && running.batch.some((e) => e.mail.id === mailId)) {
387
+ running.controller.abort(reason ?? 'cancelled');
388
+ return true;
389
+ }
390
+ }
391
+ return false;
392
+ },
393
+ cancelSender(key, senderId, reason = 'cancelled') {
394
+ const st = keys.get(keyId(key.actor, key.lane));
395
+ if (!st)
396
+ return 0;
397
+ const dropped = st.pending.filter((e) => senderOf(e.mail) === senderId);
398
+ drop(st, dropped, reason, reason);
399
+ cleanup(st);
400
+ return dropped.length;
401
+ },
402
+ idle() {
403
+ if (runningCount === 0 && queuedCount === 0 && notifying === 0)
404
+ return Promise.resolve();
405
+ return new Promise((resolve) => idleWaiters.push(resolve));
406
+ },
407
+ async close(reason) {
408
+ closed = true;
409
+ for (const st of [...keys.values()]) {
410
+ clearTimer(st);
411
+ drop(st, st.pending.slice(), 'closed', reason);
412
+ if (st.running)
413
+ st.running.controller.abort(reason ?? 'closed');
414
+ else
415
+ cleanup(st);
416
+ }
417
+ while (settling.size > 0)
418
+ await Promise.all([...settling]);
419
+ checkIdle();
420
+ },
421
+ };
422
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * IM admission as one inbox policy (ADR 0048 §3.1 / §8 H3): ports the
3
+ * semantics of `TurnAdmissionGate` (#725, ADR 0038 §1/§4/§5) onto the
4
+ * Mailbox. What happens to a mail that arrives on a lane:
5
+ *
6
+ * - **Not addressed** (`unaddressed`): an ordinary group message that
7
+ * neither @s nor replies to the bot never becomes a turn
8
+ * (`not_addressed`).
9
+ * - **Explicit stop** (`isStop`) from the running batch's initiator (or the
10
+ * master) aborts the running turn and drops the stopping sender's and the
11
+ * initiator's queued mails; the stop itself never runs. Anyone else:
12
+ * `forbidden`; nothing running: `not_running`.
13
+ * - **Same sender, nothing delivered yet** (`supersedeSameSender`): a plain
14
+ * single-sender turn is aborted and its mails are requeued in front with
15
+ * the new one, so one fresh turn answers everything the user typed.
16
+ * - **Same sender, already queued**: plain text joins the queued group
17
+ * (burst coalescing).
18
+ * - **Anyone else**: queued FIFO behind the running turn. A member never
19
+ * aborts, or joins, another member's single turn.
20
+ * - **Merge window** (`merge`): mergeable plain text from different senders
21
+ * waits in one group, answered once quiet / at `maxWaitMs`.
22
+ * - **Stale reply** (`staleOf`): when a sender of the running turn follows up
23
+ * into a merge window before anything was delivered, the reply is
24
+ * discarded and the turn's mails join the follow-up's window, unless the
25
+ * turn acted or this chain was already discarded once (starvation guard).
26
+ * - **Danmaku window** (`live`): see `live.ts`.
27
+ *
28
+ * `maxQueuedTurns` bounds queued groups (a window counts once); opening one
29
+ * more is `queue_full`. Queue TTL and the hard per-lane mail cap are
30
+ * `MailboxOptions.queueTtlMs` / `maxQueuedPerKey`.
31
+ */
32
+ import type { InboxPolicy } from '../contracts.js';
33
+ import { type LiveWindowOptions } from './live.js';
34
+ import { type MergeWindowOptions } from './merge.js';
35
+ export declare const DEFAULT_MAX_QUEUED_TURNS = 5;
36
+ export interface InboxPolicyOptions {
37
+ /** Queued (not yet running) groups allowed per lane. */
38
+ readonly maxQueuedTurns: number;
39
+ /** When true, a new mail from the running turn's sender replaces that turn. */
40
+ readonly supersedeSameSender: boolean;
41
+ /** ADR 0038 §1 merge window; omitted = disabled. */
42
+ readonly merge?: MergeWindowOptions;
43
+ /** ADR 0038 §5 danmaku window; omitted = disabled. */
44
+ readonly live?: LiveWindowOptions;
45
+ /** Cap of one danmaku window. Default `LIVE_WINDOW_MAX_MESSAGES`. */
46
+ readonly liveMaxMessages?: number;
47
+ }
48
+ export declare function createInboxPolicy(input: InboxPolicyOptions): InboxPolicy;
@@ -0,0 +1,118 @@
1
+ import { classifyMail, groupInbox } from './groups.js';
2
+ import { LIVE_WINDOW_MAX_MESSAGES, liveEvictions, liveWindowDueAt, normalizeLiveWindow } from './live.js';
3
+ import { inboxMarkers } from './markers.js';
4
+ import { mergeWindowDueAt, normalizeMergeWindow } from './merge.js';
5
+ export const DEFAULT_MAX_QUEUED_TURNS = 5;
6
+ const FRESH = Object.freeze({ kind: 'fresh' });
7
+ export function createInboxPolicy(input) {
8
+ const merge = input.merge ? normalizeMergeWindow(input.merge) : undefined;
9
+ const live = input.live ? normalizeLiveWindow(input.live) : undefined;
10
+ const maxQueuedTurns = Math.max(0, Math.floor(input.maxQueuedTurns));
11
+ const liveMax = Math.max(1, Math.floor(input.liveMaxMessages ?? LIVE_WINDOW_MAX_MESSAGES));
12
+ const grouping = {
13
+ ...(merge ? { merge: { maxSenders: merge.maxSenders } } : {}),
14
+ live: live !== undefined,
15
+ };
16
+ const groups = (entries) => groupInbox(entries, grouping);
17
+ const runningKind = (running) => {
18
+ const all = groups(running.batch);
19
+ return all.length === 1 ? all[0].kind : 'merge';
20
+ };
21
+ const initiatorOf = (running) => inboxMarkers(running.batch[0].mail).senderId;
22
+ const supersedes = (senderId, plainText, view) => {
23
+ const running = view.running;
24
+ if (!input.supersedeSameSender || !running || running.delivered || !plainText)
25
+ return false;
26
+ if (runningKind(running) !== 'single')
27
+ return false;
28
+ const marks = running.batch.map((e) => inboxMarkers(e.mail));
29
+ if (!marks.every((m) => m.senderId === senderId && m.plainText))
30
+ return false;
31
+ return !view.pending.some((e) => inboxMarkers(e.mail).senderId === senderId);
32
+ };
33
+ const lastLiveEndedAt = (view) => {
34
+ const last = view.lastTurn;
35
+ if (!live || !last || last.batch.length === 0)
36
+ return undefined;
37
+ return classifyMail(last.batch[0], grouping) === 'live' ? last.endedAt : undefined;
38
+ };
39
+ return Object.freeze({
40
+ memoryMs: live?.minIntervalMs ?? 0,
41
+ onPost(entry, view) {
42
+ const m = inboxMarkers(entry.mail);
43
+ if (m.unaddressed && !m.isStop) {
44
+ return { kind: 'reject', code: 'not_addressed', message: 'the message does not address the agent' };
45
+ }
46
+ if (m.isStop) {
47
+ const running = view.running;
48
+ if (!running)
49
+ return { kind: 'reject', code: 'not_running', message: 'nothing is running on this lane' };
50
+ const initiator = initiatorOf(running);
51
+ if (initiator !== m.senderId && !m.isMaster) {
52
+ return { kind: 'reject', code: 'forbidden', message: 'only the turn initiator or the master may stop it' };
53
+ }
54
+ return { kind: 'stop', cancelSenders: [...new Set([m.senderId, initiator])] };
55
+ }
56
+ const before = groups(view.pending);
57
+ const after = groups([...view.pending, entry]);
58
+ const group = after.find((g) => g.entries.includes(entry));
59
+ if (group.entries.length > 1) {
60
+ if (group.kind === 'live') {
61
+ const evict = liveEvictions(group.entries, liveMax);
62
+ return evict.length > 0 ? { kind: 'enqueue', merged: true, evict } : { kind: 'enqueue', merged: true };
63
+ }
64
+ return { kind: 'enqueue', merged: true };
65
+ }
66
+ if (group.kind === 'single' && supersedes(m.senderId, m.plainText, view))
67
+ return { kind: 'supersede' };
68
+ if (before.length >= maxQueuedTurns) {
69
+ return { kind: 'reject', code: 'queue_full', message: `at most ${maxQueuedTurns} queued turns per lane` };
70
+ }
71
+ return { kind: 'enqueue' };
72
+ },
73
+ ready(view) {
74
+ const head = groups(view.pending)[0];
75
+ if (!head)
76
+ return { kind: 'wait' };
77
+ const batch = head.entries.map((e) => e.mail);
78
+ let due = view.now;
79
+ if (head.kind === 'merge' && merge)
80
+ due = mergeWindowDueAt(head, merge);
81
+ if (head.kind === 'live' && live)
82
+ due = liveWindowDueAt(head, lastLiveEndedAt(view), live);
83
+ return due <= view.now ? { kind: 'run', batch } : { kind: 'wait', until: due };
84
+ },
85
+ staleOf(view) {
86
+ if (!merge)
87
+ return FRESH;
88
+ const running = view.running;
89
+ const answered = new Set(running.batch.map((e) => inboxMarkers(e.mail).senderId));
90
+ const queued = groups(view.pending);
91
+ const stale = [];
92
+ for (const e of [...view.pending].sort((a, b) => a.seq - b.seq)) {
93
+ if (e.carriedInto !== undefined || e.seq < running.startSeq)
94
+ continue;
95
+ if (classifyMail(e.mail, grouping) !== 'merge')
96
+ continue;
97
+ const s = inboxMarkers(e.mail).senderId;
98
+ if (answered.has(s) && !stale.includes(s))
99
+ stale.push(s);
100
+ }
101
+ if (stale.length === 0 || running.delivered)
102
+ return FRESH;
103
+ if (running.acted)
104
+ return { kind: 'send', senders: stale, reason: 'acted' };
105
+ if (running.batch.some((e) => e.carriedInto !== undefined))
106
+ return { kind: 'send', senders: stale, reason: 'starvation' };
107
+ let target;
108
+ for (let i = queued.length - 1; i >= 0 && !target; i -= 1) {
109
+ if (queued[i].kind === 'merge' && queued[i].senders.some((s) => stale.includes(s)))
110
+ target = queued[i];
111
+ }
112
+ if (!target)
113
+ return FRESH;
114
+ const anchor = target.entries[0];
115
+ return { kind: 'discard', senders: stale, carryInto: anchor.carriedInto ?? anchor.mail.id };
116
+ },
117
+ });
118
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Turn groups of a lane's queue (ADR 0048 H3; semantics of the former
3
+ * `TurnAdmissionGate`, ADR 0038 §1/§5 and #725).
4
+ *
5
+ * The queue holds single mails; a group is the set of queued mails one turn
6
+ * will answer. Groups are recomputed from the queue on every decision, so
7
+ * the policy stays pure. Replaying the queue in order:
8
+ *
9
+ * - **live** (danmaku, when enabled): joins the lane's last live group, no
10
+ * sender limit.
11
+ * - **merge** (mergeable plain text, when a merge window is enabled): joins
12
+ * the last merge group that already has the sender (burst), else the last
13
+ * merge group with fewer than `maxSenders` senders, else opens a new one.
14
+ * - **single** (everything else): joins the sender's own queued group when
15
+ * that is a single plain-text group and this mail is plain text too
16
+ * (burst coalescing); otherwise opens a new group. A member never joins
17
+ * another member's single group.
18
+ * - Mails requeued by a stale discard (`carriedInto`) belong to the group of
19
+ * their target mail, ahead of it, regardless of `maxSenders`; when the
20
+ * target left the queue they form their own group.
21
+ *
22
+ * Groups are ordered by their first mail in the queue; the head group always
23
+ * contains the queue's first mail.
24
+ */
25
+ import type { InboxEntry, Mail } from '../contracts.js';
26
+ export type InboxGroupKind = 'single' | 'merge' | 'live';
27
+ export interface InboxGroup {
28
+ readonly kind: InboxGroupKind;
29
+ /** In queue order. */
30
+ readonly entries: readonly InboxEntry[];
31
+ /** Distinct senders in queue order. */
32
+ readonly senders: readonly string[];
33
+ readonly plainText: boolean;
34
+ /** Earliest / latest (re)queue time of the group's own (not carried) mails. */
35
+ readonly openedAt: number;
36
+ readonly lastJoinedAt: number;
37
+ }
38
+ export interface InboxGroupingOptions {
39
+ /** Merge windows enabled; omitted = mergeable is ignored. */
40
+ readonly merge?: {
41
+ readonly maxSenders: number;
42
+ };
43
+ /** Danmaku windows enabled. */
44
+ readonly live?: boolean;
45
+ }
46
+ export declare function classifyMail(mail: Mail, options: InboxGroupingOptions): InboxGroupKind;
47
+ export declare function groupInbox(entries: readonly InboxEntry[], options: InboxGroupingOptions): InboxGroup[];