@fitchmultz/pi-subagents 0.48.0 → 0.49.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.
@@ -1,174 +1,263 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { log } from "./bridge-protocol.ts";
2
3
  import { Mailbox, type PendingNotification } from "./bridge-mailbox.ts";
3
- import type { Identity, WakePolicy } from "./bridge-config.ts";
4
+ import type { Identity } from "./bridge-config.ts";
5
+ import { WakeState } from "./bridge-wake-state.ts";
4
6
 
5
- const POLL_MS = 15000;
6
- const FALLBACK_AFTER_MS = 120000;
7
- const MAX_BACKOFF_MS = 300000;
7
+ interface Delivery {
8
+ readonly identity: Identity;
9
+ readonly mailbox: Mailbox;
10
+ readonly expires: number;
11
+ readonly controller: AbortController;
12
+ }
13
+ interface Candidate {
14
+ readonly delivery: Delivery;
15
+ readonly entry: PendingNotification;
16
+ }
17
+
18
+ function actionable(entry: PendingNotification): boolean {
19
+ const message = entry.message;
20
+ if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
21
+ return false;
22
+ }
23
+ return (
24
+ message.expectsReply === true ||
25
+ !/^(?:ACK|DUP-ACK|FYI|NOTE|RECEIPT|NOWAKE)(?=$|[\s:])/.test(message.content.text)
26
+ );
27
+ }
28
+
29
+ function bucket(identity: Identity): string {
30
+ return createHash("sha256")
31
+ .update(identity.wake?.url ?? "")
32
+ .digest("hex");
33
+ }
8
34
 
