pi-better-harness 0.1.13 → 0.1.15

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 (35) hide show
  1. package/node_modules/pi-better-background-tasks/README.md +1 -0
  2. package/node_modules/pi-better-background-tasks/package.json +1 -1
  3. package/node_modules/pi-better-background-tasks/src/goal-provider.ts +7 -1
  4. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +15 -1
  5. package/node_modules/pi-better-background-tasks/src/registry.ts +9 -0
  6. package/node_modules/pi-better-background-tasks/src/runtime.ts +44 -17
  7. package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +320 -0
  8. package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +273 -59
  9. package/node_modules/pi-better-background-tasks/src/shared-render-scheduler.ts +47 -0
  10. package/node_modules/pi-better-background-tasks/src/shared-stall-detector.ts +69 -0
  11. package/node_modules/pi-better-background-tasks/src/stall.ts +42 -0
  12. package/node_modules/pi-better-background-tasks/src/tools.ts +3 -0
  13. package/node_modules/pi-better-background-tasks/src/types.ts +2 -0
  14. package/node_modules/pi-better-goal/README.md +3 -1
  15. package/node_modules/pi-better-goal/package.json +3 -1
  16. package/node_modules/pi-better-goal/src/continuation.ts +98 -0
  17. package/node_modules/pi-better-goal/src/goal-clock.ts +29 -6
  18. package/node_modules/pi-better-goal/src/goal-state.ts +66 -1
  19. package/node_modules/pi-better-goal/src/index.ts +163 -20
  20. package/node_modules/pi-better-goal/src/shared-render-scheduler.ts +47 -0
  21. package/node_modules/pi-better-goal/src/shared-stall-detector.ts +69 -0
  22. package/node_modules/pi-better-goal/src/stall.ts +34 -0
  23. package/node_modules/pi-better-subagents/completion.mjs +3 -6
  24. package/node_modules/pi-better-subagents/finalization.ts +16 -10
  25. package/node_modules/pi-better-subagents/health-observation.ts +32 -29
  26. package/node_modules/pi-better-subagents/index.ts +233 -42
  27. package/node_modules/pi-better-subagents/package.json +1 -1
  28. package/node_modules/pi-better-subagents/parse.ts +116 -1
  29. package/node_modules/pi-better-subagents/registry.ts +12 -0
  30. package/node_modules/pi-better-subagents/shared-callback-batcher.ts +320 -0
  31. package/node_modules/pi-better-subagents/shared-navigator.ts +273 -59
  32. package/node_modules/pi-better-subagents/shared-render-scheduler.ts +47 -0
  33. package/node_modules/pi-better-subagents/shared-stall-detector.ts +69 -0
  34. package/node_modules/pi-better-subagents/widget.mjs +1 -1
  35. package/package.json +4 -4
@@ -16,6 +16,7 @@ Use `pi-better-background-tasks` when a command should keep running while the fo
16
16
  - Watch commands until success, failure, or timeout.
17
17
  - Keep task metadata and logs available across reloads.
18
18
  - Show active work in Pi's background-work navigator.
19
+ - Flag running tasks with no observable output or completed poll as stalled.
19
20
 
20
21
  ## Install
21
22
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.1.13",
3
+ "version": "0.1.17",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { listMetas } from "./registry.js";
3
3
  import type { BackgroundTaskMeta } from "./types.js";
4
4
  import { isTerminalStatus } from "./types.js";
5
+ import { backgroundTaskProgressAt, observeBackgroundTaskStall } from "./stall.js";
5
6
 
6
7
  const GOAL_READY_EVENT = "pi-better-goal:ready";
7
8
  const GOAL_REGISTER_PROVIDER_EVENT = "pi-better-goal:register-provider";
