pi-better-subagents 0.1.17 → 0.1.18
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.
- package/finalization.ts +16 -8
- package/index.ts +81 -31
- package/package.json +1 -1
- package/registry.ts +3 -0
- package/shared-callback-batcher.ts +320 -0
package/finalization.ts
CHANGED
|
@@ -62,6 +62,13 @@ export function finalizeRun(
|
|
|
62
62
|
if (outcome.incomplete) meta.failureReason = "incomplete-stream";
|
|
63
63
|
meta.exitCode = code;
|
|
64
64
|
meta.endedAt = Date.now();
|
|
65
|
+
const callback = meta.callback !== false;
|
|
66
|
+
if (callback
|
|
67
|
+
&& meta.completionCallbackPendingAt === undefined
|
|
68
|
+
&& meta.completionCallbackSentAt === undefined
|
|
69
|
+
&& meta.completionCallbackSuppressedAt === undefined) {
|
|
70
|
+
meta.completionCallbackPendingAt = meta.endedAt;
|
|
71
|
+
}
|
|
65
72
|
writeMeta(meta);
|
|
66
73
|
|
|
67
74
|
const label = meta.name ? `${meta.name} (${id})` : id;
|
|
@@ -84,10 +91,9 @@ export function finalizeRun(
|
|
|
84
91
|
/* ignore */
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// breaking, but it is NEVER put into content — the result lives in subagent_result.
|
|
94
|
+
// buildCompletionDelivery remains the compatibility formatter for callers and
|
|
95
|
+
// direct finalizer tests. Production callback:true delivery is coalesced by the
|
|
96
|
+
// host wrapper; callback:false never invokes a model-message hook.
|
|
91
97
|
const delivery = buildCompletionDelivery({
|
|
92
98
|
id,
|
|
93
99
|
label,
|
|
@@ -98,10 +104,12 @@ export function finalizeRun(
|
|
|
98
104
|
lifecycleClassification: outcome.classification,
|
|
99
105
|
resultText: r.finalText || r.lastActivity || "",
|
|
100
106
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
if (callback) {
|
|
108
|
+
hooks.sendMessage?.(
|
|
109
|
+
{ customType: "subagent-complete", content: delivery.content, display: true },
|
|
110
|
+
delivery.options,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
105
113
|
|
|
106
114
|
return {
|
|
107
115
|
applied: true,
|
package/index.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
getSharedCapacityGate,
|
|
85
85
|
} from "./capacity.mjs";
|
|
86
86
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
87
|
+
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
87
88
|
import {
|
|
88
89
|
text,
|
|
89
90
|
subagentListTool,
|
|
@@ -237,12 +238,58 @@ function stopCurrentSessionSubagents(ctx: ExtensionContext): void {
|
|
|
237
238
|
|
|
238
239
|
function markCompletionCallbackSuppressed(id: string, reason: string, now: number = Date.now()): void {
|
|
239
240
|
const meta = readMeta(id);
|
|
240
|
-
if (!meta || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
241
|
+
if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
241
242
|
meta.completionCallbackSuppressedAt = now;
|
|
242
243
|
meta.completionCallbackSuppressedReason = reason;
|
|
243
244
|
writeMeta(meta);
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
function markCompletionCallbackSent(id: string, now: number): void {
|
|
248
|
+
const meta = readMeta(id);
|
|
249
|
+
if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
250
|
+
meta.completionCallbackSentAt = now;
|
|
251
|
+
writeMeta(meta);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Queue one durable ordinary terminal event on the host-shared batch. */
|
|
255
|
+
function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
|
|
256
|
+
const meta = readMeta(id);
|
|
257
|
+
if (!meta
|
|
258
|
+
|| meta.callback === false
|
|
259
|
+
|| meta.completionCallbackPendingAt === undefined
|
|
260
|
+
|| meta.completionCallbackSentAt !== undefined
|
|
261
|
+
|| meta.completionCallbackSuppressedAt !== undefined) return;
|
|
262
|
+
const label = meta.name ? `${meta.name} (${id})` : id;
|
|
263
|
+
getCallbackBatcher(pi).enqueue({
|
|
264
|
+
source: "subagent",
|
|
265
|
+
id,
|
|
266
|
+
label,
|
|
267
|
+
status: meta.status,
|
|
268
|
+
detailTool: "subagent_result",
|
|
269
|
+
callback: true,
|
|
270
|
+
isDelivered: () => {
|
|
271
|
+
const current = readMeta(id);
|
|
272
|
+
return current?.completionCallbackSentAt !== undefined
|
|
273
|
+
|| current?.completionCallbackSuppressedAt !== undefined;
|
|
274
|
+
},
|
|
275
|
+
getSuppressionReason: () => {
|
|
276
|
+
const current = readMeta(id);
|
|
277
|
+
if (!current) return "subagent metadata is unavailable";
|
|
278
|
+
return callbackSuppressionReason(current);
|
|
279
|
+
},
|
|
280
|
+
onDelivered: (at) => markCompletionCallbackSent(id, at),
|
|
281
|
+
onSuppressed: (reason, at) => markCompletionCallbackSuppressed(id, reason, at),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Recover only records explicitly marked pending; legacy terminal runs never replay. */
|
|
286
|
+
function recoverCompletionCallbacks(pi: ExtensionAPI): void {
|
|
287
|
+
for (const meta of listMetas()) {
|
|
288
|
+
if (!ownedByThisParent(meta)) continue;
|
|
289
|
+
enqueueCompletionCallback(pi, meta.id);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
246
293
|
function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
|
|
247
294
|
if (status === "orphaned") {
|
|
248
295
|
if (meta.orphanedCallbackSuppressedAt !== undefined) return;
|
|
@@ -409,12 +456,6 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
409
456
|
if (!pi) return;
|
|
410
457
|
if (isHealthCallbackHandled(meta, status)) return;
|
|
411
458
|
|
|
412
|
-
const suppressionReason = callbackSuppressionReason(meta);
|
|
413
|
-
if (suppressionReason) {
|
|
414
|
-
markHealthCallbackSuppressed(meta, status, suppressionReason, now);
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
459
|
const callback = meta.callback !== false;
|
|
419
460
|
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
420
461
|
const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
|
|
@@ -426,20 +467,35 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
426
467
|
writeMeta(meta);
|
|
427
468
|
return;
|
|
428
469
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
470
|
+
void getCallbackBatcher(pi).deliverUrgent({
|
|
471
|
+
source: "subagent",
|
|
472
|
+
id: meta.id,
|
|
473
|
+
label,
|
|
474
|
+
status,
|
|
475
|
+
customType: "subagent-health",
|
|
476
|
+
content: delivery.content,
|
|
477
|
+
isDelivered: () => {
|
|
478
|
+
const current = readMeta(meta.id);
|
|
479
|
+
return current ? isHealthCallbackHandled(current, status) : true;
|
|
480
|
+
},
|
|
481
|
+
getSuppressionReason: () => {
|
|
482
|
+
const current = readMeta(meta.id);
|
|
483
|
+
if (!current) return "subagent metadata is unavailable";
|
|
484
|
+
return callbackSuppressionReason(current);
|
|
485
|
+
},
|
|
486
|
+
onDelivered: (at) => {
|
|
487
|
+
const current = readMeta(meta.id);
|
|
488
|
+
if (!current || isHealthCallbackHandled(current, status)) return;
|
|
489
|
+
if (status === "orphaned") current.orphanedCallbackSentAt = at;
|
|
490
|
+
else current.lostCallbackSentAt = at;
|
|
491
|
+
writeMeta(current);
|
|
492
|
+
},
|
|
493
|
+
onSuppressed: (reason, at) => {
|
|
494
|
+
const current = readMeta(meta.id);
|
|
495
|
+
if (!current || isHealthCallbackHandled(current, status)) return;
|
|
496
|
+
markHealthCallbackSuppressed(current, status, reason, at);
|
|
497
|
+
},
|
|
498
|
+
});
|
|
443
499
|
}
|
|
444
500
|
|
|
445
501
|
/** One reconciliation + durable health-callback recovery pass. */
|
|
@@ -935,16 +991,7 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
|
|
|
935
991
|
notify: (message, level) => {
|
|
936
992
|
try { ctx.ui.notify(message, level); } catch { /* ignore */ }
|
|
937
993
|
},
|
|
938
|
-
sendMessage: (
|
|
939
|
-
const meta = readMeta(id);
|
|
940
|
-
if (!meta) return;
|
|
941
|
-
const suppressionReason = callbackSuppressionReason(meta);
|
|
942
|
-
if (suppressionReason) {
|
|
943
|
-
markCompletionCallbackSuppressed(id, suppressionReason);
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
pi.sendMessage(message, options);
|
|
947
|
-
},
|
|
994
|
+
sendMessage: () => enqueueCompletionCallback(pi, id),
|
|
948
995
|
});
|
|
949
996
|
}
|
|
950
997
|
|
|
@@ -1401,6 +1448,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1401
1448
|
catch { mainAgentStartedAt = undefined; }
|
|
1402
1449
|
mainAgentTools.clear();
|
|
1403
1450
|
activeCallbackOrigin = callbackOriginFromContext(ctx);
|
|
1451
|
+
recoverCompletionCallbacks(pi);
|
|
1404
1452
|
// Reload / session switch hardening (#48):
|
|
1405
1453
|
// - Drop any leftover overlay timers/confirm state from a prior session
|
|
1406
1454
|
// (defensive if the host skipped session_shutdown before re-start).
|
|
@@ -1432,6 +1480,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1432
1480
|
|
|
1433
1481
|
pi.on("session_before_switch", () => {
|
|
1434
1482
|
activeCallbackOrigin = undefined;
|
|
1483
|
+
cancelCallbackBatch(pi);
|
|
1435
1484
|
mainAgentStartedAt = undefined;
|
|
1436
1485
|
mainAgentTools.clear();
|
|
1437
1486
|
disposeBackgroundWorkNavigator();
|
|
@@ -1444,6 +1493,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1444
1493
|
// cannot become live process groups with no coordinator.
|
|
1445
1494
|
stopCurrentSessionSubagents(ctx);
|
|
1446
1495
|
activeCallbackOrigin = undefined;
|
|
1496
|
+
cancelCallbackBatch(pi);
|
|
1447
1497
|
mainAgentStartedAt = undefined;
|
|
1448
1498
|
mainAgentTools.clear();
|
|
1449
1499
|
stopTicker();
|
package/package.json
CHANGED
package/registry.ts
CHANGED
|
@@ -102,6 +102,9 @@ export interface RunMeta {
|
|
|
102
102
|
sessionId: string;
|
|
103
103
|
/** Foreground session that is allowed to receive unsolicited callbacks. */
|
|
104
104
|
callbackOrigin?: RunCallbackOrigin;
|
|
105
|
+
/** Durable ordinary-completion callback recovery and successful-handoff markers. */
|
|
106
|
+
completionCallbackPendingAt?: number;
|
|
107
|
+
completionCallbackSentAt?: number;
|
|
105
108
|
completionCallbackSuppressedAt?: number;
|
|
106
109
|
completionCallbackSuppressedReason?: string;
|
|
107
110
|
orphanedCallbackSuppressedAt?: number;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Generated from packages/callback-batcher/index.ts. Do not edit directly.
|
|
2
|
+
export type CallbackSource = "subagent" | "background-task";
|
|
3
|
+
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
4
|
+
|
|
5
|
+
export interface CallbackBatchHost {
|
|
6
|
+
sendMessage(
|
|
7
|
+
message: { customType: string; content: string; display: boolean },
|
|
8
|
+
options: Record<string, unknown>,
|
|
9
|
+
): unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CallbackBatchEvent {
|
|
13
|
+
source: CallbackSource;
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
status: string;
|
|
17
|
+
detailTool: CallbackDetailTool;
|
|
18
|
+
callback?: boolean;
|
|
19
|
+
isDelivered?: () => boolean;
|
|
20
|
+
getSuppressionReason?: () => string | undefined;
|
|
21
|
+
onDelivered?: (at: number) => void;
|
|
22
|
+
onSuppressed?: (reason: string, at: number) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UrgentCallbackEvent {
|
|
26
|
+
source: CallbackSource;
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
status: "orphaned" | "lost" | string;
|
|
30
|
+
customType: string;
|
|
31
|
+
content: string;
|
|
32
|
+
isDelivered?: () => boolean;
|
|
33
|
+
getSuppressionReason?: () => string | undefined;
|
|
34
|
+
onDelivered?: (at: number) => void;
|
|
35
|
+
onSuppressed?: (reason: string, at: number) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CallbackBatcherOptions {
|
|
39
|
+
windowMs?: number;
|
|
40
|
+
retryMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CallbackBatcher {
|
|
44
|
+
enqueue(event: CallbackBatchEvent): boolean;
|
|
45
|
+
flush(): Promise<boolean>;
|
|
46
|
+
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
47
|
+
cancel(): void;
|
|
48
|
+
pendingCount(): number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PendingEvent {
|
|
52
|
+
event: CallbackBatchEvent;
|
|
53
|
+
sequence: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SharedCallbackBatcherState {
|
|
57
|
+
byHost: WeakMap<object, CallbackBatcher>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
61
|
+
const DEFAULT_WINDOW_MS = 100;
|
|
62
|
+
const DEFAULT_RETRY_MS = 1_000;
|
|
63
|
+
const MAX_LABEL_CHARS = 160;
|
|
64
|
+
const MAX_ID_CHARS = 200;
|
|
65
|
+
const MAX_STATUS_CHARS = 80;
|
|
66
|
+
|
|
67
|
+
export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
|
|
68
|
+
export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
69
|
+
|
|
70
|
+
export function resolveCallbackBatchWindowMs(
|
|
71
|
+
value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
|
|
72
|
+
): number {
|
|
73
|
+
if (value === undefined || value === null || value === "") return DEFAULT_WINDOW_MS;
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
if (!Number.isFinite(parsed)) return DEFAULT_WINDOW_MS;
|
|
76
|
+
return Math.max(0, Math.min(5_000, Math.floor(parsed)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatCallbackBatch(events: readonly CallbackBatchEvent[]): string {
|
|
80
|
+
const count = events.length;
|
|
81
|
+
const heading = `${count} background completion${count === 1 ? " is" : "s are"} ready:`;
|
|
82
|
+
const rows = events.map((event) => {
|
|
83
|
+
const source = boundedField(event.source, 40);
|
|
84
|
+
const id = boundedField(event.id, MAX_ID_CHARS);
|
|
85
|
+
const label = boundedField(event.label, MAX_LABEL_CHARS);
|
|
86
|
+
const status = boundedField(event.status, MAX_STATUS_CHARS);
|
|
87
|
+
const detail = event.detailTool === "bg_task_status"
|
|
88
|
+
? `bg_task_status id=${id}`
|
|
89
|
+
: `subagent_result id=${JSON.stringify(id)}`;
|
|
90
|
+
return `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${detail}`;
|
|
91
|
+
});
|
|
92
|
+
return [
|
|
93
|
+
heading,
|
|
94
|
+
...rows,
|
|
95
|
+
"Retrieve durable results/status with the listed tools. Full results and logs are intentionally omitted.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createCallbackBatcher(
|
|
100
|
+
host: CallbackBatchHost,
|
|
101
|
+
options: CallbackBatcherOptions = {},
|
|
102
|
+
): CallbackBatcher {
|
|
103
|
+
const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
|
|
104
|
+
const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
|
|
105
|
+
const pending = new Map<string, PendingEvent>();
|
|
106
|
+
const inFlight = new Set<string>();
|
|
107
|
+
const urgentInFlight = new Set<string>();
|
|
108
|
+
let sequence = 0;
|
|
109
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
let flushPromise: Promise<boolean> | undefined;
|
|
111
|
+
|
|
112
|
+
const cancelTimer = (): void => {
|
|
113
|
+
if (timer) clearTimeout(timer);
|
|
114
|
+
timer = undefined;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const schedule = (delayMs: number): void => {
|
|
118
|
+
if (timer || pending.size === 0) return;
|
|
119
|
+
timer = setTimeout(() => {
|
|
120
|
+
timer = undefined;
|
|
121
|
+
void api.flush();
|
|
122
|
+
}, Math.max(0, delayMs));
|
|
123
|
+
timer.unref?.();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const enqueue = (event: CallbackBatchEvent): boolean => {
|
|
127
|
+
if (event.callback === false) return false;
|
|
128
|
+
const key = eventKey(event);
|
|
129
|
+
if (pending.has(key) || inFlight.has(key)) return false;
|
|
130
|
+
pending.set(key, { event, sequence: sequence++ });
|
|
131
|
+
schedule(windowMs);
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const performFlush = async (): Promise<boolean> => {
|
|
136
|
+
cancelTimer();
|
|
137
|
+
const snapshot = [...pending.entries()]
|
|
138
|
+
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
139
|
+
pending.clear();
|
|
140
|
+
for (const [key] of snapshot) inFlight.add(key);
|
|
141
|
+
|
|
142
|
+
const deliverable: Array<[string, PendingEvent]> = [];
|
|
143
|
+
for (const item of snapshot) {
|
|
144
|
+
const [key, pendingEvent] = item;
|
|
145
|
+
const disposition = eventDisposition(pendingEvent.event);
|
|
146
|
+
if (disposition.kind === "delivered") {
|
|
147
|
+
inFlight.delete(key);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (disposition.kind === "suppressed") {
|
|
151
|
+
invokeSuppressed(pendingEvent.event, disposition.reason, Date.now());
|
|
152
|
+
inFlight.delete(key);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
deliverable.push(item);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (deliverable.length === 0) {
|
|
159
|
+
if (pending.size > 0) schedule(windowMs);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await host.sendMessage(
|
|
165
|
+
{
|
|
166
|
+
customType: "background-completion-batch",
|
|
167
|
+
content: formatCallbackBatch(deliverable.map(([, item]) => item.event)),
|
|
168
|
+
display: true,
|
|
169
|
+
},
|
|
170
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
for (const [key] of deliverable) inFlight.delete(key);
|
|
174
|
+
const retryItems = [...deliverable, ...pending.entries()]
|
|
175
|
+
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
176
|
+
pending.clear();
|
|
177
|
+
for (const [key, item] of retryItems) {
|
|
178
|
+
if (!pending.has(key)) pending.set(key, item);
|
|
179
|
+
}
|
|
180
|
+
schedule(retryMs);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const deliveredAt = Date.now();
|
|
185
|
+
for (const [key, item] of deliverable) {
|
|
186
|
+
invokeDelivered(item.event, deliveredAt);
|
|
187
|
+
inFlight.delete(key);
|
|
188
|
+
}
|
|
189
|
+
if (pending.size > 0) schedule(windowMs);
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const flush = (): Promise<boolean> => {
|
|
194
|
+
if (flushPromise) return flushPromise;
|
|
195
|
+
flushPromise = performFlush().finally(() => {
|
|
196
|
+
flushPromise = undefined;
|
|
197
|
+
});
|
|
198
|
+
return flushPromise;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const deliverUrgent = (event: UrgentCallbackEvent): boolean | Promise<boolean> => {
|
|
202
|
+
const key = eventKey(event);
|
|
203
|
+
if (urgentInFlight.has(key)) return false;
|
|
204
|
+
const disposition = eventDisposition(event);
|
|
205
|
+
if (disposition.kind === "delivered") return true;
|
|
206
|
+
if (disposition.kind === "suppressed") {
|
|
207
|
+
invokeSuppressed(event, disposition.reason, Date.now());
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
urgentInFlight.add(key);
|
|
212
|
+
try {
|
|
213
|
+
const handoff = host.sendMessage(
|
|
214
|
+
{ customType: event.customType, content: event.content, display: true },
|
|
215
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
216
|
+
);
|
|
217
|
+
if (isPromiseLike(handoff)) {
|
|
218
|
+
return Promise.resolve(handoff).then(
|
|
219
|
+
() => {
|
|
220
|
+
invokeDelivered(event, Date.now());
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
() => false,
|
|
224
|
+
).finally(() => urgentInFlight.delete(key));
|
|
225
|
+
}
|
|
226
|
+
invokeDelivered(event, Date.now());
|
|
227
|
+
urgentInFlight.delete(key);
|
|
228
|
+
return true;
|
|
229
|
+
} catch {
|
|
230
|
+
urgentInFlight.delete(key);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const api: CallbackBatcher = {
|
|
236
|
+
enqueue,
|
|
237
|
+
flush,
|
|
238
|
+
deliverUrgent,
|
|
239
|
+
cancel() {
|
|
240
|
+
cancelTimer();
|
|
241
|
+
pending.clear();
|
|
242
|
+
},
|
|
243
|
+
pendingCount() {
|
|
244
|
+
return pending.size;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
return api;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function getCallbackBatcher(
|
|
251
|
+
host: CallbackBatchHost,
|
|
252
|
+
options: CallbackBatcherOptions = {},
|
|
253
|
+
): CallbackBatcher {
|
|
254
|
+
const state = globalState();
|
|
255
|
+
const key = host as object;
|
|
256
|
+
const existing = state.byHost.get(key);
|
|
257
|
+
if (existing) return existing;
|
|
258
|
+
const created = createCallbackBatcher(host, options);
|
|
259
|
+
state.byHost.set(key, created);
|
|
260
|
+
return created;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
264
|
+
globalState().byHost.get(host as object)?.cancel();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function globalState(): SharedCallbackBatcherState {
|
|
268
|
+
const root = globalThis as typeof globalThis & {
|
|
269
|
+
[GLOBAL_STATE_KEY]?: SharedCallbackBatcherState;
|
|
270
|
+
};
|
|
271
|
+
root[GLOBAL_STATE_KEY] ??= { byHost: new WeakMap<object, CallbackBatcher>() };
|
|
272
|
+
return root[GLOBAL_STATE_KEY];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">): string {
|
|
276
|
+
return `${event.source}\u0000${event.id}\u0000${event.status}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function eventDisposition(
|
|
280
|
+
event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
|
|
281
|
+
): { kind: "deliver" } | { kind: "delivered" } | { kind: "suppressed"; reason: string } {
|
|
282
|
+
try {
|
|
283
|
+
if (event.isDelivered?.()) return { kind: "delivered" };
|
|
284
|
+
} catch {
|
|
285
|
+
return { kind: "suppressed", reason: "durable delivery state could not be verified" };
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const reason = event.getSuppressionReason?.();
|
|
289
|
+
return reason ? { kind: "suppressed", reason } : { kind: "deliver" };
|
|
290
|
+
} catch {
|
|
291
|
+
return { kind: "suppressed", reason: "callback ownership could not be verified" };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function invokeDelivered(
|
|
296
|
+
event: Pick<CallbackBatchEvent, "onDelivered">,
|
|
297
|
+
at: number,
|
|
298
|
+
): void {
|
|
299
|
+
try { event.onDelivered?.(at); } catch { /* handoff already succeeded */ }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function invokeSuppressed(
|
|
303
|
+
event: Pick<CallbackBatchEvent, "onSuppressed">,
|
|
304
|
+
reason: string,
|
|
305
|
+
at: number,
|
|
306
|
+
): void {
|
|
307
|
+
try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function boundedField(value: unknown, maxChars: number): string {
|
|
311
|
+
const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
312
|
+
if (oneLine.length <= maxChars) return oneLine;
|
|
313
|
+
return `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
317
|
+
return (typeof value === "object" || typeof value === "function")
|
|
318
|
+
&& value !== null
|
|
319
|
+
&& typeof (value as PromiseLike<unknown>).then === "function";
|
|
320
|
+
}
|