@nicknisi/pi-relay 0.2.0 → 0.3.1
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/README.md +17 -3
- package/core.ts +314 -0
- package/dist/core.d.ts +32 -0
- package/dist/core.js +223 -0
- package/dist/core.test.d.ts +1 -0
- package/dist/core.test.js +358 -0
- package/dist/filesystem.d.ts +42 -0
- package/dist/filesystem.js +614 -0
- package/dist/index.js +105 -26
- package/dist/mailbox.d.ts +30 -8
- package/dist/mailbox.js +615 -126
- package/dist/registry.js +254 -70
- package/dist/relay.test.js +204 -2
- package/filesystem.ts +635 -0
- package/index.ts +101 -25
- package/mailbox.ts +557 -122
- package/package.json +19 -1
- package/registry.ts +213 -66
package/index.ts
CHANGED
|
@@ -29,15 +29,20 @@ import {
|
|
|
29
29
|
shortAddr,
|
|
30
30
|
} from './format.js';
|
|
31
31
|
import {
|
|
32
|
+
ackClaimedLetter,
|
|
32
33
|
appendAudit,
|
|
33
34
|
awaitReceipt,
|
|
35
|
+
claimInbox,
|
|
34
36
|
clearAsk,
|
|
35
|
-
drain,
|
|
36
37
|
deposit,
|
|
38
|
+
outgoingAskIds,
|
|
37
39
|
pendingAsks,
|
|
38
40
|
previewBody,
|
|
39
41
|
readAudit,
|
|
42
|
+
readClaimedLetter,
|
|
40
43
|
readOutgoingAsk,
|
|
44
|
+
recoverInboxClaims,
|
|
45
|
+
requeueClaimedLetter,
|
|
41
46
|
resolveAskByRef,
|
|
42
47
|
trackIncomingAsk,
|
|
43
48
|
trackOutgoingAsk,
|
|
@@ -168,10 +173,22 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
168
173
|
let unwatch: (() => void) | undefined;
|
|
169
174
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
170
175
|
const askWaiters = new Map<string, (outcome: AskOutcome) => void>();
|
|
171
|
-
// Backoff while the session can't accept mail: a
|
|
176
|
+
// Backoff while the session can't accept mail: a requeued letter
|
|
172
177
|
// re-fires the watcher, and without this a failing sendMessage would spin
|
|
173
|
-
//
|
|
178
|
+
// claim→fail→requeue at watch speed.
|
|
174
179
|
let lastDeliveryFailureAt = 0;
|
|
180
|
+
// Letter ids this session has already accepted. Claims make redelivery
|
|
181
|
+
// possible (a crash between delivery and ack, or an ack failure after a
|
|
182
|
+
// successful send); without dedupe the model would see the body twice.
|
|
183
|
+
// Seeded from the transcript on session_start so recovery after a crash
|
|
184
|
+
// doesn't redeliver what the previous process already handed to the agent.
|
|
185
|
+
const deliveredIds = new Set<string>();
|
|
186
|
+
const DELIVERED_IDS_CAP = 512;
|
|
187
|
+
const noteDelivered = (id: string): void => {
|
|
188
|
+
deliveredIds.delete(id); // refresh recency
|
|
189
|
+
deliveredIds.add(id);
|
|
190
|
+
if (deliveredIds.size > DELIVERED_IDS_CAP) deliveredIds.delete(deliveredIds.values().next().value!);
|
|
191
|
+
};
|
|
175
192
|
// Presence watch: addr → last observed presence. A poller surfaces
|
|
176
193
|
// transitions as a notification message (no full turn wake).
|
|
177
194
|
const watched = new Map<string, Presence>();
|
|
@@ -191,7 +208,13 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
191
208
|
function deliver(letter: Letter): boolean {
|
|
192
209
|
// Audited after the delivery attempts below: a 'deliver' record means the
|
|
193
210
|
// session actually accepted the letter; refusal records 'deliver-failed'
|
|
194
|
-
// (the caller
|
|
211
|
+
// (the caller requeues, so logging here would duplicate every retry).
|
|
212
|
+
// Note pi.sendMessage is fire-and-forget: asynchronous failures are
|
|
213
|
+
// swallowed by the runtime (emitted as extension errors, never rethrown),
|
|
214
|
+
// so only synchronous refusals — a disposed/uninitialized runtime during
|
|
215
|
+
// reload or shutdown — reach these catches. That is exactly the case
|
|
216
|
+
// where the letter never enters the transcript: async rejections from the
|
|
217
|
+
// triggerTurn path happen after the message is already in context.
|
|
195
218
|
const auditDelivery = (event: 'deliver' | 'deliver-failed') =>
|
|
196
219
|
appendAudit(root, {
|
|
197
220
|
ts: Date.now(),
|
|
@@ -254,7 +277,7 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
254
277
|
return true;
|
|
255
278
|
} catch {
|
|
256
279
|
// session is shutting down or otherwise undeliverable — the caller
|
|
257
|
-
//
|
|
280
|
+
// requeues the letter so a future drain retries
|
|
258
281
|
auditDelivery('deliver-failed');
|
|
259
282
|
return false;
|
|
260
283
|
}
|
|
@@ -264,35 +287,76 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
264
287
|
|
|
265
288
|
function checkInbox(): void {
|
|
266
289
|
if (!self) return;
|
|
290
|
+
const me = self.addr;
|
|
267
291
|
if (Date.now() - lastDeliveryFailureAt < 5000) return;
|
|
268
|
-
// Refuse mode: never
|
|
292
|
+
// Refuse mode: never claim. Letters stay on disk (receipts honestly read
|
|
269
293
|
// 'queued') instead of being silently consumed and dropped.
|
|
270
294
|
if (!inboundAccepts()) return;
|
|
271
|
-
|
|
295
|
+
// Claim, not drain: the inbox is detached atomically and each letter is
|
|
296
|
+
// only acked (deleted) once the session has accepted it. A claimed but
|
|
297
|
+
// unacked letter still reads 'queued' to the sender's receipt, survives
|
|
298
|
+
// a crash (recoverInboxClaims on the next session_start), and can be
|
|
299
|
+
// requeued for retry — unlink-as-read could silently lose it while the
|
|
300
|
+
// sender's receipt reported "delivered".
|
|
301
|
+
let claim: ReturnType<typeof claimInbox>;
|
|
272
302
|
try {
|
|
273
|
-
|
|
303
|
+
claim = claimInbox(root, me);
|
|
274
304
|
} catch {
|
|
275
305
|
return;
|
|
276
306
|
}
|
|
277
|
-
|
|
307
|
+
if (!claim) return;
|
|
308
|
+
const { claimToken, fileTokens } = claim;
|
|
309
|
+
const ack = (fileToken: string) => {
|
|
310
|
+
try {
|
|
311
|
+
ackClaimedLetter(root, me, claimToken, fileToken);
|
|
312
|
+
} catch {
|
|
313
|
+
// ack failure leaves the letter claimed; recovery requeues it and
|
|
314
|
+
// deliveredIds suppresses the duplicate
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
for (const fileToken of fileTokens) {
|
|
318
|
+
let letter: Letter | null = null;
|
|
319
|
+
try {
|
|
320
|
+
letter = readClaimedLetter(root, me, claimToken, fileToken);
|
|
321
|
+
} catch {
|
|
322
|
+
// transient read failure — keep it claimed rather than discarding
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!letter || deliveredIds.has(letter.id)) {
|
|
326
|
+
// corrupt letters are discarded like drain does; already-accepted
|
|
327
|
+
// letters are redeliveries (crash between delivery and ack)
|
|
328
|
+
ack(fileToken);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
278
331
|
let accepted = false;
|
|
279
332
|
try {
|
|
280
333
|
accepted = deliver(letter);
|
|
281
334
|
} catch {
|
|
282
|
-
// a malformed delivery never blocks the rest of the
|
|
335
|
+
// a malformed delivery never blocks the rest of the claim
|
|
283
336
|
}
|
|
284
|
-
if (
|
|
337
|
+
if (accepted) {
|
|
338
|
+
noteDelivered(letter.id);
|
|
339
|
+
ack(fileToken);
|
|
340
|
+
} else {
|
|
285
341
|
lastDeliveryFailureAt = Date.now();
|
|
286
|
-
// Not handed to the session —
|
|
287
|
-
//
|
|
288
|
-
// while the sender's receipt reported "delivered".
|
|
342
|
+
// Not handed to the session — atomically return it to the live inbox
|
|
343
|
+
// so the watch/poll retries it.
|
|
289
344
|
try {
|
|
290
|
-
|
|
345
|
+
requeueClaimedLetter(root, me, claimToken, fileToken);
|
|
291
346
|
} catch {
|
|
292
|
-
//
|
|
347
|
+
// stays claimed; the next session_start recovers it
|
|
293
348
|
}
|
|
294
349
|
}
|
|
295
350
|
}
|
|
351
|
+
// claimInbox renamed the watched inbox dir away and created a fresh one,
|
|
352
|
+
// so the fs.watch is now on a detached directory. Re-arm on the live
|
|
353
|
+
// inbox (the 3s poll covers anything deposited in between).
|
|
354
|
+
try {
|
|
355
|
+
unwatch?.();
|
|
356
|
+
unwatch = watchInbox(root, me, checkInbox);
|
|
357
|
+
} catch {
|
|
358
|
+
// watch failure is non-fatal; the old poll timer died with the old watch
|
|
359
|
+
}
|
|
296
360
|
}
|
|
297
361
|
|
|
298
362
|
function myFrom(): { addr: string; name: string; cwd: string } {
|
|
@@ -431,6 +495,26 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
431
495
|
writeRecord(root, self);
|
|
432
496
|
const resumable = collectResumableSessionIds();
|
|
433
497
|
sweep(root, Date.now(), (id) => resumable.has(id));
|
|
498
|
+
// Seed redelivery dedupe from the transcript: a letter delivered by a
|
|
499
|
+
// previous process but never acked (crash between delivery and ack)
|
|
500
|
+
// will be recovered below and must not be handed to the model twice.
|
|
501
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
502
|
+
if (entry.type !== 'custom_message' || entry.customType !== DELIVERY_TYPE) continue;
|
|
503
|
+
const id = (entry.details as { id?: unknown } | undefined)?.id;
|
|
504
|
+
if (typeof id === 'string') noteDelivered(id);
|
|
505
|
+
}
|
|
506
|
+
// Recover letters stranded in durable claims by a crash mid-delivery:
|
|
507
|
+
// return them to the live inbox so the normal watch/poll retries them.
|
|
508
|
+
// Runs before the watch is armed so requeues land in the watched inbox.
|
|
509
|
+
for (const stranded of recoverInboxClaims(root, self.addr)) {
|
|
510
|
+
for (const fileToken of stranded.fileTokens) {
|
|
511
|
+
try {
|
|
512
|
+
requeueClaimedLetter(root, self.addr, stranded.claimToken, fileToken);
|
|
513
|
+
} catch {
|
|
514
|
+
// stays claimed; the next session_start retries recovery
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
434
518
|
unwatch?.();
|
|
435
519
|
unwatch = watchInbox(root, self.addr, checkInbox);
|
|
436
520
|
heartbeat = setInterval(() => writeSelf({}), 15_000);
|
|
@@ -770,15 +854,7 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
770
854
|
|
|
771
855
|
/** Find an outgoing ask id by exact id or unique prefix (for cancel ergonomics). */
|
|
772
856
|
function resolveOutAskId(root: string, addr: string, idOrPrefix: string): string | undefined {
|
|
773
|
-
|
|
774
|
-
try {
|
|
775
|
-
names = fs.readdirSync(path.join(root, `${addr}.asks`));
|
|
776
|
-
} catch {
|
|
777
|
-
return undefined;
|
|
778
|
-
}
|
|
779
|
-
const ids = names
|
|
780
|
-
.filter((f) => f.startsWith('out-') && f.endsWith('.json'))
|
|
781
|
-
.map((f) => f.slice('out-'.length, -'.json'.length));
|
|
857
|
+
const ids = outgoingAskIds(root, addr);
|
|
782
858
|
if (ids.includes(idOrPrefix)) return idOrPrefix;
|
|
783
859
|
const matches = ids.filter((id) => id.startsWith(idOrPrefix));
|
|
784
860
|
return matches.length === 1 ? matches[0] : undefined;
|