@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/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
18
18
  import { Box, getKeybindings, Text } from '@earendil-works/pi-tui';
19
19
  import { Type } from 'typebox';
20
20
  import { formatAudit, formatDelivery, formatListing, formatPendingAsk, refusalAmbiguous, refusalUnknown, shortAddr, } from './format.js';
21
- import { appendAudit, awaitReceipt, clearAsk, drain, deposit, pendingAsks, previewBody, readAudit, readOutgoingAsk, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
21
+ import { ackClaimedLetter, appendAudit, awaitReceipt, claimInbox, clearAsk, deposit, outgoingAskIds, pendingAsks, previewBody, readAudit, readClaimedLetter, readOutgoingAsk, recoverInboxClaims, requeueClaimedLetter, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
22
22
  import { OutboundPolicy, inboundAccepts } from './policy.js';
23
23
  import { claimAlias, deriveAddr, ensureRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeRecord, } from './registry.js';
24
24
  function toolResult(text, details = {}) {
@@ -99,10 +99,23 @@ export default function relay(pi) {
99
99
  let unwatch;
100
100
  let heartbeat;
101
101
  const askWaiters = new Map();
102
- // Backoff while the session can't accept mail: a re-deposited letter
102
+ // Backoff while the session can't accept mail: a requeued letter
103
103
  // re-fires the watcher, and without this a failing sendMessage would spin
104
- // drain→fail→re-deposit at watch speed.
104
+ // claim→fail→requeue at watch speed.
105
105
  let lastDeliveryFailureAt = 0;
106
+ // Letter ids this session has already accepted. Claims make redelivery
107
+ // possible (a crash between delivery and ack, or an ack failure after a
108
+ // successful send); without dedupe the model would see the body twice.
109
+ // Seeded from the transcript on session_start so recovery after a crash
110
+ // doesn't redeliver what the previous process already handed to the agent.
111
+ const deliveredIds = new Set();
112
+ const DELIVERED_IDS_CAP = 512;
113
+ const noteDelivered = (id) => {
114
+ deliveredIds.delete(id); // refresh recency
115
+ deliveredIds.add(id);
116
+ if (deliveredIds.size > DELIVERED_IDS_CAP)
117
+ deliveredIds.delete(deliveredIds.values().next().value);
118
+ };
106
119
  // Presence watch: addr → last observed presence. A poller surfaces
107
120
  // transitions as a notification message (no full turn wake).
108
121
  const watched = new Map();
@@ -122,7 +135,13 @@ export default function relay(pi) {
122
135
  function deliver(letter) {
123
136
  // Audited after the delivery attempts below: a 'deliver' record means the
124
137
  // session actually accepted the letter; refusal records 'deliver-failed'
125
- // (the caller re-deposits, so logging here would duplicate every retry).
138
+ // (the caller requeues, so logging here would duplicate every retry).
139
+ // Note pi.sendMessage is fire-and-forget: asynchronous failures are
140
+ // swallowed by the runtime (emitted as extension errors, never rethrown),
141
+ // so only synchronous refusals — a disposed/uninitialized runtime during
142
+ // reload or shutdown — reach these catches. That is exactly the case
143
+ // where the letter never enters the transcript: async rejections from the
144
+ // triggerTurn path happen after the message is already in context.
126
145
  const auditDelivery = (event) => appendAudit(root, {
127
146
  ts: Date.now(),
128
147
  event,
@@ -186,7 +205,7 @@ export default function relay(pi) {
186
205
  }
187
206
  catch {
188
207
  // session is shutting down or otherwise undeliverable — the caller
189
- // re-deposits the letter so a future drain retries
208
+ // requeues the letter so a future drain retries
190
209
  auditDelivery('deliver-failed');
191
210
  return false;
192
211
  }
@@ -196,40 +215,86 @@ export default function relay(pi) {
196
215
  function checkInbox() {
197
216
  if (!self)
198
217
  return;
218
+ const me = self.addr;
199
219
  if (Date.now() - lastDeliveryFailureAt < 5000)
200
220
  return;
201
- // Refuse mode: never drain. Letters stay on disk (receipts honestly read
221
+ // Refuse mode: never claim. Letters stay on disk (receipts honestly read
202
222
  // 'queued') instead of being silently consumed and dropped.
203
223
  if (!inboundAccepts())
204
224
  return;
205
- let letters;
225
+ // Claim, not drain: the inbox is detached atomically and each letter is
226
+ // only acked (deleted) once the session has accepted it. A claimed but
227
+ // unacked letter still reads 'queued' to the sender's receipt, survives
228
+ // a crash (recoverInboxClaims on the next session_start), and can be
229
+ // requeued for retry — unlink-as-read could silently lose it while the
230
+ // sender's receipt reported "delivered".
231
+ let claim;
206
232
  try {
207
- letters = drain(root, self.addr);
233
+ claim = claimInbox(root, me);
208
234
  }
209
235
  catch {
210
236
  return;
211
237
  }
212
- for (const letter of letters) {
238
+ if (!claim)
239
+ return;
240
+ const { claimToken, fileTokens } = claim;
241
+ const ack = (fileToken) => {
242
+ try {
243
+ ackClaimedLetter(root, me, claimToken, fileToken);
244
+ }
245
+ catch {
246
+ // ack failure leaves the letter claimed; recovery requeues it and
247
+ // deliveredIds suppresses the duplicate
248
+ }
249
+ };
250
+ for (const fileToken of fileTokens) {
251
+ let letter = null;
252
+ try {
253
+ letter = readClaimedLetter(root, me, claimToken, fileToken);
254
+ }
255
+ catch {
256
+ // transient read failure — keep it claimed rather than discarding
257
+ continue;
258
+ }
259
+ if (!letter || deliveredIds.has(letter.id)) {
260
+ // corrupt letters are discarded like drain does; already-accepted
261
+ // letters are redeliveries (crash between delivery and ack)
262
+ ack(fileToken);
263
+ continue;
264
+ }
213
265
  let accepted = false;
214
266
  try {
215
267
  accepted = deliver(letter);
216
268
  }
217
269
  catch {
218
- // a malformed delivery never blocks the rest of the drain
270
+ // a malformed delivery never blocks the rest of the claim
219
271
  }
220
- if (!accepted) {
272
+ if (accepted) {
273
+ noteDelivered(letter.id);
274
+ ack(fileToken);
275
+ }
276
+ else {
221
277
  lastDeliveryFailureAt = Date.now();
222
- // Not handed to the session — put the letter back so a future drain
223
- // retries it; otherwise drain's unlink-as-read would silently lose it
224
- // while the sender's receipt reported "delivered".
278
+ // Not handed to the session — atomically return it to the live inbox
279
+ // so the watch/poll retries it.
225
280
  try {
226
- deposit(root, self.addr, letter);
281
+ requeueClaimedLetter(root, me, claimToken, fileToken);
227
282
  }
228
283
  catch {
229
- // inbox dir unwritable; nothing more we can do
284
+ // stays claimed; the next session_start recovers it
230
285
  }
231
286
  }
232
287
  }
288
+ // claimInbox renamed the watched inbox dir away and created a fresh one,
289
+ // so the fs.watch is now on a detached directory. Re-arm on the live
290
+ // inbox (the 3s poll covers anything deposited in between).
291
+ try {
292
+ unwatch?.();
293
+ unwatch = watchInbox(root, me, checkInbox);
294
+ }
295
+ catch {
296
+ // watch failure is non-fatal; the old poll timer died with the old watch
297
+ }
233
298
  }
234
299
  function myFrom() {
235
300
  const s = self;
@@ -363,6 +428,29 @@ export default function relay(pi) {
363
428
  writeRecord(root, self);
364
429
  const resumable = collectResumableSessionIds();
365
430
  sweep(root, Date.now(), (id) => resumable.has(id));
431
+ // Seed redelivery dedupe from the transcript: a letter delivered by a
432
+ // previous process but never acked (crash between delivery and ack)
433
+ // will be recovered below and must not be handed to the model twice.
434
+ for (const entry of ctx.sessionManager.getEntries()) {
435
+ if (entry.type !== 'custom_message' || entry.customType !== DELIVERY_TYPE)
436
+ continue;
437
+ const id = entry.details?.id;
438
+ if (typeof id === 'string')
439
+ noteDelivered(id);
440
+ }
441
+ // Recover letters stranded in durable claims by a crash mid-delivery:
442
+ // return them to the live inbox so the normal watch/poll retries them.
443
+ // Runs before the watch is armed so requeues land in the watched inbox.
444
+ for (const stranded of recoverInboxClaims(root, self.addr)) {
445
+ for (const fileToken of stranded.fileTokens) {
446
+ try {
447
+ requeueClaimedLetter(root, self.addr, stranded.claimToken, fileToken);
448
+ }
449
+ catch {
450
+ // stays claimed; the next session_start retries recovery
451
+ }
452
+ }
453
+ }
366
454
  unwatch?.();
367
455
  unwatch = watchInbox(root, self.addr, checkInbox);
368
456
  heartbeat = setInterval(() => writeSelf({}), 15_000);
@@ -690,16 +778,7 @@ export default function relay(pi) {
690
778
  }
691
779
  /** Find an outgoing ask id by exact id or unique prefix (for cancel ergonomics). */
692
780
  function resolveOutAskId(root, addr, idOrPrefix) {
693
- let names;
694
- try {
695
- names = fs.readdirSync(path.join(root, `${addr}.asks`));
696
- }
697
- catch {
698
- return undefined;
699
- }
700
- const ids = names
701
- .filter((f) => f.startsWith('out-') && f.endsWith('.json'))
702
- .map((f) => f.slice('out-'.length, -'.json'.length));
781
+ const ids = outgoingAskIds(root, addr);
703
782
  if (ids.includes(idOrPrefix))
704
783
  return idOrPrefix;
705
784
  const matches = ids.filter((id) => id.startsWith(idOrPrefix));
package/dist/mailbox.d.ts CHANGED
@@ -4,11 +4,13 @@
4
4
  * Guarantees (each pinned by a test):
5
5
  * - A reader never sees half a letter: writers rename into place; readers
6
6
  * only look at `*.json`.
7
- * - Nothing is delivered twice: a letter is unlinked as it is read, BEFORE
8
- * the caller handles it.
7
+ * - A letter is deleted only once consumed: drain unlinks as it reads, and
8
+ * the durable claim flow deletes only on explicit ack — a crashed or
9
+ * failed delivery leaves the letter recoverable (recoverInboxClaims) or
10
+ * requeueable.
9
11
  * - A corrupt letter is discarded on read, so it cannot poison every drain.
10
12
  * - Consumption is the receipt: the sender learns "delivered" only when the
11
- * letter actually disappeared from the target's inbox.
13
+ * letter actually disappeared from the target's inbox and durable claims.
12
14
  * - Every deposit and every delivery appends one append-only audit line —
13
15
  * drain-as-receipt must not destroy evidence. The log never holds a full
14
16
  * body, only a short preview.
@@ -27,13 +29,17 @@ export interface Letter {
27
29
  ts: number;
28
30
  }
29
31
  export declare const MAX_BODY_CHARS: number;
30
- /** Atomic deposit: write to a tmp sibling, then rename into place. */
32
+ /** Atomic deposit: write to a random exclusive tmp sibling, then rename into place. */
31
33
  export declare function deposit(root: string, toAddr: string, letter: Letter): void;
32
34
  /**
33
35
  * Take every letter in the inbox, oldest first. Each letter is unlinked as
34
36
  * it is read — before parsing — so it can never be delivered twice. The
35
- * tradeoff: a drained letter that is never delivered would be lost, so the
36
- * caller must re-deposit any letter it fails to deliver (checkInbox does).
37
+ * tradeoff: a drained letter that is never delivered is lost, so callers
38
+ * that can fail delivery should prefer the durable claim flow (claimInbox →
39
+ * readClaimedLetter → ackClaimedLetter/requeueClaimedLetter), which only
40
+ * deletes a letter once it has actually been handed off. The pi entry point
41
+ * (index.ts checkInbox) uses claims; drain remains for core consumers that
42
+ * handle drained letters synchronously.
37
43
  */
38
44
  export declare function drain(root: string, addr: string): Letter[];
39
45
  export declare function unreadCount(root: string, addr: string): number;
@@ -44,10 +50,24 @@ export declare function unreadCount(root: string, addr: string): number;
44
50
  * a process alive.
45
51
  */
46
52
  export declare function watchInbox(root: string, addr: string, onMail: () => void): () => void;
53
+ export interface InboxClaim {
54
+ claimToken: string;
55
+ fileTokens: string[];
56
+ }
57
+ /** Atomically detach the current non-empty inbox and return its durable tokens. */
58
+ export declare function claimInbox(root: string, addr: string): InboxClaim | null;
59
+ /** Enumerate durable claims left by this or a crashed consumer. */
60
+ export declare function recoverInboxClaims(root: string, addr: string): InboxClaim[];
61
+ /** Read and validate one claimed letter without exposing or following a path. */
62
+ export declare function readClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): Letter | null;
63
+ /** Permanently acknowledge/delete one claimed letter. Idempotent. */
64
+ export declare function ackClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean;
65
+ /** Atomically return one claimed letter to the current inbox. */
66
+ export declare function requeueClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean;
47
67
  /**
48
68
  * Consumption receipt: after depositing to a LIVE target, wait briefly for
49
- * the letter to vanish. 'delivered' means the receiver's drainer took it —
50
- * stronger than any transport ack.
69
+ * the exact letter to vanish from both the live inbox and durable claims.
70
+ * 'delivered' means the receiver acknowledged it, not merely claimed it.
51
71
  */
52
72
  export declare function awaitReceipt(root: string, toAddr: string, letter: Letter, timeoutMs?: number): Promise<'delivered' | 'queued'>;
53
73
  export interface OutAsk {
@@ -82,3 +102,5 @@ export declare function readAudit(root: string, limit?: number): AuditRecord[];
82
102
  export declare function resolveAskByRef(root: string, addr: string, replyTo: string): Letter | null;
83
103
  /** Asks we have received and not yet answered, oldest first. */
84
104
  export declare function pendingAsks(root: string, addr: string): Letter[];
105
+ /** Outgoing ask ids, used by the Pi entry point for prefix resolution. */
106
+ export declare function outgoingAskIds(root: string, addr: string): string[];