@haywoodmarx/agent-pager 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/src/broker/address.d.ts +49 -0
- package/dist/src/broker/address.js +110 -0
- package/dist/src/broker/broker.d.ts +665 -0
- package/dist/src/broker/broker.js +2270 -0
- package/dist/src/broker/database.d.ts +81 -0
- package/dist/src/broker/database.js +69 -0
- package/dist/src/broker/operator-projection.d.ts +291 -0
- package/dist/src/broker/operator-projection.js +722 -0
- package/dist/src/broker/protocol.d.ts +237 -0
- package/dist/src/broker/protocol.js +311 -0
- package/dist/src/broker/schema.d.ts +94 -0
- package/dist/src/broker/schema.js +454 -0
- package/dist/src/broker/snapshot.d.ts +171 -0
- package/dist/src/broker/snapshot.js +78 -0
- package/dist/src/configuration-defaults.d.ts +64 -0
- package/dist/src/configuration-defaults.js +48 -0
- package/dist/src/configuration.d.ts +76 -0
- package/dist/src/configuration.js +156 -0
- package/dist/src/implementations/claude/environment.d.ts +8 -0
- package/dist/src/implementations/claude/environment.js +9 -0
- package/dist/src/implementations/claude/gateway-cli.d.ts +2 -0
- package/dist/src/implementations/claude/gateway-cli.js +136 -0
- package/dist/src/implementations/claude/gateway-identity.d.ts +134 -0
- package/dist/src/implementations/claude/gateway-identity.js +103 -0
- package/dist/src/implementations/claude/gateway.d.ts +148 -0
- package/dist/src/implementations/claude/gateway.js +236 -0
- package/dist/src/implementations/claude/instructions.d.ts +33 -0
- package/dist/src/implementations/claude/instructions.js +64 -0
- package/dist/src/implementations/claude/manifest.d.ts +33 -0
- package/dist/src/implementations/claude/manifest.js +108 -0
- package/dist/src/implementations/claude/plugin-directory.d.ts +80 -0
- package/dist/src/implementations/claude/plugin-directory.js +137 -0
- package/dist/src/implementations/claude/session-continuation.d.ts +25 -0
- package/dist/src/implementations/claude/session-continuation.js +125 -0
- package/dist/src/implementations/claude/session-identity.d.ts +25 -0
- package/dist/src/implementations/claude/session-identity.js +31 -0
- package/dist/src/implementations/claude/session-start-cli.d.ts +2 -0
- package/dist/src/implementations/claude/session-start-cli.js +16 -0
- package/dist/src/implementations/claude/socket-delivery.d.ts +52 -0
- package/dist/src/implementations/claude/socket-delivery.js +159 -0
- package/dist/src/implementations/model.d.ts +162 -0
- package/dist/src/implementations/model.js +1 -0
- package/dist/src/implementations/opencode/adapter.d.ts +71 -0
- package/dist/src/implementations/opencode/adapter.js +94 -0
- package/dist/src/implementations/opencode/environment.d.ts +8 -0
- package/dist/src/implementations/opencode/environment.js +9 -0
- package/dist/src/implementations/opencode/manifest.d.ts +30 -0
- package/dist/src/implementations/opencode/manifest.js +93 -0
- package/dist/src/implementations/opencode/plugin-api.d.ts +120 -0
- package/dist/src/implementations/opencode/plugin-api.js +1 -0
- package/dist/src/implementations/opencode/plugin.d.ts +38 -0
- package/dist/src/implementations/opencode/plugin.js +303 -0
- package/dist/src/implementations/opencode/self-close-tool.d.ts +9 -0
- package/dist/src/implementations/opencode/self-close-tool.js +31 -0
- package/dist/src/implementations/orca/terminal.d.ts +48 -0
- package/dist/src/implementations/orca/terminal.js +154 -0
- package/dist/src/implementations/pi/broker-client.d.ts +56 -0
- package/dist/src/implementations/pi/broker-client.js +156 -0
- package/dist/src/implementations/pi/environment.d.ts +8 -0
- package/dist/src/implementations/pi/environment.js +9 -0
- package/dist/src/implementations/pi/extension.d.ts +57 -0
- package/dist/src/implementations/pi/extension.js +253 -0
- package/dist/src/implementations/pi/host-slots.d.ts +18 -0
- package/dist/src/implementations/pi/host-slots.js +27 -0
- package/dist/src/implementations/pi/manifest.d.ts +11 -0
- package/dist/src/implementations/pi/manifest.js +61 -0
- package/dist/src/implementations/pi/self-close-tool.d.ts +7 -0
- package/dist/src/implementations/pi/self-close-tool.js +27 -0
- package/dist/src/implementations/registry.d.ts +47 -0
- package/dist/src/implementations/registry.js +157 -0
- package/dist/src/implementations/session-key.d.ts +13 -0
- package/dist/src/implementations/session-key.js +19 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.js +26 -0
- package/dist/src/launch/broker-lock.d.ts +257 -0
- package/dist/src/launch/broker-lock.js +644 -0
- package/dist/src/launch/cli.d.ts +37 -0
- package/dist/src/launch/cli.js +317 -0
- package/dist/src/launch/environment.d.ts +45 -0
- package/dist/src/launch/environment.js +48 -0
- package/dist/src/launch/launch.d.ts +235 -0
- package/dist/src/launch/launch.js +223 -0
- package/dist/src/launch/on-demand.d.ts +202 -0
- package/dist/src/launch/on-demand.js +435 -0
- package/dist/src/launch/runtime-composition.d.ts +88 -0
- package/dist/src/launch/runtime-composition.js +253 -0
- package/dist/src/launch/runtime.d.ts +79 -0
- package/dist/src/launch/runtime.js +196 -0
- package/dist/src/launch/service-system.d.ts +11 -0
- package/dist/src/launch/service-system.js +39 -0
- package/dist/src/launch/service.d.ts +173 -0
- package/dist/src/launch/service.js +492 -0
- package/dist/src/launch/skill.d.ts +57 -0
- package/dist/src/launch/skill.js +81 -0
- package/dist/src/launch/terminal.d.ts +26 -0
- package/dist/src/launch/terminal.js +43 -0
- package/dist/src/launch/workspace-branch.d.ts +10 -0
- package/dist/src/launch/workspace-branch.js +29 -0
- package/dist/src/messaging/delivery-client.d.ts +144 -0
- package/dist/src/messaging/delivery-client.js +261 -0
- package/dist/src/messaging/delivery.d.ts +75 -0
- package/dist/src/messaging/delivery.js +22 -0
- package/dist/src/messaging/inbox-socket.d.ts +98 -0
- package/dist/src/messaging/inbox-socket.js +278 -0
- package/dist/src/messaging/mcp.d.ts +53 -0
- package/dist/src/messaging/mcp.js +501 -0
- package/dist/src/messaging/notifications.d.ts +231 -0
- package/dist/src/messaging/notifications.js +489 -0
- package/dist/src/messaging/push-delivery.d.ts +77 -0
- package/dist/src/messaging/push-delivery.js +187 -0
- package/dist/src/messaging/tools.d.ts +11 -0
- package/dist/src/messaging/tools.js +102 -0
- package/dist/src/messaging/wake.d.ts +23 -0
- package/dist/src/messaging/wake.js +39 -0
- package/dist/src/operator/agent-projection.d.ts +102 -0
- package/dist/src/operator/agent-projection.js +249 -0
- package/dist/src/operator/commands.d.ts +50 -0
- package/dist/src/operator/commands.js +199 -0
- package/dist/src/operator/diagnostics.d.ts +21 -0
- package/dist/src/operator/diagnostics.js +22 -0
- package/dist/src/operator/protocol.d.ts +511 -0
- package/dist/src/operator/protocol.js +49 -0
- package/dist/src/operator/repository-system.d.ts +14 -0
- package/dist/src/operator/repository-system.js +150 -0
- package/dist/src/operator/repository.d.ts +79 -0
- package/dist/src/operator/repository.js +72 -0
- package/dist/src/operator/traffic-projection.d.ts +19 -0
- package/dist/src/operator/traffic-projection.js +229 -0
- package/dist/src/operator/transport.d.ts +87 -0
- package/dist/src/operator/transport.js +549 -0
- package/dist/src/operator/update-store.d.ts +53 -0
- package/dist/src/operator/update-store.js +162 -0
- package/dist/src/operator/updates.d.ts +94 -0
- package/dist/src/operator/updates.js +280 -0
- package/dist/src/operator/validation.d.ts +114 -0
- package/dist/src/operator/validation.js +916 -0
- package/dist/src/shared/guards.d.ts +59 -0
- package/dist/src/shared/guards.js +119 -0
- package/dist/src/shared/http.d.ts +30 -0
- package/dist/src/shared/http.js +50 -0
- package/dist/src/shared/row.d.ts +2 -0
- package/dist/src/shared/row.js +1 -0
- package/dist/src/shared/shell.d.ts +6 -0
- package/dist/src/shared/shell.js +8 -0
- package/dist/src/tui/actions.d.ts +166 -0
- package/dist/src/tui/actions.js +253 -0
- package/dist/src/tui/agent-tree.d.ts +125 -0
- package/dist/src/tui/agent-tree.js +663 -0
- package/dist/src/tui/app.d.ts +38 -0
- package/dist/src/tui/app.js +378 -0
- package/dist/src/tui/chrome.d.ts +86 -0
- package/dist/src/tui/chrome.js +85 -0
- package/dist/src/tui/client.d.ts +41 -0
- package/dist/src/tui/client.js +279 -0
- package/dist/src/tui/composition.d.ts +41 -0
- package/dist/src/tui/composition.js +146 -0
- package/dist/src/tui/message-views.d.ts +94 -0
- package/dist/src/tui/message-views.js +515 -0
- package/dist/src/tui/model.d.ts +414 -0
- package/dist/src/tui/model.js +758 -0
- package/dist/src/tui/presentation.d.ts +350 -0
- package/dist/src/tui/presentation.js +541 -0
- package/dist/src/tui/production.d.ts +79 -0
- package/dist/src/tui/production.js +253 -0
- package/dist/src/tui/run-terminal.d.ts +32 -0
- package/dist/src/tui/run-terminal.js +54 -0
- package/dist/src/tui/runtime.d.ts +113 -0
- package/dist/src/tui/runtime.js +189 -0
- package/dist/src/tui/theme.d.ts +49 -0
- package/dist/src/tui/theme.js +55 -0
- package/dist/src/tui/transport.d.ts +31 -0
- package/dist/src/tui/transport.js +255 -0
- package/dist/src/version.d.ts +11 -0
- package/dist/src/version.js +52 -0
- package/package.json +142 -0
- package/skills/pager/SKILL.md +112 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { isOpaqueId } from "../broker/protocol.js";
|
|
3
|
+
import { PAGER_DEFAULTS } from "../configuration-defaults.js";
|
|
4
|
+
/** Limits the number of accepted operator correlations represented by one body-free wake. */
|
|
5
|
+
export const OPERATOR_CONTROL_CORRELATION_LIMIT = 64;
|
|
6
|
+
/** Limits the aggregate ASCII bytes retained for one body-free operator wake. */
|
|
7
|
+
const OPERATOR_CONTROL_CORRELATION_BYTES_LIMIT = 8 * 1024;
|
|
8
|
+
/** Bounds retained scheduler diagnostics independently from durable state. */
|
|
9
|
+
export const NOTIFICATION_OPERATIONAL_EVENT_LIMIT = 4_096;
|
|
10
|
+
/** Reports a controlled rejection at the body-free notification boundary. */
|
|
11
|
+
export class NotificationValidationError extends Error {
|
|
12
|
+
}
|
|
13
|
+
/** Body-free adapter notification fan-out. */
|
|
14
|
+
export class NotificationHub {
|
|
15
|
+
#events = new EventEmitter();
|
|
16
|
+
#epoch;
|
|
17
|
+
#sequences = new Map();
|
|
18
|
+
#fanOutQueues = new Map();
|
|
19
|
+
#subscriberFailureFrames = [];
|
|
20
|
+
#onSubscriberError;
|
|
21
|
+
/**
|
|
22
|
+
* Constructs a notification hub for one broker epoch.
|
|
23
|
+
* @param epoch - Broker generation used to fence stale sessions.
|
|
24
|
+
* @param options - Optional non-retaining subscriber-failure observer.
|
|
25
|
+
*/
|
|
26
|
+
constructor(epoch, options = {}) {
|
|
27
|
+
this.#epoch = epoch;
|
|
28
|
+
this.#onSubscriberError = options.onSubscriberError ?? (() => { });
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Publish an inbox change without storing message bodies or creating queue state.
|
|
32
|
+
* @param agentId - Recipient the inbox change is published for.
|
|
33
|
+
* @param reason - Lifecycle reason attached to the inbox notification.
|
|
34
|
+
* @param messageId - Message the change names, when it names one.
|
|
35
|
+
* @returns - Copy of the published event; changing it does not alter what subscribers received.
|
|
36
|
+
*/
|
|
37
|
+
publish(agentId, reason, messageId) {
|
|
38
|
+
const sequence = (this.#sequences.get(agentId) ?? 0) + 1;
|
|
39
|
+
this.#sequences.set(agentId, sequence);
|
|
40
|
+
const event = {
|
|
41
|
+
type: "inbox_changed",
|
|
42
|
+
agentId,
|
|
43
|
+
sequence,
|
|
44
|
+
epoch: this.#epoch,
|
|
45
|
+
reason,
|
|
46
|
+
...(messageId ? { messageId } : {}),
|
|
47
|
+
};
|
|
48
|
+
this.#fanOut(agentId, event);
|
|
49
|
+
return cloneInboxChanged(event);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Publish a body-free operator control wake without creating peer delivery state.
|
|
53
|
+
* @param agentId - Stable identity of the requested target.
|
|
54
|
+
* @param controlId - Opaque correlation of the primary accepted command.
|
|
55
|
+
* @param coalescedControlIds - Additional accepted command correlations represented by this wake.
|
|
56
|
+
* @returns - Copy of the published event; changing it does not alter what subscribers received.
|
|
57
|
+
*/
|
|
58
|
+
publishOperatorPing(agentId, controlId, coalescedControlIds = []) {
|
|
59
|
+
const controlIds = validateControlIds([controlId, ...coalescedControlIds]);
|
|
60
|
+
const sequence = (this.#sequences.get(agentId) ?? 0) + 1;
|
|
61
|
+
this.#sequences.set(agentId, sequence);
|
|
62
|
+
const event = {
|
|
63
|
+
type: "inbox_changed",
|
|
64
|
+
agentId,
|
|
65
|
+
sequence,
|
|
66
|
+
epoch: this.#epoch,
|
|
67
|
+
reason: "operator_ping",
|
|
68
|
+
controlId: controlIds[0] ?? controlId,
|
|
69
|
+
coalescedControlIds: controlIds.slice(1),
|
|
70
|
+
};
|
|
71
|
+
this.#fanOut(agentId, event);
|
|
72
|
+
return cloneInboxChanged(event);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Subscribe an adapter to body-free notification events.
|
|
76
|
+
* @param agentId - Agent whose operational events the listener receives.
|
|
77
|
+
* @param listener - Callback invoked for each matching operational event.
|
|
78
|
+
* @returns - Unsubscribe callback that detaches the registered listener.
|
|
79
|
+
*/
|
|
80
|
+
subscribe(agentId, listener) {
|
|
81
|
+
const isolatedListener = (event) => {
|
|
82
|
+
try {
|
|
83
|
+
listener(cloneInboxChanged(event));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
const failure = { agentId, sequence: event.sequence };
|
|
87
|
+
const currentFrame = this.#subscriberFailureFrames.at(-1);
|
|
88
|
+
if (currentFrame)
|
|
89
|
+
currentFrame.push(failure);
|
|
90
|
+
else
|
|
91
|
+
this.#reportSubscriberFailure(failure);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
this.#events.on(agentId, isolatedListener);
|
|
95
|
+
return () => this.#events.off(agentId, isolatedListener);
|
|
96
|
+
}
|
|
97
|
+
#fanOut(agentId, event) {
|
|
98
|
+
const pending = this.#fanOutQueues.get(agentId);
|
|
99
|
+
if (pending) {
|
|
100
|
+
pending.push(event);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const queue = [event];
|
|
104
|
+
this.#fanOutQueues.set(agentId, queue);
|
|
105
|
+
try {
|
|
106
|
+
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
107
|
+
const failures = [];
|
|
108
|
+
this.#subscriberFailureFrames.push(failures);
|
|
109
|
+
try {
|
|
110
|
+
this.#events.emit(agentId, next);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
this.#subscriberFailureFrames.pop();
|
|
114
|
+
}
|
|
115
|
+
for (const failure of failures)
|
|
116
|
+
this.#reportSubscriberFailure(failure);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
this.#fanOutQueues.delete(agentId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
#reportSubscriberFailure(failure) {
|
|
124
|
+
try {
|
|
125
|
+
this.#onSubscriberError(failure);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Subscriber failure reporting is isolated from every accepted wake as well.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Broker notification scheduler for retry, coalescing, busy deferral, wake and correlation shedding, and operational events. */
|
|
133
|
+
export class NotificationScheduler {
|
|
134
|
+
#hub;
|
|
135
|
+
#broker;
|
|
136
|
+
#wakeBudget;
|
|
137
|
+
#wakeBudgetWindowMs;
|
|
138
|
+
#now;
|
|
139
|
+
#events = [];
|
|
140
|
+
#busyAgents = new Set();
|
|
141
|
+
#releasingAgents = new Set();
|
|
142
|
+
#coalesced = new Map();
|
|
143
|
+
#wakeHistory = new Map();
|
|
144
|
+
#liveReservations = new Map();
|
|
145
|
+
#capabilities = new Map();
|
|
146
|
+
#operatorAdmissions = new Set();
|
|
147
|
+
/**
|
|
148
|
+
* Constructs a scheduler over the notification broker and event hub.
|
|
149
|
+
* @param broker - Durable broker instance that owns message state.
|
|
150
|
+
* @param hub - Notification stream used to publish inbox changes.
|
|
151
|
+
* @param options - Clock, required so this scheduler shares the process's, plus optional policy.
|
|
152
|
+
*/
|
|
153
|
+
constructor(broker, hub, options) {
|
|
154
|
+
this.#broker = broker;
|
|
155
|
+
this.#hub = hub;
|
|
156
|
+
this.#wakeBudget = options.wakeBudget ?? PAGER_DEFAULTS.wakeBudget;
|
|
157
|
+
this.#wakeBudgetWindowMs = options.wakeBudgetWindowMs ?? PAGER_DEFAULTS.wakeBudgetWindowMs;
|
|
158
|
+
this.#now = options.now;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Record adapter-declared capabilities used by the notification scheduler.
|
|
162
|
+
* @param agentId - Agent whose adapter capabilities are recorded.
|
|
163
|
+
* @param capabilities - Delivery capabilities reported by the adapter.
|
|
164
|
+
*/
|
|
165
|
+
declareAdapter(agentId, capabilities) {
|
|
166
|
+
this.#capabilities.set(agentId, capabilities);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Notify an agent of durable inbox state without creating a second message queue.
|
|
170
|
+
* @param agentId - Agent notified of the inbox change.
|
|
171
|
+
* @param reason - Lifecycle reason attached to the inbox notification.
|
|
172
|
+
* @returns - Wake event when budget and capability policy allow delivery, otherwise null.
|
|
173
|
+
*/
|
|
174
|
+
notify(agentId, reason = "queued") {
|
|
175
|
+
return this.notifyMessage(undefined, agentId, reason);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Notify one message recipient and append adapter lifecycle outcomes for its receipt.
|
|
179
|
+
* @param messageId - Message the notification concerns.
|
|
180
|
+
* @param agentId - Recipient notified of the message.
|
|
181
|
+
* @param reason - Lifecycle reason attached to the inbox notification.
|
|
182
|
+
* @returns - Wake event tied to the durable message, or null when policy suppresses it.
|
|
183
|
+
*/
|
|
184
|
+
notifyMessage(messageId, agentId, reason = "queued") {
|
|
185
|
+
if (this.#busyAgents.has(agentId) && this.#capabilities.get(agentId)?.coalescing === true) {
|
|
186
|
+
const existing = this.#coalesced.get(agentId);
|
|
187
|
+
if (!existing?.message && !this.#reserveWakeBudget(agentId)) {
|
|
188
|
+
this.#recordEvent({ type: "wake_shed", agentId, reason: "wake_budget", ...(messageId ? { messageId } : {}) });
|
|
189
|
+
this.#hub.publish(agentId, "shed", messageId);
|
|
190
|
+
if (messageId)
|
|
191
|
+
this.#broker.recordNotification(messageId, "shed");
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const pending = existing ?? this.#pendingWake(agentId);
|
|
195
|
+
pending.message = coalescePendingMessage(pending.message, messageId, reason);
|
|
196
|
+
this.#recordEvent({ type: "wake_deferred", agentId });
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (!this.#consumeWakeBudget(agentId)) {
|
|
200
|
+
this.#recordEvent({ type: "wake_shed", agentId, reason: "wake_budget", ...(messageId ? { messageId } : {}) });
|
|
201
|
+
this.#hub.publish(agentId, "shed", messageId);
|
|
202
|
+
if (messageId)
|
|
203
|
+
this.#broker.recordNotification(messageId, "shed");
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const event = this.#hub.publish(agentId, reason, messageId);
|
|
207
|
+
this.#recordEvent({ type: "wake_sent", agentId, sequence: event.sequence });
|
|
208
|
+
return event;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Admit a body-free operator ping without creating message delivery state.
|
|
212
|
+
* @param agentId - Stable identity of the requested target.
|
|
213
|
+
* @param controlId - Durable opaque correlation created with command acceptance.
|
|
214
|
+
* @param commitAcceptance - Synchronous durable acceptance performed after preflight and before scheduler mutation.
|
|
215
|
+
* @param resolveExisting - Read-only durable retry resolution performed before a rejected preflight becomes observable.
|
|
216
|
+
* @returns The admission outcome; `accepted` carries the published wake.
|
|
217
|
+
*/
|
|
218
|
+
notifyOperatorPing(agentId, controlId, commitAcceptance, resolveExisting) {
|
|
219
|
+
const [validatedControlId] = validateControlIds([controlId]);
|
|
220
|
+
if (!validatedControlId)
|
|
221
|
+
throw new Error("Validated operator control id is unavailable");
|
|
222
|
+
if (this.#operatorAdmissions.has(agentId)) {
|
|
223
|
+
if (resolveExistingOperatorCommand(resolveExisting)) {
|
|
224
|
+
return { status: "already_accepted", controlId: validatedControlId };
|
|
225
|
+
}
|
|
226
|
+
return { status: "correlation_rejected", controlId: validatedControlId, reason: "admission_in_progress" };
|
|
227
|
+
}
|
|
228
|
+
this.#operatorAdmissions.add(agentId);
|
|
229
|
+
try {
|
|
230
|
+
if (this.#busyAgents.has(agentId) && this.#capabilities.get(agentId)?.coalescing === true) {
|
|
231
|
+
return this.#admitCoalescedOperatorPing(agentId, validatedControlId, commitAcceptance, resolveExisting);
|
|
232
|
+
}
|
|
233
|
+
if (!this.#hasWakeBudget(agentId)) {
|
|
234
|
+
return this.#rejectOperatorPing(agentId, validatedControlId, resolveExisting, "wake_budget");
|
|
235
|
+
}
|
|
236
|
+
if (commitAcceptance() === "existing") {
|
|
237
|
+
return { status: "already_accepted", controlId: validatedControlId };
|
|
238
|
+
}
|
|
239
|
+
return this.#publishOperatorPings(agentId, [validatedControlId]);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
this.#operatorAdmissions.delete(agentId);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Admit or reject one operator ping against a busy, coalescing-capable agent, appending its
|
|
247
|
+
* control id to the pending wake rather than publishing immediately.
|
|
248
|
+
* @param agentId - Busy agent the ping targets.
|
|
249
|
+
* @param controlId - Validated control id correlating the request.
|
|
250
|
+
* @param commitAcceptance - Synchronous durable acceptance performed after preflight.
|
|
251
|
+
* @param resolveExisting - Read-only durable retry resolution performed before a rejected
|
|
252
|
+
* preflight becomes observable.
|
|
253
|
+
* @returns - `coalesced` when the id joined the pending wake, `already_accepted` on a durable retry, otherwise a rejection.
|
|
254
|
+
*/
|
|
255
|
+
#admitCoalescedOperatorPing(agentId, controlId, commitAcceptance, resolveExisting) {
|
|
256
|
+
const existing = this.#coalesced.get(agentId);
|
|
257
|
+
if (existing?.controlIdSet.has(controlId) === true) {
|
|
258
|
+
return this.#rejectOperatorPing(agentId, controlId, resolveExisting, "duplicate_control_id");
|
|
259
|
+
}
|
|
260
|
+
if (existing && !canAppendControlId(existing, controlId)) {
|
|
261
|
+
return this.#rejectOperatorPing(agentId, controlId, resolveExisting, "pending_control_limit");
|
|
262
|
+
}
|
|
263
|
+
const needsReservation = !existing || existing.controlIds.length === 0;
|
|
264
|
+
if (needsReservation && !this.#hasWakeBudget(agentId)) {
|
|
265
|
+
return this.#rejectOperatorPing(agentId, controlId, resolveExisting, "wake_budget");
|
|
266
|
+
}
|
|
267
|
+
if (commitAcceptance() === "existing") {
|
|
268
|
+
return { status: "already_accepted", controlId };
|
|
269
|
+
}
|
|
270
|
+
if (needsReservation && !this.#reserveWakeBudget(agentId)) {
|
|
271
|
+
throw new Error("Preflighted operator wake budget became unavailable");
|
|
272
|
+
}
|
|
273
|
+
const pending = existing ?? this.#pendingWake(agentId);
|
|
274
|
+
pending.controlIds.push(controlId);
|
|
275
|
+
pending.controlIdSet.add(controlId);
|
|
276
|
+
pending.controlBytes += controlId.length;
|
|
277
|
+
this.#recordEvent({ type: "wake_deferred", agentId, controlIds: [controlId] });
|
|
278
|
+
return { status: "coalesced", controlId };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Resolve an existing durable acceptance first, otherwise record a shed event and reject.
|
|
282
|
+
* @param agentId - Affected agent.
|
|
283
|
+
* @param controlId - Validated control id correlating the rejected request.
|
|
284
|
+
* @param resolveExisting - Read-only durable retry resolution performed before the rejection
|
|
285
|
+
* becomes observable.
|
|
286
|
+
* @param shedReason - Policy that sheds the wake, also naming the correlation rejection's reason
|
|
287
|
+
* when it is not a wake-budget rejection.
|
|
288
|
+
* @returns Already-accepted admission when a durable retry resolved one, otherwise the rejection.
|
|
289
|
+
*/
|
|
290
|
+
#rejectOperatorPing(agentId, controlId, resolveExisting, shedReason) {
|
|
291
|
+
if (resolveExistingOperatorCommand(resolveExisting)) {
|
|
292
|
+
return { status: "already_accepted", controlId };
|
|
293
|
+
}
|
|
294
|
+
this.#recordEvent({ type: "wake_shed", agentId, reason: shedReason, controlIds: [controlId] });
|
|
295
|
+
return shedReason === "wake_budget"
|
|
296
|
+
? { status: "budget_rejected", controlId }
|
|
297
|
+
: { status: "correlation_rejected", controlId, reason: shedReason };
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Record recipient delivery availability and coalesce many arrivals to one pending wake.
|
|
301
|
+
* @param agentId - Agent whose delivery availability changed.
|
|
302
|
+
* @param busy - Whether the agent is currently occupied.
|
|
303
|
+
* @returns - Every released message/control member, or null when no state changed.
|
|
304
|
+
*/
|
|
305
|
+
setBusy(agentId, busy) {
|
|
306
|
+
if (busy) {
|
|
307
|
+
this.#busyAgents.add(agentId);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
if (this.#releasingAgents.has(agentId))
|
|
311
|
+
return null;
|
|
312
|
+
this.#busyAgents.delete(agentId);
|
|
313
|
+
const pending = this.#coalesced.get(agentId);
|
|
314
|
+
if (!pending)
|
|
315
|
+
return null;
|
|
316
|
+
this.#releasingAgents.add(agentId);
|
|
317
|
+
try {
|
|
318
|
+
const messageWake = pending.message ? this.#releasePendingMessage(agentId, pending) : null;
|
|
319
|
+
const controlWake = !this.#busyAgents.has(agentId) && pending.controlIds.length > 0
|
|
320
|
+
? this.#releasePendingOperatorPings(agentId, pending)
|
|
321
|
+
: null;
|
|
322
|
+
if (!pending.message && pending.controlIds.length === 0)
|
|
323
|
+
this.#coalesced.delete(agentId);
|
|
324
|
+
if (!messageWake && !controlWake)
|
|
325
|
+
return null;
|
|
326
|
+
return { type: "busy_wake_release", message: messageWake, operatorPing: controlWake };
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
this.#releasingAgents.delete(agentId);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Retry a dropped wake while durable messages remain unclaimed.
|
|
334
|
+
* @param agentId - Agent whose dropped wake is retried.
|
|
335
|
+
* @returns - Retry wake when queued work remains, otherwise null.
|
|
336
|
+
*/
|
|
337
|
+
retryUnclaimed(agentId) {
|
|
338
|
+
if (!this.#broker.hasQueuedMessages(agentId))
|
|
339
|
+
return null;
|
|
340
|
+
return this.notify(agentId, "redelivery");
|
|
341
|
+
}
|
|
342
|
+
#consumeWakeBudget(agentId) {
|
|
343
|
+
const history = this.#currentWakeHistory(agentId);
|
|
344
|
+
if (history.length + (this.#liveReservations.get(agentId) ?? 0) >= this.#wakeBudget)
|
|
345
|
+
return false;
|
|
346
|
+
history.push(this.#now());
|
|
347
|
+
this.#wakeHistory.set(agentId, history);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
#hasWakeBudget(agentId) {
|
|
351
|
+
const history = this.#currentWakeHistory(agentId);
|
|
352
|
+
return history.length + (this.#liveReservations.get(agentId) ?? 0) < this.#wakeBudget;
|
|
353
|
+
}
|
|
354
|
+
#reserveWakeBudget(agentId) {
|
|
355
|
+
const history = this.#currentWakeHistory(agentId);
|
|
356
|
+
const reservations = this.#liveReservations.get(agentId) ?? 0;
|
|
357
|
+
if (history.length + reservations >= this.#wakeBudget)
|
|
358
|
+
return false;
|
|
359
|
+
this.#liveReservations.set(agentId, reservations + 1);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
#currentWakeHistory(agentId) {
|
|
363
|
+
const cutoff = this.#now() - this.#wakeBudgetWindowMs;
|
|
364
|
+
const history = (this.#wakeHistory.get(agentId) ?? []).filter((at) => at > cutoff);
|
|
365
|
+
this.#wakeHistory.set(agentId, history);
|
|
366
|
+
return history;
|
|
367
|
+
}
|
|
368
|
+
#completeReservedWake(agentId) {
|
|
369
|
+
const reservations = this.#liveReservations.get(agentId) ?? 0;
|
|
370
|
+
if (reservations < 1)
|
|
371
|
+
throw new Error("Reserved notification wake is unavailable");
|
|
372
|
+
if (reservations === 1)
|
|
373
|
+
this.#liveReservations.delete(agentId);
|
|
374
|
+
else
|
|
375
|
+
this.#liveReservations.set(agentId, reservations - 1);
|
|
376
|
+
const history = this.#currentWakeHistory(agentId);
|
|
377
|
+
history.push(this.#now());
|
|
378
|
+
this.#wakeHistory.set(agentId, history);
|
|
379
|
+
}
|
|
380
|
+
#publishOperatorPings(agentId, controlIds) {
|
|
381
|
+
const validated = validateControlIds(controlIds);
|
|
382
|
+
const controlId = validated[0];
|
|
383
|
+
if (!controlId)
|
|
384
|
+
throw new Error("Validated operator control id is unavailable");
|
|
385
|
+
if (!this.#consumeWakeBudget(agentId)) {
|
|
386
|
+
this.#recordEvent({ type: "wake_shed", agentId, reason: "wake_budget", controlIds: validated });
|
|
387
|
+
return { status: "budget_rejected", controlId };
|
|
388
|
+
}
|
|
389
|
+
const coalescedControlIds = validated.slice(1);
|
|
390
|
+
const event = this.#hub.publishOperatorPing(agentId, controlId, coalescedControlIds);
|
|
391
|
+
this.#recordEvent({ type: "wake_sent", agentId, sequence: event.sequence, controlIds: validated });
|
|
392
|
+
return { status: "accepted", controlId, wake: event };
|
|
393
|
+
}
|
|
394
|
+
#releasePendingMessage(agentId, pending) {
|
|
395
|
+
const message = pending.message;
|
|
396
|
+
if (!message)
|
|
397
|
+
throw new Error("Pending message wake is unavailable");
|
|
398
|
+
delete pending.message;
|
|
399
|
+
const messageId = message.correlation === "single" ? message.messageId : undefined;
|
|
400
|
+
if (messageId)
|
|
401
|
+
this.#broker.recordNotification(messageId, "deferred");
|
|
402
|
+
const event = this.#hub.publish(agentId, message.reason, messageId);
|
|
403
|
+
this.#completeReservedWake(agentId);
|
|
404
|
+
this.#recordEvent({ type: "wake_sent", agentId, sequence: event.sequence });
|
|
405
|
+
return event;
|
|
406
|
+
}
|
|
407
|
+
#releasePendingOperatorPings(agentId, pending) {
|
|
408
|
+
const controlIds = [...pending.controlIds];
|
|
409
|
+
pending.controlIds.length = 0;
|
|
410
|
+
pending.controlIdSet.clear();
|
|
411
|
+
pending.controlBytes = 0;
|
|
412
|
+
const validated = validateControlIds(controlIds);
|
|
413
|
+
const controlId = validated[0];
|
|
414
|
+
if (!controlId)
|
|
415
|
+
throw new Error("Validated operator control id is unavailable");
|
|
416
|
+
const event = this.#hub.publishOperatorPing(agentId, controlId, validated.slice(1));
|
|
417
|
+
this.#completeReservedWake(agentId);
|
|
418
|
+
this.#recordEvent({ type: "wake_sent", agentId, sequence: event.sequence, controlIds: validated });
|
|
419
|
+
return event;
|
|
420
|
+
}
|
|
421
|
+
#pendingWake(agentId) {
|
|
422
|
+
const existing = this.#coalesced.get(agentId);
|
|
423
|
+
if (existing)
|
|
424
|
+
return existing;
|
|
425
|
+
const pending = { controlIds: [], controlIdSet: new Set(), controlBytes: 0 };
|
|
426
|
+
this.#coalesced.set(agentId, pending);
|
|
427
|
+
return pending;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Return operational notification events.
|
|
431
|
+
* @returns - Copy of accumulated operational events, safe for caller inspection.
|
|
432
|
+
*/
|
|
433
|
+
operationalEvents() {
|
|
434
|
+
return [...this.#events];
|
|
435
|
+
}
|
|
436
|
+
#recordEvent(event) {
|
|
437
|
+
this.#events.push(event);
|
|
438
|
+
if (this.#events.length > NOTIFICATION_OPERATIONAL_EVENT_LIMIT)
|
|
439
|
+
this.#events.shift();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function resolveExistingOperatorCommand(resolveExisting) {
|
|
443
|
+
const resolution = resolveExisting();
|
|
444
|
+
if (resolution === "existing")
|
|
445
|
+
return true;
|
|
446
|
+
if (resolution === "absent")
|
|
447
|
+
return false;
|
|
448
|
+
throw new Error("Operator command resolver returned an invalid result");
|
|
449
|
+
}
|
|
450
|
+
function validateControlIds(values) {
|
|
451
|
+
if (values.length > OPERATOR_CONTROL_CORRELATION_LIMIT) {
|
|
452
|
+
throw new NotificationValidationError("Operator control correlation count exceeds the bounded limit");
|
|
453
|
+
}
|
|
454
|
+
const output = [];
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
let bytes = 0;
|
|
457
|
+
for (const value of values) {
|
|
458
|
+
if (!isOpaqueId(value))
|
|
459
|
+
throw new NotificationValidationError("Operator control id is invalid");
|
|
460
|
+
if (seen.has(value))
|
|
461
|
+
throw new NotificationValidationError("Operator control ids must be unique");
|
|
462
|
+
bytes += value.length;
|
|
463
|
+
if (bytes > OPERATOR_CONTROL_CORRELATION_BYTES_LIMIT) {
|
|
464
|
+
throw new NotificationValidationError("Operator control correlation bytes exceed the bounded limit");
|
|
465
|
+
}
|
|
466
|
+
seen.add(value);
|
|
467
|
+
output.push(value);
|
|
468
|
+
}
|
|
469
|
+
if (output.length === 0)
|
|
470
|
+
throw new NotificationValidationError("At least one operator control id is required");
|
|
471
|
+
return output;
|
|
472
|
+
}
|
|
473
|
+
function canAppendControlId(pending, controlId) {
|
|
474
|
+
return (pending.controlIds.length < OPERATOR_CONTROL_CORRELATION_LIMIT &&
|
|
475
|
+
pending.controlBytes + controlId.length <= OPERATOR_CONTROL_CORRELATION_BYTES_LIMIT);
|
|
476
|
+
}
|
|
477
|
+
function coalescePendingMessage(existing, messageId, reason) {
|
|
478
|
+
if (!existing)
|
|
479
|
+
return messageId ? { correlation: "single", reason, messageId } : { correlation: "generic", reason };
|
|
480
|
+
if (existing.correlation === "generic" || messageId === undefined || existing.messageId !== messageId) {
|
|
481
|
+
return { correlation: "generic", reason: existing.reason };
|
|
482
|
+
}
|
|
483
|
+
return existing;
|
|
484
|
+
}
|
|
485
|
+
function cloneInboxChanged(event) {
|
|
486
|
+
return event.reason === "operator_ping"
|
|
487
|
+
? { ...event, coalescedControlIds: [...event.coalescedControlIds] }
|
|
488
|
+
: { ...event };
|
|
489
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Broker } from "../broker/broker.js";
|
|
2
|
+
import { type AgentId } from "../broker/protocol.js";
|
|
3
|
+
import type { HostPushNotificationCapability } from "../implementations/model.js";
|
|
4
|
+
import type { NotificationHub, NotificationScheduler } from "./notifications.js";
|
|
5
|
+
/** Broker operations the push service reaches, narrowed so a test can hand it a recording fake. */
|
|
6
|
+
export type PushDeliveryBroker = Pick<Broker, "fetch" | "releaseClaims" | "recordNotification" | "implementationForAgent" | "hasQueuedMessages">;
|
|
7
|
+
/** Scheduler operations the push service reaches. */
|
|
8
|
+
export type PushDeliveryScheduler = Pick<NotificationScheduler, "declareAdapter" | "setBusy" | "notify">;
|
|
9
|
+
/** Supplies every collaborator the push service reaches outside its own state. */
|
|
10
|
+
export type PushDeliveryDependencies = {
|
|
11
|
+
/** Claims, releases, and records against durable message state. */
|
|
12
|
+
broker: PushDeliveryBroker;
|
|
13
|
+
/** Publishes the wakes this service turns into deliveries. */
|
|
14
|
+
hub: Pick<NotificationHub, "subscribe">;
|
|
15
|
+
/** Owns busy coalescing and the wake budget for every recipient. */
|
|
16
|
+
scheduler: PushDeliveryScheduler;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the host-push capability of one implementation, or nothing when it has no notification capability.
|
|
19
|
+
* @param implementationId - Implementation recorded for the recipient at registration.
|
|
20
|
+
* @returns - Capability owning address parsing and transport, or undefined.
|
|
21
|
+
*/
|
|
22
|
+
capabilityFor(implementationId: string): HostPushNotificationCapability | undefined;
|
|
23
|
+
/** Writes one diagnostic the operator can read; never reaches a model. */
|
|
24
|
+
report(message: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Read wall-clock time for stamping a push attempt.
|
|
27
|
+
*
|
|
28
|
+
* Required rather than defaulted, so this service cannot stamp its attempts from a clock the
|
|
29
|
+
* broker whose rows it is acting on does not share.
|
|
30
|
+
* @returns - Epoch milliseconds.
|
|
31
|
+
*/
|
|
32
|
+
now: () => number;
|
|
33
|
+
};
|
|
34
|
+
/** Reports what registering an address changed. */
|
|
35
|
+
export type DeliveryAddressRegistration = "registered" | "unchanged" | "rejected" | "not_push";
|
|
36
|
+
/**
|
|
37
|
+
* Turns wakes for host-push recipients into deliveries written by their implementation.
|
|
38
|
+
*
|
|
39
|
+
* The service claims on the recipient's behalf immediately before writing, so a message is never
|
|
40
|
+
* held by a session that has not been handed it. A wake for a recipient without a registered
|
|
41
|
+
* address is left to the redelivery schedule, which offers it again once an address arrives.
|
|
42
|
+
*/
|
|
43
|
+
export declare class PushDeliveryService {
|
|
44
|
+
#private;
|
|
45
|
+
/**
|
|
46
|
+
* Construct the service over injected broker, hub, scheduler, and capability resolution.
|
|
47
|
+
* @param dependencies - Collaborators the service reaches outside its own state.
|
|
48
|
+
*/
|
|
49
|
+
constructor(dependencies: PushDeliveryDependencies);
|
|
50
|
+
/**
|
|
51
|
+
* Hold the address a session registered, and deliver anything already waiting for it.
|
|
52
|
+
*
|
|
53
|
+
* An unchanged address is a no-op.
|
|
54
|
+
* @param agentId - Recipient whose session supplied the address.
|
|
55
|
+
* @param raw - Untrusted address text exactly as the session supplied it.
|
|
56
|
+
* @returns - What the registration changed.
|
|
57
|
+
*/
|
|
58
|
+
registerAddress(agentId: AgentId, raw: string): DeliveryAddressRegistration;
|
|
59
|
+
/**
|
|
60
|
+
* Drop a recipient's address so no further wake is written to it.
|
|
61
|
+
* @param agentId - Recipient whose address is no longer usable.
|
|
62
|
+
*/
|
|
63
|
+
forget(agentId: AgentId): void;
|
|
64
|
+
/**
|
|
65
|
+
* Report whether a recipient currently has an address to deliver to.
|
|
66
|
+
* @param agentId - Recipient to check.
|
|
67
|
+
* @returns - Whether a delivery address is held for the recipient.
|
|
68
|
+
*/
|
|
69
|
+
hasAddress(agentId: AgentId): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Resolve once every delivery in flight, and every one released by their completion, has finished.
|
|
72
|
+
* @returns - Settles when no delivery chain remains.
|
|
73
|
+
*/
|
|
74
|
+
settled(): Promise<void>;
|
|
75
|
+
/** Drop every address and stop listening for wakes. */
|
|
76
|
+
close(): void;
|
|
77
|
+
}
|