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.
- package/node_modules/pi-better-background-tasks/README.md +1 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/goal-provider.ts +7 -1
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +15 -1
- package/node_modules/pi-better-background-tasks/src/registry.ts +9 -0
- package/node_modules/pi-better-background-tasks/src/runtime.ts +44 -17
- package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +320 -0
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +273 -59
- package/node_modules/pi-better-background-tasks/src/shared-render-scheduler.ts +47 -0
- package/node_modules/pi-better-background-tasks/src/shared-stall-detector.ts +69 -0
- package/node_modules/pi-better-background-tasks/src/stall.ts +42 -0
- package/node_modules/pi-better-background-tasks/src/tools.ts +3 -0
- package/node_modules/pi-better-background-tasks/src/types.ts +2 -0
- package/node_modules/pi-better-goal/README.md +3 -1
- package/node_modules/pi-better-goal/package.json +3 -1
- package/node_modules/pi-better-goal/src/continuation.ts +98 -0
- package/node_modules/pi-better-goal/src/goal-clock.ts +29 -6
- package/node_modules/pi-better-goal/src/goal-state.ts +66 -1
- package/node_modules/pi-better-goal/src/index.ts +163 -20
- package/node_modules/pi-better-goal/src/shared-render-scheduler.ts +47 -0
- package/node_modules/pi-better-goal/src/shared-stall-detector.ts +69 -0
- package/node_modules/pi-better-goal/src/stall.ts +34 -0
- package/node_modules/pi-better-subagents/completion.mjs +3 -6
- package/node_modules/pi-better-subagents/finalization.ts +16 -10
- package/node_modules/pi-better-subagents/health-observation.ts +32 -29
- package/node_modules/pi-better-subagents/index.ts +233 -42
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/parse.ts +116 -1
- package/node_modules/pi-better-subagents/registry.ts +12 -0
- package/node_modules/pi-better-subagents/shared-callback-batcher.ts +320 -0
- package/node_modules/pi-better-subagents/shared-navigator.ts +273 -59
- package/node_modules/pi-better-subagents/shared-render-scheduler.ts +47 -0
- package/node_modules/pi-better-subagents/shared-stall-detector.ts +69 -0
- package/node_modules/pi-better-subagents/widget.mjs +1 -1
- 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
|
|
|
@@ -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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
}
|