@@ -92,11 +93,14 @@ function getGoalActivityOrigin(ctx: ExtensionContext) {
92
93
  function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkItem {
93
94
  const active = meta.status === "running";
94
95
  const terminal = isTerminalStatus(meta.status);
95
- const attention = terminal && meta.status !== "succeeded";
96
+ const stall = observeBackgroundTaskStall(meta);
97
+ const unhealthy = active && stall.state === "stalled";
98
+ const attention = (terminal && meta.status !== "succeeded") || unhealthy;
96
99
  const item: GoalBackgroundWorkItem = {
97
100
  id: meta.id,
98
101
  status: meta.status,
99
102
  active,
103
+ unhealthy,
100
104
  terminal,
101
105
  attention,
102
106
  startedAt: meta.startedAt,
@@ -108,6 +112,8 @@ function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkI
108
112
  argv: meta.argv,
109
113
  logPath: meta.logPath,
110
114
  spawnPid: meta.spawnPid,
115
+ lastProgressAt: backgroundTaskProgressAt(meta),
116
+ stall: stall.state,
111
117
  },
112
118
  };
113
119
  if (meta.name !== undefined) item.label = meta.name;
@@ -10,8 +10,9 @@ import {
10
10
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
12
  import { readLog } from "./logs.js";
13
- import { listMetas, readMeta, writeMeta } from "./registry.js";
13
+ import { listMetas, onMetaChanged, readMeta, writeMeta } from "./registry.js";
14
14
  import { stopTask } from "./runtime.js";
15
+ import { observeBackgroundTaskStall } from "./stall.js";
15
16
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
16
17
 
17
18
  let unregister: (() => void) | undefined;
@@ -62,6 +63,7 @@ const provider: BackgroundWorkProvider = {
62
63
  writeMeta(meta);
63
64
  return { action: "dismissed", providerId: "background-tasks", id, status: meta.status };
64
65
  },
66
+ onVisibleChanged: onMetaChanged,
65
67
  };
66
68
 
67
69
  function visibleMetas(now = Date.now()): BackgroundTaskMeta[] {
@@ -113,6 +115,9 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
113
115
  secondary: secondaryLabel(meta),
114
116
  facts: factsForMeta(meta, now),
115
117
  sortStartedAt: meta.startedAt,
118
+ expiresAt: meta.status === "running" || meta.endedAt === undefined
119
+ ? undefined
120
+ : meta.endedAt + TERMINAL_NAVIGATOR_RETENTION_MS,
116
121
  };
117
122
  }
118
123
 
@@ -131,6 +136,10 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
131
136
  ];
132
137
  if (meta.deadlineAt) metadata.push({ label: "deadline", value: formatDuration(meta.deadlineAt - now) });
133
138
  if (meta.lastCheckedAt) metadata.push({ label: "checked", value: `${formatDuration(now - meta.lastCheckedAt)} ago` });
139
+ if (meta.status === "running") {
140
+ const stall = observeBackgroundTaskStall(meta, now);
141
+ if (stall.state !== "healthy") metadata.push({ label: "activity", value: stall.state });
142
+ }
134
143
  if (meta.lastExitCode !== undefined) metadata.push({ label: "exit", value: String(meta.lastExitCode) });
135
144
  if (meta.error) metadata.push({ label: "error", value: meta.error });
