@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/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 re-deposited letter
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
- // drain→fail→re-deposit at watch speed.
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 re-deposits, so logging here would duplicate every retry).
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
- // re-deposits the letter so a future drain retries
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 drain. Letters stay on disk (receipts honestly read
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
- let letters: Letter[];
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
- letters = drain(root, self.addr);
303
+ claim = claimInbox(root, me);
274
304
  } catch {
275
305
  return;
276
306
  }
277
- for (const letter of letters) {
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 drain
335
+ // a malformed delivery never blocks the rest of the claim
283
336
  }
284
- if (!accepted) {
337
+ if (accepted) {
338
+ noteDelivered(letter.id);
339
+ ack(fileToken);
340
+ } else {
285
341
  lastDeliveryFailureAt = Date.now();
286
- // Not handed to the session — put the letter back so a future drain
287
- // retries it; otherwise drain's unlink-as-read would silently lose it
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
- deposit(root, self.addr, letter);
345
+ requeueClaimedLetter(root, me, claimToken, fileToken);
291
346
  } catch {
292
- // inbox dir unwritable; nothing more we can do
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
- let names: string[];
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;