9
- export class WakeDelivery {
35
+ /** Mailbox commits and durable retry/quota deadlines drive wakes; there is no intake scan timer. */
36
+ export class WakeDeliveries {
37
+ private readonly stateDir: string | undefined;
38
+ private readonly state?: WakeState;
39
+ private readonly allowed: (identity: Identity) => boolean;
40
+ private readonly deliveries = new Map<string, Delivery>();
10
41
  private readonly controller = new AbortController();
11
42
  private timer?: NodeJS.Timeout;
12
43
  private running?: Promise<void>;
13
- private backoff = POLL_MS;
14
- private readonly name: string;
15
- private readonly mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">;
16
- private readonly policy: WakePolicy;
17
- private readonly authorized: () => boolean;
18
-
19
- constructor(
20
- name: string,
21
- mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">,
22
- policy: WakePolicy,
23
- authorized: () => boolean,
24
- ) {
25
- this.name = name;
26
- this.mailbox = mailbox;
27
- this.policy = policy;
28
- this.authorized = authorized;
29
- }
44
+ private dirty = false;
45
+ private storageBackoff = 15000;
30
46
 
31
- start(): void {
32
- if (!this.running && !this.controller.signal.aborted) {
33
- this.schedule(0);
34
- }
47
+ constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
48
+ this.stateDir = stateDir;
49
+ this.allowed = allowed;
50
+ // Initialize before any presence proof/mailbox is created, so genuinely fresh installs start now.
51
+ this.state = stateDir === undefined ? undefined : new WakeState(stateDir);
35
52
  }
36
53
 
37
- private schedule(delay: number): void {
38
- clearTimeout(this.timer);
39
- this.timer = setTimeout(() => {
40
- this.running = this.deliver()
41
- .catch(() => {
42
- log("wake_delivery", { name: this.name, result: "storage_unavailable" });
43
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
44
- })
45
- .finally(() => {
46
- this.running = undefined;
47
- if (!this.controller.signal.aborted) {
48
- this.schedule(this.backoff);
49
- }
50
- });
51
- }, delay);
54
+ session(identity: Identity, expires: number): void {
55
+ if (
56
+ !identity.wake ||
57
+ this.stateDir === undefined ||
58
+ this.deliveries.has(identity.fingerprint256)
59
+ ) {
60
+ return;
61
+ }
62
+ this.deliveries.set(identity.fingerprint256, {
63
+ identity,
64
+ expires,
65
+ mailbox: new Mailbox(this.stateDir, identity.fingerprint256, true),
66
+ controller: new AbortController(),
67
+ });
68
+ // Startup restores every certificate proof synchronously before the scheduled reconciliation.
69
+ // An empty first fingerprint must not clear another retained fingerprint's durable cold batch.
70
+ this.changed(false);
52
71
  }
53
72
 
54
- private async deliver(): Promise<void> {
55
- if (!this.authorized()) {
73
+ changed(reconcile = true): void {
74
+ if (this.controller.signal.aborted) {
56
75
  return;
57
76
  }
58
- const entries = this.mailbox.pendingNotifications().messages;
59
- for (const entry of entries) {
60
- if (entry.acknowledged) {
61
- // The box's durable worker owns primary wake/replay after acknowledgement.
62
- this.mailbox.notificationAccepted(entry.message.id);
77
+ try {
78
+ // ACK may be followed by fresh mail before the event timer runs. Clear completed batches now
79
+ // so a new obligation does not inherit a completed batch's uncertainty/backoff.
80
+ if (reconcile) {
81
+ this.reconcile();
63
82
  }
83
+ } catch {
84
+ // The scheduled storage recovery fails closed; never let an event bypass durable admission.
64
85
  }
65
- const next = entries.find(
66
- (entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS,
86
+ if (this.running) {
87
+ this.dirty = true;
88
+ } else {
89
+ this.schedule(Date.now());
90
+ }
91
+ }
92
+
93
+ private reconcile(): void {
94
+ for (const [key, candidate] of this.groups()) {
95
+ if (candidate) {
96
+ this.state?.deadline(key);
97
+ } else {
98
+ this.state?.retire(key);
99
+ }
100
+ }
101
+ }
102
+
103
+ private schedule(deadline: number): void {
104
+ clearTimeout(this.timer);
105
+ this.timer = setTimeout(
106
+ () => {
107
+ this.dirty = false;
108
+ let next = Infinity;
109
+ this.running = this.deliver()
110
+ .then((due) => {
111
+ next = due;
112
+ this.storageBackoff = 15000;
113
+ return;
114
+ })
115
+ .catch(() => {
116
+ log("wake_delivery", { result: "storage_unavailable" });
117
+ next = Date.now() + this.storageBackoff;
118
+ this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
119
+ })
120
+ .finally(() => {
121
+ this.running = undefined;
122
+ if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
123
+ this.schedule(this.dirty ? Date.now() : next);
124
+ }
125
+ });
126
+ },
127
+ Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)),
67
128
  );
68
- if (next) {
69
- // One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
70
- await this.post(next);
71
- return;
129
+ }
130
+
131
+ private authorized(delivery: Delivery): boolean {
132
+ return (
133
+ !this.controller.signal.aborted &&
134
+ this.allowed(delivery.identity) &&
135
+ delivery.expires > Date.now()
136
+ );
137
+ }
138
+
139
+ private pending(delivery: Delivery): PendingNotification | undefined {
140
+ let after = 0;
141
+ let next: PendingNotification | undefined;
142
+ let page;
143
+ do {
144
+ page = delivery.mailbox.pendingNotifications(after);
145
+ for (const entry of page.messages) {
146
+ if (!actionable(entry)) {
147
+ // Retire only notification intent. Payload/reply obligations require actual ACK/reply.
148
+ delivery.mailbox.notificationAccepted(entry.message.id);
149
+ } else if (!next) {
150
+ next = entry;
151
+ }
152
+ }
153
+ after = Number(page.nextCursor);
154
+ } while (page.hasMore);
155
+ return next;
156
+ }
157
+
158
+ private groups(): Map<string, Candidate | undefined> {
159
+ const groups = new Map<string, Candidate | undefined>();
160
+ for (const delivery of this.deliveries.values()) {
161
+ if (!this.authorized(delivery)) {
162
+ continue;
163
+ }
164
+ const key = bucket(delivery.identity);
165
+ const entry = this.pending(delivery);
166
+ const previous = groups.get(key);
167
+ if (
168
+ !groups.has(key) ||
169
+ (entry && (!previous || entry.receivedAt < previous.entry.receivedAt))
170
+ ) {
171
+ groups.set(key, entry ? { delivery, entry } : undefined);
172
+ }
173
+ }
174
+ return groups;
175
+ }
176
+
177
+ private async deliver(): Promise<number> {
178
+ const state = this.state;
179
+ let next = Infinity;
180
+ if (!state) {
181
+ return next;
182
+ }
183
+ // ACK can remove the final candidate while its failed completion write still needs recovery.
184
+ state.retrySettlement();
185
+ for (const [key, candidate] of this.groups()) {
186
+ if (!candidate) {
187
+ state.retire(key);
188
+ continue;
189
+ }
190
+ const due = state.deadline(key);
191
+ if (due > Date.now()) {
192
+ next = Math.min(next, due);
193
+ continue;
194
+ }
195
+ const deferred = state.reserve(key);
196
+ if (deferred !== undefined) {
197
+ next = Math.min(next, deferred);
198
+ continue;
199
+ }
200
+ // ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
201
+ // requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
202
+ try {
203
+ // oxlint-disable-next-line no-await-in-loop
204
+ await this.post(candidate);
205
+ } finally {
206
+ // Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
207
+ state.settle();
208
+ }
209
+ // Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
210
+ return Date.now();
72
211
  }
73
- this.backoff = POLL_MS;
212
+ return next;
74
213
  }
75
214
 
76
- private async post(entry: PendingNotification): Promise<void> {
77
- if (!this.authorized()) {
215
+ private async post({ delivery, entry }: Candidate): Promise<void> {
216
+ const policy = delivery.identity.wake;
217
+ if (!policy || !this.authorized(delivery)) {
78
218
  return;
79
219
  }
80
- const fields = { name: this.name, messageId: entry.message.id };
220
+ const fields = { name: delivery.identity.name, messageId: entry.message.id };
81
221
  try {
82
- const response = await fetch(this.policy.url, {
222
+ const response = await fetch(policy.url, {
83
223
  method: "POST",
84
224
  redirect: "error",
85
- headers: {
86
- Authorization: `Bearer ${this.policy.key}`,
87
- "Content-Type": "application/json",
88
- },
225
+ headers: { Authorization: `Bearer ${policy.key}`, "Content-Type": "application/json" },
89
226
  body: JSON.stringify({
90
227
  from: entry.from.name ?? entry.from.id,
91
228
  from_id: entry.from.id,
92
- to: this.name,
229
+ to: delivery.identity.name,
93
230
  message_id: entry.message.id,
94
231
  ask_id: entry.message.expectsReply === true ? entry.message.id : null,
95
232
  text: entry.message.content.text,
96
233
  }),
97
- signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
234
+ signal: AbortSignal.any([
235
+ this.controller.signal,
236
+ delivery.controller.signal,
237
+ AbortSignal.timeout(10000),
238
+ ]),
98
239
  });
99
240
  await response.body?.cancel();
100
- if (response.ok && !this.controller.signal.aborted && this.authorized()) {
101
- // HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
102
- this.backoff = MAX_BACKOFF_MS;
103
- log("wake_delivery", { ...fields, result: "accepted" });
104
- } else {
105
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
106
- log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
107
- }
241
+ // Acceptance does not consume mail or reset uncertainty: only real inbox ACK retires intent.
242
+ log("wake_delivery", {
243
+ ...fields,
244
+ result: response.ok ? "accepted" : "http_rejected",
245
+ status: response.status,
246
+ });
108
247
  } catch {
109
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
110
248
  if (!this.controller.signal.aborted) {
111
249
  log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
112
250
  }
113
251
  }
114
252
  }
115
253
 
116
- async stop(): Promise<void> {
117
- this.controller.abort();
118
- clearTimeout(this.timer);
119
- await this.running;
120
- }
121
- }
122
-
123
- export class WakeDeliveries {
124
- private readonly stateDir: string | undefined;
125
- private readonly allowed: (identity: Identity) => boolean;
126
- private readonly deliveries = new Map<
127
- string,
128
- { readonly identity: Identity; readonly mailbox: Mailbox; readonly worker: WakeDelivery }
129
- >();
130
- private readonly cleanup = new Set<Promise<unknown>>();
131
-
132
- constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
133
- this.stateDir = stateDir;
134
- this.allowed = allowed;
135
- }
136
-
137
- session(identity: Identity, expires: number): void {
138
- if (
139
- !identity.wake ||
140
- this.stateDir === undefined ||
141
- this.deliveries.has(identity.fingerprint256)
142
- ) {
143
- return;
144
- }
145
- const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
146
- const worker = new WakeDelivery(
147
- identity.name,
148
- mailbox,
149
- identity.wake,
150
- () => this.allowed(identity) && expires > Date.now(),
151
- );
152
- this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
153
- worker.start();
154
- }
155
-
156
254
  drop(fp: string): void {
157
255
  const delivery = this.deliveries.get(fp);
158
- if (!delivery) {
159
- return;
160
- }
161
256
  this.deliveries.delete(fp);
162
- const cleanup = delivery.worker
163
- .stop()
164
- .then(() => delivery.mailbox.close())
165
- .catch(() => {
166
- log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
167
- })
168
- .finally(() => {
169
- this.cleanup.delete(cleanup);
170
- });
171
- this.cleanup.add(cleanup);
257
+ delivery?.controller.abort();
258
+ delivery?.mailbox.close();
259
+ // Membership removal alone is not an ACK. Reload restores proof-backed replacements first.
260
+ this.changed(false);
172
261
  }
173
262
 
174
263
  reload(): void {
@@ -177,12 +266,17 @@ export class WakeDeliveries {
177
266
  this.drop(fp);
178
267
  }
179
268
  }
269
+ this.changed(false);
180
270
  }
181
271
 
182
272
  async stop(): Promise<void> {
183
- for (const fp of this.deliveries.keys()) {
184
- this.drop(fp);
273
+ this.controller.abort();
274
+ clearTimeout(this.timer);
275
+ await this.running;
276
+ for (const delivery of this.deliveries.values()) {
277
+ delivery.mailbox.close();
185
278
  }
186
- await Promise.all(this.cleanup);
279
+ this.deliveries.clear();
280
+ this.state?.close();
187
281
  }
188
282
  }
@@ -64,6 +64,7 @@ class Bridge {
64
64
  this.presence = new PersistentPresence({
65
65
  stateDir: config.stateDir,
66
66
  clients: () => this.config.clients,
67
+ restored: (identity, expires) => this.deliveries.session(identity, expires),
67
68
  connect: async (identity, expires) => {
68
69
  await spawnBrokerIfNeeded(process.execPath, []);
69
70
  if (!this.allowed(identity)) {
@@ -153,6 +154,7 @@ class Bridge {
153
154
  {
154
155
  stateDir: this.config.stateDir,
155
156
  notifications: identity.wake !== undefined,
157
+ mailboxChanged: () => this.deliveries.changed(),
156
158
  allowed: (candidate) => this.allowed(candidate),
157
159
  lost: (fp, count) => this.lost(fp, count),
158
160
  losses: (fp) => this.losses.get(fp) ?? 0,
@@ -299,6 +301,9 @@ class Bridge {
299
301
  this.losses.delete(fp);
300
302
  }
301
303
  }
304
+ // Retire stale sessions before publishing replacement delivery membership for the same pin.
305
+ this.presence?.restoreAll();
306
+ this.deliveries.changed();
302
307
  }
303
308
  private stop(): void {
304
309
  this.shutdown().catch(() => {
@@ -363,8 +368,11 @@ class Bridge {
363
368
  let controller: AbortController | undefined;
364
369
  let deadline: NodeJS.Timeout | undefined;
365
370
  let peer: string | undefined;
371
+ let readOnlyInbox = false;
372
+ const cancel = (fault: Readonly<Fault>) =>
373
+ readOnlyInbox ? controller?.abort(fault) : session?.close(fault);
366
374
  const abort = () =>
367
- session?.close(
375
+ cancel(
368
376
  new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."),
369
377
  );
370
378
  const onClose = () => {
@@ -376,7 +384,7 @@ class Bridge {
376
384
  clearTimeout(deadline);
377
385
  deadline = setTimeout(
378
386
  () =>
379
- session?.close(
387
+ cancel(
380
388
  new Fault(
381
389
  504,
382
390
  "request_timeout",
@@ -389,6 +397,7 @@ class Bridge {
389
397
  try {
390
398
  const { identity, expires, certificate } = this.admission(req);
391
399
  const route = routeFor(req);
400
+ readOnlyInbox = route === "GET /v1/inbox";
392
401
  session = this.session(identity, expires);
393
402
  controller = session.begin();
394
403
  const signal = controller.signal;