@nicknisi/pi-relay 0.3.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 +1 -1
- package/dist/index.js +104 -16
- package/dist/mailbox.d.ts +11 -5
- package/dist/mailbox.js +11 -5
- package/dist/relay.test.js +29 -1
- package/index.ts +99 -16
- package/mailbox.ts +11 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ main moved; rebase before you push.
|
|
|
51
51
|
|
|
52
52
|
- **A mailbox outlives the process.** The address is a hash of the working directory and pi's session id, so a session resumed with `pi -c` answers to the same address. Mail sent to a closed session waits on disk and is read when it resumes — the common case when you're opening and closing terminals all day.
|
|
53
53
|
- **The queue is inspectable.** Diagnosing delivery is `ls`, not instrumenting a transport.
|
|
54
|
-
- **
|
|
54
|
+
- **Delivery is the receipt.** The receiver detaches its inbox into a durable claim and deletes each exact full-message-id letter only after the session has accepted it — a failed delivery is requeued for retry, and a crash mid-delivery leaves the letter recoverable on the next start (redeliveries are deduped by message id, seeded from the transcript). The sender therefore learns _delivered_ vs _queued_ honestly: a claimed-but-unacknowledged letter still reads _queued_, never a false receipt.
|
|
55
55
|
|
|
56
56
|
## Semantics
|
|
57
57
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
233
|
+
claim = claimInbox(root, me);
|
|
208
234
|
}
|
|
209
235
|
catch {
|
|
210
236
|
return;
|
|
211
237
|
}
|
|
212
|
-
|
|
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
|
|
270
|
+
// a malformed delivery never blocks the rest of the claim
|
|
271
|
+
}
|
|
272
|
+
if (accepted) {
|
|
273
|
+
noteDelivered(letter.id);
|
|
274
|
+
ack(fileToken);
|
|
219
275
|
}
|
|
220
|
-
|
|
276
|
+
else {
|
|
221
277
|
lastDeliveryFailureAt = Date.now();
|
|
222
|
-
// Not handed to the session —
|
|
223
|
-
//
|
|
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
|
-
|
|
281
|
+
requeueClaimedLetter(root, me, claimToken, fileToken);
|
|
227
282
|
}
|
|
228
283
|
catch {
|
|
229
|
-
//
|
|
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);
|
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
|
-
* -
|
|
8
|
-
* the
|
|
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.
|
|
@@ -32,8 +34,12 @@ export declare function deposit(root: string, toAddr: string, letter: Letter): v
|
|
|
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
|
|
36
|
-
*
|
|
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;
|
package/dist/mailbox.js
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
|
-
* -
|
|
8
|
-
* the
|
|
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.
|
|
@@ -80,8 +82,12 @@ export function deposit(root, toAddr, letter) {
|
|
|
80
82
|
/**
|
|
81
83
|
* Take every letter in the inbox, oldest first. Each letter is unlinked as
|
|
82
84
|
* it is read — before parsing — so it can never be delivered twice. The
|
|
83
|
-
* tradeoff: a drained letter that is never delivered
|
|
84
|
-
*
|
|
85
|
+
* tradeoff: a drained letter that is never delivered is lost, so callers
|
|
86
|
+
* that can fail delivery should prefer the durable claim flow (claimInbox →
|
|
87
|
+
* readClaimedLetter → ackClaimedLetter/requeueClaimedLetter), which only
|
|
88
|
+
* deletes a letter once it has actually been handed off. The pi entry point
|
|
89
|
+
* (index.ts checkInbox) uses claims; drain remains for core consumers that
|
|
90
|
+
* handle drained letters synchronously.
|
|
85
91
|
*/
|
|
86
92
|
export function drain(root, addr) {
|
|
87
93
|
assertPathSegment(addr, 'relay address');
|
package/dist/relay.test.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as os from 'node:os';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
10
10
|
import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown, } from './format.js';
|
|
11
|
-
import { appendAudit, auditLogPath, awaitReceipt, claimInbox, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readIncomingAsk, readOutgoingAsk, recoverInboxClaims, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
11
|
+
import { ackClaimedLetter, appendAudit, auditLogPath, awaitReceipt, claimInbox, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readClaimedLetter, readIncomingAsk, readOutgoingAsk, recoverInboxClaims, requeueClaimedLetter, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
|
|
12
12
|
import { BACKLOG_CAP, OutboundPolicy, inboundAccepts } from './policy.js';
|
|
13
13
|
import { claimAlias, clearAlias, deriveAddr, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
@@ -191,6 +191,34 @@ describe('mailbox', () => {
|
|
|
191
191
|
deposit(root, addr, l2);
|
|
192
192
|
expect(await awaitReceipt(root, addr, l2, 400)).toBe('queued');
|
|
193
193
|
});
|
|
194
|
+
it("a letter reads 'queued' to the sender until actually delivered (claim → fail → requeue → ack)", async () => {
|
|
195
|
+
// The pi entry point's delivery flow: a claimed letter stays 'queued' to
|
|
196
|
+
// the sender, a failed delivery returns it to the inbox for retry, and
|
|
197
|
+
// the receipt flips to 'delivered' only after acceptance (ack). A crash
|
|
198
|
+
// mid-claim leaves the letter recoverable instead of silently lost.
|
|
199
|
+
const root = tmpRoot();
|
|
200
|
+
const addr = 'inbox0000004';
|
|
201
|
+
const l1 = letter({ id: 'rcpt-3', ts: Date.now() });
|
|
202
|
+
deposit(root, addr, l1);
|
|
203
|
+
const claim = claimInbox(root, addr);
|
|
204
|
+
expect(claim.fileTokens).toHaveLength(1);
|
|
205
|
+
// Claimed but not yet delivered: the sender must NOT see 'delivered'.
|
|
206
|
+
expect(await awaitReceipt(root, addr, l1, 400)).toBe('queued');
|
|
207
|
+
expect(readClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])?.id).toBe(l1.id);
|
|
208
|
+
// Delivery failure: requeue returns the letter to the live inbox, and a
|
|
209
|
+
// later claim picks it up again.
|
|
210
|
+
expect(requeueClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(true);
|
|
211
|
+
expect(unreadCount(root, addr)).toBe(1);
|
|
212
|
+
expect(recoverInboxClaims(root, addr)).toEqual([]);
|
|
213
|
+
const retry = claimInbox(root, addr);
|
|
214
|
+
expect(retry.fileTokens).toEqual(claim.fileTokens);
|
|
215
|
+
// Crash after delivery but before ack: the letter is recoverable.
|
|
216
|
+
expect(recoverInboxClaims(root, addr)).toEqual([retry]);
|
|
217
|
+
// Acceptance: only the ack deletes the letter and flips the receipt.
|
|
218
|
+
expect(ackClaimedLetter(root, addr, retry.claimToken, retry.fileTokens[0])).toBe(true);
|
|
219
|
+
expect(recoverInboxClaims(root, addr)).toEqual([]);
|
|
220
|
+
expect(await awaitReceipt(root, addr, l1, 400)).toBe('delivered');
|
|
221
|
+
});
|
|
194
222
|
it('watch fires on deposit (poll fallback covers missed events)', async () => {
|
|
195
223
|
const root = tmpRoot();
|
|
196
224
|
const addr = 'inbox0000004';
|
package/index.ts
CHANGED
|
@@ -29,16 +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,
|
|
37
38
|
outgoingAskIds,
|
|
38
39
|
pendingAsks,
|
|
39
40
|
previewBody,
|
|
40
41
|
readAudit,
|
|
42
|
+
readClaimedLetter,
|
|
41
43
|
readOutgoingAsk,
|
|
44
|
+
recoverInboxClaims,
|
|
45
|
+
requeueClaimedLetter,
|
|
42
46
|
resolveAskByRef,
|
|
43
47
|
trackIncomingAsk,
|
|
44
48
|
trackOutgoingAsk,
|
|
@@ -169,10 +173,22 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
169
173
|
let unwatch: (() => void) | undefined;
|
|
170
174
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
171
175
|
const askWaiters = new Map<string, (outcome: AskOutcome) => void>();
|
|
172
|
-
// Backoff while the session can't accept mail: a
|
|
176
|
+
// Backoff while the session can't accept mail: a requeued letter
|
|
173
177
|
// re-fires the watcher, and without this a failing sendMessage would spin
|
|
174
|
-
//
|
|
178
|
+
// claim→fail→requeue at watch speed.
|
|
175
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
|
+
};
|
|
176
192
|
// Presence watch: addr → last observed presence. A poller surfaces
|
|
177
193
|
// transitions as a notification message (no full turn wake).
|
|
178
194
|
const watched = new Map<string, Presence>();
|
|
@@ -192,7 +208,13 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
192
208
|
function deliver(letter: Letter): boolean {
|
|
193
209
|
// Audited after the delivery attempts below: a 'deliver' record means the
|
|
194
210
|
// session actually accepted the letter; refusal records 'deliver-failed'
|
|
195
|
-
// (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.
|
|
196
218
|
const auditDelivery = (event: 'deliver' | 'deliver-failed') =>
|
|
197
219
|
appendAudit(root, {
|
|
198
220
|
ts: Date.now(),
|
|
@@ -255,7 +277,7 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
255
277
|
return true;
|
|
256
278
|
} catch {
|
|
257
279
|
// session is shutting down or otherwise undeliverable — the caller
|
|
258
|
-
//
|
|
280
|
+
// requeues the letter so a future drain retries
|
|
259
281
|
auditDelivery('deliver-failed');
|
|
260
282
|
return false;
|
|
261
283
|
}
|
|
@@ -265,35 +287,76 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
265
287
|
|
|
266
288
|
function checkInbox(): void {
|
|
267
289
|
if (!self) return;
|
|
290
|
+
const me = self.addr;
|
|
268
291
|
if (Date.now() - lastDeliveryFailureAt < 5000) return;
|
|
269
|
-
// Refuse mode: never
|
|
292
|
+
// Refuse mode: never claim. Letters stay on disk (receipts honestly read
|
|
270
293
|
// 'queued') instead of being silently consumed and dropped.
|
|
271
294
|
if (!inboundAccepts()) return;
|
|
272
|
-
|
|
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>;
|
|
273
302
|
try {
|
|
274
|
-
|
|
303
|
+
claim = claimInbox(root, me);
|
|
275
304
|
} catch {
|
|
276
305
|
return;
|
|
277
306
|
}
|
|
278
|
-
|
|
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
|
+
}
|
|
279
331
|
let accepted = false;
|
|
280
332
|
try {
|
|
281
333
|
accepted = deliver(letter);
|
|
282
334
|
} catch {
|
|
283
|
-
// a malformed delivery never blocks the rest of the
|
|
335
|
+
// a malformed delivery never blocks the rest of the claim
|
|
284
336
|
}
|
|
285
|
-
if (
|
|
337
|
+
if (accepted) {
|
|
338
|
+
noteDelivered(letter.id);
|
|
339
|
+
ack(fileToken);
|
|
340
|
+
} else {
|
|
286
341
|
lastDeliveryFailureAt = Date.now();
|
|
287
|
-
// Not handed to the session —
|
|
288
|
-
//
|
|
289
|
-
// 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.
|
|
290
344
|
try {
|
|
291
|
-
|
|
345
|
+
requeueClaimedLetter(root, me, claimToken, fileToken);
|
|
292
346
|
} catch {
|
|
293
|
-
//
|
|
347
|
+
// stays claimed; the next session_start recovers it
|
|
294
348
|
}
|
|
295
349
|
}
|
|
296
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
|
+
}
|
|
297
360
|
}
|
|
298
361
|
|
|
299
362
|
function myFrom(): { addr: string; name: string; cwd: string } {
|
|
@@ -432,6 +495,26 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
432
495
|
writeRecord(root, self);
|
|
433
496
|
const resumable = collectResumableSessionIds();
|
|
434
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
|
+
}
|
|
435
518
|
unwatch?.();
|
|
436
519
|
unwatch = watchInbox(root, self.addr, checkInbox);
|
|
437
520
|
heartbeat = setInterval(() => writeSelf({}), 15_000);
|
package/mailbox.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
|
-
* -
|
|
8
|
-
* the
|
|
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.
|
|
@@ -103,8 +105,12 @@ export function deposit(root: string, toAddr: string, letter: Letter): void {
|
|
|
103
105
|
/**
|
|
104
106
|
* Take every letter in the inbox, oldest first. Each letter is unlinked as
|
|
105
107
|
* it is read — before parsing — so it can never be delivered twice. The
|
|
106
|
-
* tradeoff: a drained letter that is never delivered
|
|
107
|
-
*
|
|
108
|
+
* tradeoff: a drained letter that is never delivered is lost, so callers
|
|
109
|
+
* that can fail delivery should prefer the durable claim flow (claimInbox →
|
|
110
|
+
* readClaimedLetter → ackClaimedLetter/requeueClaimedLetter), which only
|
|
111
|
+
* deletes a letter once it has actually been handed off. The pi entry point
|
|
112
|
+
* (index.ts checkInbox) uses claims; drain remains for core consumers that
|
|
113
|
+
* handle drained letters synchronously.
|
|
108
114
|
*/
|
|
109
115
|
export function drain(root: string, addr: string): Letter[] {
|
|
110
116
|
assertPathSegment(addr, 'relay address');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nicknisi/pi-relay",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Brokerless session-to-session messaging for pi — a file mailbox that outlives the process, with ask/reply coordination. Formerly @nicknisi/pi-intercom.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|