136
145
  if (meta.logDiscardedBytes) {
@@ -194,6 +203,11 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
194
203
 
195
204
  function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
196
205
  const facts: string[] = [];
206
+ if (meta.status === "running") {
207
+ const stall = observeBackgroundTaskStall(meta, now);
208
+ if (stall.state === "stalled") facts.push("stalled");
209
+ else if (stall.state === "quiet") facts.push("quiet");
210
+ }
197
211
  if (meta.kind === "command_watch" && meta.intervalMs) facts.push(`every ${formatDuration(meta.intervalMs)}`);
198
212
  if (meta.deadlineAt && meta.status === "running") facts.push(`${formatDuration(meta.deadlineAt - now)} left`);
199
213
  if (meta.result && meta.status !== "running") {
@@ -6,6 +6,7 @@ import { isTerminalStatus } from "./types.js";
6
6
 
7
7
  let seq = 0;
8
8
  const metaCache = new Map<string, BackgroundTaskMeta>();
9
+ const metaChangedListeners = new Set<() => void>();
9
10
 
10
11
  export function baseDir(): string {
11
12
  return join(tmpdir(), "pi-better-background-tasks");
@@ -40,6 +41,14 @@ export function writeMeta(meta: BackgroundTaskMeta): void {
40
41
  ensureTaskDir(meta.id);
41
42
  writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
42
43
  metaCache.set(meta.id, meta);
44
+ for (const listener of metaChangedListeners) {
45
+ try { listener(); } catch { /* best effort */ }
46
+ }
47
+ }
48
+
49
+ export function onMetaChanged(listener: () => void): () => void {
50
+ metaChangedListeners.add(listener);
51
+ return () => metaChangedListeners.delete(listener);
43
52
  }
44
53
 
45
54
  export function readMeta(id: string): BackgroundTaskMeta | undefined {
@@ -1,8 +1,10 @@
1
+ import { statSync } from "node:fs";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import { appendLine, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
3
4
  import { evaluateCondition } from "./conditions.js";
4
5
  import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
5
6
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
7
+ import { getCallbackBatcher } from "./shared-callback-batcher.js";
6
8
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, CommandSpec, Condition, TerminalResult } from "./types.js";
7
9
  import { isTerminalStatus } from "./types.js";
8
10
 
@@ -51,6 +53,7 @@ export function spawnTask(
51
53
  kind: "process",
52
54
  status: "running",
53
55
  startedAt: now,
56
+ lastProgressAt: now,
54
57
  deadlineAt: params.timeout_seconds ? now + params.timeout_seconds * 1000 : undefined,
55
58
  logPath,
56
59
  callback: params.callback,
@@ -103,6 +106,7 @@ export function startWatchTask(
103
106
  kind: "command_watch",
104
107
  status: "running",
105
108
  startedAt: now,
109
+ lastProgressAt: now,
106
110
  deadlineAt: timeoutSeconds ? now + timeoutSeconds * 1000 : undefined,
107
111
  intervalMs: Math.max(1, params.interval_seconds ?? 30) * 1000,
108
112
  logPath: logPathFor(id),
@@ -212,6 +216,7 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
212
216
  if (!latest || latest.status !== "running") return;
213
217
  enforceLogRetention(latest);
214
218
  latest.lastCheckedAt = Date.now();
219
+ latest.lastProgressAt = latest.lastCheckedAt;
215
220
  latest.lastExitCode = result.exitCode;
216
221
  latest.lastSignal = result.signal;
217
222
  latest.lastState = extractLastState(result);
@@ -300,24 +305,37 @@ async function notifyTerminal(
300
305
  if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
301
306
  const latest = readMeta(meta.id) ?? meta;
302
307
  if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
303
- const suppressionReason = getCallbackSuppressionReason(latest, getActiveSession?.());
304
- if (suppressionReason) {
305
- latest.callbackSuppressedAt = Date.now();
306
- latest.callbackSuppressedReason = suppressionReason;
307
- writeMeta(latest);
308
- return;
309
- }
310
308
  const label = latest.name ? `${latest.name} (${latest.id})` : latest.id;
311
- try {
312
- await pi.sendUserMessage(
313
- `Background task ${label} reached terminal status ${latest.status}. Inspect the compact result with bg_task_status id=${latest.id}; call bg_task_log only if the status summary is insufficient.`,
314
- { deliverAs: "followUp" },
315
- );
316
- latest.callbackSentAt = Date.now();
317
- writeMeta(latest);
318
- } catch {
319
- // Leave callbackSentAt unset so the originating session can attempt delivery once.
320
- }
309
+ getCallbackBatcher(pi).enqueue({
310
+ source: "background-task",
311
+ id: latest.id,
312
+ label,
313
+ status: latest.status,
314
+ detailTool: "bg_task_status",
315
+ callback: true,
316
+ isDelivered: () => {
317
+ const current = readMeta(latest.id);
318
+ return current?.callbackSentAt !== undefined || current?.callbackSuppressedAt !== undefined;
319
+ },
320
+ getSuppressionReason: () => {
321
+ const current = readMeta(latest.id);
322
+ if (!current) return "background task metadata is unavailable";
323
+ return getCallbackSuppressionReason(current, getActiveSession?.());
324
+ },
325
+ onDelivered: (at) => {
326
+ const current = readMeta(latest.id);
327
+ if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
328
+ current.callbackSentAt = at;
329
+ writeMeta(current);
330
+ },
331
+ onSuppressed: (reason, at) => {
332
+ const current = readMeta(latest.id);
333
+ if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
334
+ current.callbackSuppressedAt = at;
335
+ current.callbackSuppressedReason = reason;
336
+ writeMeta(current);
337
+ },
338
+ });
321
339
  }
322
340
 
323
341
  function getCallbackSuppressionReason(
@@ -371,6 +389,15 @@ function stopLogRetention(id: string): void {
371
389
  }
372
390
 
373
391
  function enforceLogRetention(meta: BackgroundTaskMeta): void {
392
+ try {
393
+ const mtimeMs = Math.trunc(statSync(meta.logPath).mtimeMs);
394
+ if (mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
395
+ meta.lastProgressAt = mtimeMs;
396
+ writeMeta(meta);
397
+ }
398
+ } catch {
399
+ // Logs are optional progress evidence; retention still proceeds if absent.
400
+ }
374
401
  const compacted = retainLogTail(meta.logPath, resolveMaxLogBytes(meta.maxLogBytes));
375
402
  if (!compacted) return;
376
403
  meta.logDiscardedBytes = (meta.logDiscardedBytes ?? 0) + compacted.discardedBytes;
@@ -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
+ }