@tinycloud/courier 0.2.0-beta.38 → 0.2.0-beta.40
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 +51 -13
- package/dist/a2a.d.ts +1 -1
- package/dist/a2a.js +1 -1
- package/dist/a2a.js.map +1 -1
- package/dist/notify.d.ts +6 -2
- package/dist/notify.js +11 -7
- package/dist/notify.js.map +1 -1
- package/dist/pause.d.ts +8 -2
- package/dist/pause.js +27 -15
- package/dist/pause.js.map +1 -1
- package/dist/watch-cmd.js +1 -1
- package/dist/watch-cmd.js.map +1 -1
- package/dist/watch.d.ts +73 -9
- package/dist/watch.js +473 -70
- package/dist/watch.js.map +1 -1
- package/package.json +3 -3
- package/skills/courier/SKILL.md +26 -1
package/dist/watch.js
CHANGED
|
@@ -6,7 +6,8 @@ import { isRecord, messageView, oneLine, taskView } from './a2a.js';
|
|
|
6
6
|
import { commitReceipts, selectUndelivered } from './inbox.js';
|
|
7
7
|
import { del, getJson, listAll, putCreate, putMatch, putOverwrite } from './store.js';
|
|
8
8
|
import { appendNotice } from './notify.js';
|
|
9
|
-
import { writePauseMarker } from './pause.js';
|
|
9
|
+
import { nextNoticeId, writePauseMarker } from './pause.js';
|
|
10
|
+
import { HANDLE, ULID } from './request.js';
|
|
10
11
|
/** Spec §8. inform and receipts never wake; a message with no valid intent is delivered but never wakes. */
|
|
11
12
|
const WAKE_INTENTS = new Set(['request', 'ask', 'answer', 'update']);
|
|
12
13
|
const TERMINAL_TASK_STATES = new Set([
|
|
@@ -27,16 +28,35 @@ const CLAIM_SKIP_MS = 30_000;
|
|
|
27
28
|
const DAY_MS = 24 * 3600 * 1000;
|
|
28
29
|
/** A peer can make this side's watcher notify its human at most this often per hour, and the outbox never grows past `MAX_OUTBOX`. */
|
|
29
30
|
const NOTICES_PER_PEER_PER_HOUR = 10;
|
|
31
|
+
const NOTICES_PER_HOUR = 10;
|
|
32
|
+
const NOTICE_RESERVE_PER_PEER = 3;
|
|
33
|
+
const MAX_COALESCED_PAUSES = 999999;
|
|
34
|
+
const MAX_PEER_CLOCK_SKEW_MS = 10 * 60_000;
|
|
30
35
|
const MAX_OUTBOX = 50;
|
|
31
36
|
const MAX_OUTBOX_TRIES = 8;
|
|
37
|
+
const MAX_NOTICE_ATTEMPTS = 3;
|
|
32
38
|
const MAX_HELD_PER_THREAD = 100;
|
|
33
39
|
/** The subject is peer-controlled text that reaches state, prompts and environments: one line, control characters removed, capped. */
|
|
34
40
|
const MAX_SUBJECT = 200;
|
|
35
41
|
/** Thread and message ids come from key names the peer controls; they reach prompts and argv, so only ULID-like ids pass. */
|
|
36
42
|
const SAFE_ID = /^[0-9A-Za-z_-]{1,64}$/;
|
|
43
|
+
const emptyNoticeRetry = () => ({ dispatchArrivals: '------', tries: 0, nextAttemptAt: 0, logged: false });
|
|
44
|
+
const ZERO_ULID = '0'.repeat(26);
|
|
45
|
+
const EPOCH = new Date(0).toISOString();
|
|
46
|
+
const needsKind = (kind) => kind === 'new-message' ? 'message' : kind === 'new-approval' ? 'approval' : undefined;
|
|
47
|
+
const emptyNeedsSlot = () => ({ threadId: ZERO_ULID, eventId: ZERO_ULID, count: '000000',
|
|
48
|
+
firstSeenAt: EPOCH, arrivalFirstSeenAt: EPOCH, ...emptyNoticeRetry() });
|
|
49
|
+
const needsKey = (peer, kind) => `needs:${peer}:${kind}`;
|
|
50
|
+
const emptyEpisode = () => ({ kind: '', eventId: ZERO_ULID, repeats: '000000', ...emptyNoticeRetry() });
|
|
51
|
+
const incrementCount = (count) => String(Math.min(MAX_COALESCED_PAUSES, Number(count) + 1)).padStart(6, '0');
|
|
52
|
+
const episodeKey = (key, slot) => `notice:${key}:${slot}`;
|
|
53
|
+
function threadNoticeState() {
|
|
54
|
+
return { ourPaused: false, peerPaused: false, lastPeerNoticeId: '0'.repeat(26), lastNoticeSentAt: new Date(0).toISOString(),
|
|
55
|
+
ourMarkerId: '', episodes: [emptyEpisode(), emptyEpisode()], informPending: false, informText: '' };
|
|
56
|
+
}
|
|
37
57
|
export const WATCH_DEFAULTS = { debounceMs: 30_000, pollMs: 60_000, ticketSeconds: 25, wakeBudget: 12 };
|
|
38
58
|
export function emptyWatchState() {
|
|
39
|
-
return { v: 1, pending: {}, woken: [], approvalsSeen: [], wakes: {}, paused: [], outbox: [], held: {},
|
|
59
|
+
return { v: 1, pending: {}, woken: [], approvalsSeen: [], wakes: {}, paused: [], wakeRetries: {}, approvalRetries: {}, outbox: [], held: {}, threadNotices: {}, peerNotices: {}, noticeGlobalLog: [], noticeLog: {} };
|
|
40
60
|
}
|
|
41
61
|
/** Wake time: `debounce` after the latest message, but never later than twice `debounce` after the first. */
|
|
42
62
|
export function wakeDueAt(pending, debounceMs) {
|
|
@@ -87,7 +107,6 @@ export class Watcher {
|
|
|
87
107
|
pausedKeys = new Set();
|
|
88
108
|
/** Serialises everything that reads or writes pause markers, so a stale listing cannot drop a pause just written. */
|
|
89
109
|
pausedLock = Promise.resolve();
|
|
90
|
-
wakeFailures = new Map();
|
|
91
110
|
outboxBusy = new Set();
|
|
92
111
|
approvalsAgain = false;
|
|
93
112
|
/** Unique to this run: a claim with this id is this process's own. */
|
|
@@ -124,7 +143,67 @@ export class Watcher {
|
|
|
124
143
|
this.state = isRecord(value) && value.v === 1
|
|
125
144
|
? { ...emptyWatchState(), ...value }
|
|
126
145
|
: emptyWatchState();
|
|
127
|
-
|
|
146
|
+
// Discard the old per-peer-generation map; it was sized by untrusted ids.
|
|
147
|
+
delete this.state.peerPausedDay;
|
|
148
|
+
for (const key of [...Object.keys(this.state.wakes), ...this.state.paused]) {
|
|
149
|
+
const [peer, threadId] = key.split('/');
|
|
150
|
+
if (peer && threadId && SAFE_ID.test(peer) && SAFE_ID.test(threadId)) {
|
|
151
|
+
const record = this.state.threadNotices[key] ??= threadNoticeState();
|
|
152
|
+
record.ourPaused = this.state.paused.includes(key);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
this.syncNoticePeers();
|
|
156
|
+
for (const record of Object.values(this.state.peerNotices))
|
|
157
|
+
for (const slot of Object.values(record))
|
|
158
|
+
slot.dispatchArrivals = '------';
|
|
159
|
+
// Upgrade the previous single-pending record while retaining its attempt/deadline metadata.
|
|
160
|
+
for (const [key, record] of Object.entries(this.state.threadNotices)) {
|
|
161
|
+
const old = record;
|
|
162
|
+
if (!record.episodes) {
|
|
163
|
+
record.episodes = [emptyEpisode(), emptyEpisode()];
|
|
164
|
+
for (const item of this.state.outbox) {
|
|
165
|
+
if (item.kind !== 'notify' || `${item.notice.peer}/${item.notice.threadId}` !== key
|
|
166
|
+
|| (item.notice.kind !== 'paused' && item.notice.kind !== 'peer-paused')
|
|
167
|
+
|| item.id !== `${item.notice.kind}:${key}` || !item.notice.eventId)
|
|
168
|
+
continue;
|
|
169
|
+
this.queueEpisode(record, item.notice.kind, item.notice.eventId);
|
|
170
|
+
}
|
|
171
|
+
if (old.ourNoticePending)
|
|
172
|
+
this.queueEpisode(record, 'paused', record.ourMarkerId);
|
|
173
|
+
if (old.peerNoticePending && old.peerNoticeId)
|
|
174
|
+
this.queueEpisode(record, 'peer-paused', old.peerNoticeId);
|
|
175
|
+
}
|
|
176
|
+
record.episodes = record.episodes.map(episode => ({ ...emptyEpisode(), ...episode, dispatchArrivals: '------' }));
|
|
177
|
+
delete old.ourNoticePending;
|
|
178
|
+
delete old.peerNoticePending;
|
|
179
|
+
delete old.peerNoticeId;
|
|
180
|
+
}
|
|
181
|
+
this.state.outbox = this.state.outbox.filter(item => {
|
|
182
|
+
if (item.kind === 'inform')
|
|
183
|
+
return true;
|
|
184
|
+
const kind = needsKind(item.notice.kind);
|
|
185
|
+
if (kind) {
|
|
186
|
+
const slot = this.noticeSlot(item);
|
|
187
|
+
if (!slot || item.id !== needsKey(item.notice.peer, kind))
|
|
188
|
+
return false;
|
|
189
|
+
this.rememberNotice(item);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
const key = `${item.notice.peer}/${item.notice.threadId}`;
|
|
193
|
+
const record = this.state.threadNotices[key];
|
|
194
|
+
const slot = record?.episodes.findIndex(episode => episode.kind === item.notice.kind && episode.eventId === item.notice.eventId) ?? -1;
|
|
195
|
+
if (slot < 0 || (item.id !== `${item.notice.kind}:${key}` && item.id !== episodeKey(key, slot)))
|
|
196
|
+
return false;
|
|
197
|
+
item.id = episodeKey(key, slot);
|
|
198
|
+
item.notice.repeats ??= record.episodes[slot].repeats;
|
|
199
|
+
this.rememberNotice(item);
|
|
200
|
+
return true;
|
|
201
|
+
});
|
|
202
|
+
for (const item of [...this.state.outbox]) {
|
|
203
|
+
if ((item.tries ?? 0) >= (item.kind === 'notify' ? MAX_NOTICE_ATTEMPTS : MAX_OUTBOX_TRIES))
|
|
204
|
+
this.finishOutbox(item);
|
|
205
|
+
}
|
|
206
|
+
this.state.outbox = this.state.outbox.slice(-MAX_OUTBOX);
|
|
128
207
|
}
|
|
129
208
|
snapshot() {
|
|
130
209
|
return structuredClone(this.state);
|
|
@@ -288,7 +367,7 @@ export class Watcher {
|
|
|
288
367
|
// Pause markers are read with every listing, so a `courier resume` or `courier pause` takes effect on the very
|
|
289
368
|
// next message instead of at the next poll (a message delivered while the watcher still thought the thread was
|
|
290
369
|
// paused would never wake anyone).
|
|
291
|
-
const [keys] = await Promise.all([listAll(peerKv, `${PREFIX}/to/${mailbox}/threads/`), this.syncPaused()]);
|
|
370
|
+
const [keys] = await Promise.all([listAll(peerKv, `${PREFIX}/to/${mailbox}/threads/`), this.syncPaused(), this.syncKnownThreads(peer)]);
|
|
292
371
|
const receipts = (await getJson(selfKv, k.receipts(peer.handle)))?.value ?? { v: 1, threads: {} };
|
|
293
372
|
receipts.threads ??= {};
|
|
294
373
|
const delivered = [];
|
|
@@ -334,9 +413,11 @@ export class Watcher {
|
|
|
334
413
|
}
|
|
335
414
|
await this.mutate(() => {
|
|
336
415
|
if (this.deps.notifyOnly) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
416
|
+
this.syncNoticePeers();
|
|
417
|
+
for (const item of [...delivered].sort((a, b) => a.messageId.localeCompare(b.messageId))) {
|
|
418
|
+
if (item.intent && item.notice !== 'thread-paused' && item.notice !== 'thread-resumed') {
|
|
419
|
+
this.queueNeeds(peer.handle, 'message', item.threadId);
|
|
420
|
+
}
|
|
340
421
|
}
|
|
341
422
|
}
|
|
342
423
|
for (const item of delivered) {
|
|
@@ -356,23 +437,53 @@ export class Watcher {
|
|
|
356
437
|
entry.messages.push({ messageId: item.messageId, intent: item.intent, sentAt: sentAtOf(item.messageId) });
|
|
357
438
|
entry.lastSeenAt = at;
|
|
358
439
|
}
|
|
359
|
-
//
|
|
360
|
-
// Only a paired peer's `inform` carrying the notice counts, once per thread per UTC day, at most
|
|
361
|
-
// NOTICES_PER_PEER_PER_HOUR per peer: a peer cannot turn this into a stream of notifications or agent runs.
|
|
440
|
+
// Only our local listing establishes a known thread. The peer controls its own state, not allocation.
|
|
362
441
|
for (const item of delivered) {
|
|
363
|
-
if (item.notice !== 'thread-paused'
|
|
442
|
+
if (item.intent !== 'inform' || (item.notice !== 'thread-paused' && item.notice !== 'thread-resumed'))
|
|
364
443
|
continue;
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
444
|
+
const record = this.state.threadNotices[`${peer.handle}/${item.threadId}`];
|
|
445
|
+
if (!record || !ULID.test(item.messageId) || item.messageId <= record.lastPeerNoticeId)
|
|
446
|
+
continue;
|
|
447
|
+
let sentAt;
|
|
448
|
+
try {
|
|
449
|
+
sentAt = decodeTime(item.messageId);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
continue;
|
|
453
|
+
} // ULID's high bits must fit 128 bits
|
|
454
|
+
if (sentAt > at + MAX_PEER_CLOCK_SKEW_MS)
|
|
455
|
+
continue;
|
|
456
|
+
record.lastPeerNoticeId = item.messageId;
|
|
457
|
+
const paused = item.notice === 'thread-paused';
|
|
458
|
+
if (paused && !record.peerPaused)
|
|
459
|
+
this.queueEpisode(record, 'peer-paused', item.messageId);
|
|
460
|
+
record.peerPaused = paused;
|
|
371
461
|
}
|
|
462
|
+
this.fillOutbox();
|
|
372
463
|
});
|
|
373
464
|
await commitReceipts(selfKv, new Map([[peer.handle, delivered.map(({ threadId, messageId }) => ({ threadId, messageId }))]]));
|
|
374
465
|
await this.mirrorThreads(peer, peerKv, keys);
|
|
375
466
|
}
|
|
467
|
+
/** Only locally authored meta/messages/tasks, or a local wake/pause witness, establish a known thread. */
|
|
468
|
+
async syncKnownThreads(peer) {
|
|
469
|
+
const prefix = `${PREFIX}/to/${peer.handle}/threads/`;
|
|
470
|
+
const keys = await listAll(this.deps.selfKv, prefix);
|
|
471
|
+
const known = new Set();
|
|
472
|
+
for (const path of keys) {
|
|
473
|
+
const match = /^([^/]+)\/(?:meta\.json|(?:msg|task)\/[^/]+\.json)$/.exec(path.slice(prefix.length));
|
|
474
|
+
if (match && SAFE_ID.test(match[1]))
|
|
475
|
+
known.add(`${peer.handle}/${match[1]}`);
|
|
476
|
+
}
|
|
477
|
+
for (const key of Object.keys(this.state.wakes))
|
|
478
|
+
if (key.startsWith(`${peer.handle}/`))
|
|
479
|
+
known.add(key);
|
|
480
|
+
const missing = [...known].filter(key => !this.state.threadNotices[key]);
|
|
481
|
+
if (missing.length)
|
|
482
|
+
await this.mutate(() => {
|
|
483
|
+
for (const key of missing)
|
|
484
|
+
this.state.threadNotices[key] ??= threadNoticeState();
|
|
485
|
+
});
|
|
486
|
+
}
|
|
376
487
|
async mirrorCreate(key, value) {
|
|
377
488
|
try {
|
|
378
489
|
await putCreate(this.deps.selfKv, key, value);
|
|
@@ -481,9 +592,11 @@ export class Watcher {
|
|
|
481
592
|
const at = this.now();
|
|
482
593
|
await this.mutate(() => {
|
|
483
594
|
this.state.approvalsSeen = [...new Set([...this.state.approvalsSeen, ...handled])].sort();
|
|
595
|
+
if (this.deps.notifyOnly)
|
|
596
|
+
this.syncNoticePeers();
|
|
484
597
|
for (const { approval, peer, threadId, subject } of wakes) {
|
|
485
598
|
if (this.deps.notifyOnly) {
|
|
486
|
-
this.
|
|
599
|
+
this.queueNeeds(peer, 'approval', threadId);
|
|
487
600
|
continue;
|
|
488
601
|
}
|
|
489
602
|
const key = `${peer}/${threadId}`;
|
|
@@ -493,6 +606,8 @@ export class Watcher {
|
|
|
493
606
|
entry.approvals.push(approval);
|
|
494
607
|
entry.lastSeenAt = at;
|
|
495
608
|
}
|
|
609
|
+
if (this.deps.notifyOnly)
|
|
610
|
+
this.fillOutbox();
|
|
496
611
|
});
|
|
497
612
|
}
|
|
498
613
|
/** Reads one new approval and its request; `skip` says why it should not wake anyone. */
|
|
@@ -525,16 +640,18 @@ export class Watcher {
|
|
|
525
640
|
if (this.fatalError || this.stopping)
|
|
526
641
|
return;
|
|
527
642
|
if (this.deps.notifyOnly) {
|
|
528
|
-
//
|
|
643
|
+
// Switching to D mode converts the prior local backlog into the same fixed peer slots.
|
|
529
644
|
if (Object.keys(this.state.pending).length)
|
|
530
645
|
await this.mutate(() => {
|
|
646
|
+
this.syncNoticePeers();
|
|
531
647
|
for (const pending of Object.values(this.state.pending)) {
|
|
532
|
-
|
|
533
|
-
this.
|
|
534
|
-
for (const
|
|
535
|
-
this.
|
|
648
|
+
for (const _message of pending.messages)
|
|
649
|
+
this.queueNeeds(pending.peer, 'message', pending.threadId);
|
|
650
|
+
for (const _approval of pending.approvals ?? [])
|
|
651
|
+
this.queueNeeds(pending.peer, 'approval', pending.threadId);
|
|
536
652
|
}
|
|
537
653
|
this.state.pending = {};
|
|
654
|
+
this.fillOutbox();
|
|
538
655
|
});
|
|
539
656
|
await this.runOutbox();
|
|
540
657
|
return;
|
|
@@ -546,7 +663,7 @@ export class Watcher {
|
|
|
546
663
|
continue;
|
|
547
664
|
if (!force && wakeDueAt(pending, this.opts.debounceMs) > at)
|
|
548
665
|
continue;
|
|
549
|
-
if (!force && (this.retryAt.get(key) ?? 0) > at)
|
|
666
|
+
if (!force && !pending.approvals?.length && (this.retryAt.get(key) ?? 0) > at)
|
|
550
667
|
continue;
|
|
551
668
|
this.waking.add(key);
|
|
552
669
|
runs.push(this.wakeThread(key, pending, at).finally(() => this.waking.delete(key)));
|
|
@@ -566,7 +683,7 @@ export class Watcher {
|
|
|
566
683
|
await this.syncPaused();
|
|
567
684
|
// Messages that land while the wake runs stay pending for the next one, so wake exactly this snapshot.
|
|
568
685
|
let messages = [...pending.messages];
|
|
569
|
-
|
|
686
|
+
let approvals = [...(pending.approvals ?? [])];
|
|
570
687
|
// Spec §8: the loop stops here. A paused thread, or one over budget, wakes nobody for messages (approvals are
|
|
571
688
|
// a human decision, not a loop, and always wake).
|
|
572
689
|
if (messages.length) {
|
|
@@ -592,6 +709,13 @@ export class Watcher {
|
|
|
592
709
|
return;
|
|
593
710
|
}
|
|
594
711
|
}
|
|
712
|
+
// Owner decisions have their own retry clock. A failed message adapter cannot delay an approval.
|
|
713
|
+
if ((this.state.wakeRetries[key]?.nextAttemptAt ?? 0) > at)
|
|
714
|
+
messages = [];
|
|
715
|
+
if ((this.state.approvalRetries[key]?.nextAttemptAt ?? 0) > at)
|
|
716
|
+
approvals = [];
|
|
717
|
+
if (!messages.length && !approvals.length)
|
|
718
|
+
return;
|
|
595
719
|
const woken = messages.map((m) => m.messageId);
|
|
596
720
|
const approvedIds = approvals.map((a) => a.approvalId);
|
|
597
721
|
const oldestSentAt = messages[0]?.sentAt ?? '';
|
|
@@ -607,6 +731,7 @@ export class Watcher {
|
|
|
607
731
|
// The attempt is counted against the budget BEFORE the adapter runs, and whether it then succeeds, fails or
|
|
608
732
|
// times out: an adapter that always throws must not be retried forever. (A crash mid-wake counts too.)
|
|
609
733
|
await this.mutate(() => {
|
|
734
|
+
this.state.threadNotices[key] ??= threadNoticeState();
|
|
610
735
|
if (woken.length)
|
|
611
736
|
this.state.wakes[key] = [...this.recentWakes(key), this.now()];
|
|
612
737
|
});
|
|
@@ -632,9 +757,14 @@ export class Watcher {
|
|
|
632
757
|
}
|
|
633
758
|
catch (error) {
|
|
634
759
|
// Failures back off: 1, 2, 4, ... minutes, at most 15.
|
|
635
|
-
|
|
636
|
-
this.
|
|
637
|
-
|
|
760
|
+
let failures = 0;
|
|
761
|
+
await this.mutate(() => {
|
|
762
|
+
for (const retries of [...(woken.length ? [this.state.wakeRetries] : []), ...(approvedIds.length ? [this.state.approvalRetries] : [])]) {
|
|
763
|
+
const count = (retries[key]?.failures ?? 0) + 1;
|
|
764
|
+
retries[key] = { failures: count, nextAttemptAt: this.now() + Math.min(15 * 60_000, WAKE_RETRY_MS * 2 ** (count - 1)) };
|
|
765
|
+
failures = Math.max(failures, count);
|
|
766
|
+
}
|
|
767
|
+
});
|
|
638
768
|
this.deps.log('wake-error', { peer: pending.peer, threadId: pending.threadId, failures, ...errInfo(error) });
|
|
639
769
|
// The attempt failed, so the thread is not being woken: release the claim (a restart must not wait for it).
|
|
640
770
|
await this.releaseClaim(pending.peer, pending.threadId, claimId);
|
|
@@ -649,8 +779,11 @@ export class Watcher {
|
|
|
649
779
|
delete this.state.pending[key];
|
|
650
780
|
}
|
|
651
781
|
this.state.woken = [...this.state.woken, ...woken].slice(-WAKEN_MEMORY);
|
|
782
|
+
if (woken.length)
|
|
783
|
+
delete this.state.wakeRetries[key];
|
|
784
|
+
if (approvedIds.length)
|
|
785
|
+
delete this.state.approvalRetries[key];
|
|
652
786
|
});
|
|
653
|
-
this.wakeFailures.delete(key);
|
|
654
787
|
// Only now, with the wake recorded, is the retry hold-off cleared.
|
|
655
788
|
this.retryAt.delete(key);
|
|
656
789
|
this.deps.log('woke', {
|
|
@@ -662,8 +795,9 @@ export class Watcher {
|
|
|
662
795
|
async pauseThread(key, pending, used) {
|
|
663
796
|
const reason = `wake budget reached (${used} wakes today, UTC; limit ${this.opts.wakeBudget ?? WATCH_DEFAULTS.wakeBudget} per thread per day)`;
|
|
664
797
|
const at = new Date(this.now()).toISOString();
|
|
798
|
+
const pauseId = await nextNoticeId(this.deps.selfKv, pending.peer, pending.threadId, this.now());
|
|
665
799
|
const wrote = await this.exclusive(async () => {
|
|
666
|
-
const created = await writePauseMarker(this.deps.selfKv, { v: 1, peer: pending.peer, threadId: pending.threadId, by: 'budget', at, reason });
|
|
800
|
+
const created = await writePauseMarker(this.deps.selfKv, { v: 1, peer: pending.peer, threadId: pending.threadId, by: 'budget', at, reason, informId: pauseId });
|
|
667
801
|
this.pausedKeys.add(key);
|
|
668
802
|
return created;
|
|
669
803
|
});
|
|
@@ -677,8 +811,10 @@ export class Watcher {
|
|
|
677
811
|
await this.mutate(() => {
|
|
678
812
|
if (!this.state.paused.includes(key))
|
|
679
813
|
this.state.paused.push(key);
|
|
680
|
-
|
|
681
|
-
|
|
814
|
+
const record = this.state.threadNotices[key] ??= threadNoticeState();
|
|
815
|
+
Object.assign(record, { ourPaused: true, ourMarkerId: pauseId, informPending: true, informText: text });
|
|
816
|
+
this.queueEpisode(record, 'paused', pauseId);
|
|
817
|
+
this.fillOutbox();
|
|
682
818
|
});
|
|
683
819
|
this.deps.log('paused', { peer: pending.peer, threadId: pending.threadId, by: 'budget', reason });
|
|
684
820
|
}
|
|
@@ -694,29 +830,220 @@ export class Watcher {
|
|
|
694
830
|
return;
|
|
695
831
|
entry.messages = [...entry.messages, message].slice(-MAX_HELD_PER_THREAD);
|
|
696
832
|
}
|
|
697
|
-
/**
|
|
698
|
-
|
|
699
|
-
if (
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
833
|
+
/** Two episode values per known thread. Additional transitions coalesce, without allocating another slot. */
|
|
834
|
+
queueEpisode(record, kind, eventId) {
|
|
835
|
+
if (!ULID.test(eventId) || record.episodes.some(episode => episode.kind === kind && episode.eventId === eventId))
|
|
836
|
+
return;
|
|
837
|
+
const slot = record.episodes.find(episode => !episode.kind);
|
|
838
|
+
if (slot)
|
|
839
|
+
Object.assign(slot, emptyEpisode(), { kind, eventId });
|
|
840
|
+
else {
|
|
841
|
+
const last = record.episodes[1];
|
|
842
|
+
last.repeats = incrementCount(last.repeats);
|
|
843
|
+
if (last.dispatchArrivals !== '------')
|
|
844
|
+
last.dispatchArrivals = incrementCount(last.dispatchArrivals);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** Only the paired handles in our local configuration allocate needs-you state. */
|
|
848
|
+
syncNoticePeers() {
|
|
849
|
+
const paired = new Set(this.deps.loadPeers().filter(peer => peer.status === 'paired' && HANDLE.test(peer.handle)).map(peer => peer.handle));
|
|
850
|
+
for (const peer of Object.keys(this.state.peerNotices))
|
|
851
|
+
if (!paired.has(peer))
|
|
852
|
+
delete this.state.peerNotices[peer];
|
|
853
|
+
if (this.deps.notifyOnly)
|
|
854
|
+
for (const peer of paired) {
|
|
855
|
+
if (!Object.hasOwn(this.state.peerNotices, peer))
|
|
856
|
+
this.state.peerNotices[peer] = { message: emptyNeedsSlot(), approval: emptyNeedsSlot() };
|
|
711
857
|
}
|
|
712
|
-
|
|
713
|
-
|
|
858
|
+
for (const record of Object.values(this.state.peerNotices))
|
|
859
|
+
for (const kind of ['message', 'approval']) {
|
|
860
|
+
record[kind] = { ...emptyNeedsSlot(), ...record[kind] };
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** Bounded values only: a later delivery updates the same paired-peer slot. Called inside mutate. */
|
|
864
|
+
queueNeeds(peer, kind, threadId) {
|
|
865
|
+
const slot = this.state.peerNotices[peer]?.[kind];
|
|
866
|
+
if (!slot || !ULID.test(threadId))
|
|
867
|
+
return;
|
|
868
|
+
try {
|
|
869
|
+
decodeTime(threadId);
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const at = new Date(this.now()).toISOString();
|
|
875
|
+
if (slot.count === '000000') {
|
|
876
|
+
slot.eventId = ulid(this.now());
|
|
877
|
+
slot.firstSeenAt = at;
|
|
878
|
+
}
|
|
879
|
+
slot.threadId = threadId;
|
|
880
|
+
slot.count = incrementCount(slot.count);
|
|
881
|
+
if (slot.dispatchArrivals !== '------') {
|
|
882
|
+
if (slot.dispatchArrivals === '000000')
|
|
883
|
+
slot.arrivalFirstSeenAt = at;
|
|
884
|
+
slot.dispatchArrivals = incrementCount(slot.dispatchArrivals);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
noticeSlot(item) {
|
|
888
|
+
const kind = needsKind(item.notice.kind);
|
|
889
|
+
if (kind) {
|
|
890
|
+
const slot = this.state.peerNotices[item.notice.peer]?.[kind];
|
|
891
|
+
return slot?.count !== '000000' && slot?.eventId === item.notice.eventId ? slot : undefined;
|
|
714
892
|
}
|
|
893
|
+
return this.state.threadNotices[`${item.notice.peer}/${item.notice.threadId}`]?.episodes.find(episode => episode.kind === item.notice.kind && episode.eventId === item.notice.eventId);
|
|
894
|
+
}
|
|
895
|
+
rememberNotice(item) {
|
|
896
|
+
const episode = this.noticeSlot(item);
|
|
897
|
+
if (episode)
|
|
898
|
+
Object.assign(episode, { tries: item.tries ?? 0, nextAttemptAt: item.nextAttemptAt ?? 0, logged: item.logged ?? false });
|
|
899
|
+
}
|
|
900
|
+
/** Earliest rolling-hour retry under the global allowance and each paired peer's share. */
|
|
901
|
+
noticeRateDeadline(peer, now = this.now()) {
|
|
902
|
+
const recent = (this.state.noticeLog[peer] ?? []).filter(t => now - t < 3600_000);
|
|
903
|
+
const global = this.state.noticeGlobalLog.filter(t => now - t < 3600_000);
|
|
904
|
+
const peers = [...new Set([...this.deps.loadPeers().filter(peer => peer.status === 'paired').map(peer => peer.handle), peer])];
|
|
905
|
+
const share = Math.min(NOTICE_RESERVE_PER_PEER, Math.floor(NOTICES_PER_HOUR / peers.length));
|
|
906
|
+
const reservedForOthers = peers.filter(other => other !== peer).reduce((reserved, other) => reserved + Math.max(0, share - (this.state.noticeLog[other] ?? []).filter(t => now - t < 3600_000).length), 0);
|
|
907
|
+
if (recent.length < NOTICES_PER_PEER_PER_HOUR && global.length + reservedForOthers < NOTICES_PER_HOUR)
|
|
908
|
+
return 0;
|
|
909
|
+
return Math.max(recent.length >= NOTICES_PER_PEER_PER_HOUR ? Math.min(...recent) + 3600_000 : 0, global.length ? Math.min(...global) + 3600_000 : now + 3600_000);
|
|
910
|
+
}
|
|
911
|
+
/** -1 appends; a nonnegative index yields that place; undefined leaves the pending slot alone. */
|
|
912
|
+
admissionIndex(item) {
|
|
913
|
+
if (this.state.outbox.some(existing => existing.id === item.id))
|
|
914
|
+
return undefined;
|
|
915
|
+
if (this.state.outbox.length < MAX_OUTBOX)
|
|
916
|
+
return -1;
|
|
917
|
+
const now = this.now();
|
|
918
|
+
const eligible = item.kind === 'inform' || ((item.nextAttemptAt ?? 0) <= now && !this.noticeRateDeadline(item.notice.peer, now));
|
|
919
|
+
if (!eligible)
|
|
920
|
+
return undefined;
|
|
921
|
+
const deferred = this.state.outbox.findIndex(existing => existing.kind === 'notify' && !this.outboxBusy.has(existing.id)
|
|
922
|
+
&& ((existing.nextAttemptAt ?? 0) > now || this.noticeRateDeadline(existing.notice.peer, now) > now));
|
|
923
|
+
if (deferred >= 0)
|
|
924
|
+
return deferred;
|
|
925
|
+
if (item.kind === 'notify') {
|
|
926
|
+
const counts = new Map();
|
|
927
|
+
for (const existing of this.state.outbox)
|
|
928
|
+
if (existing.kind === 'notify')
|
|
929
|
+
counts.set(existing.notice.peer, (counts.get(existing.notice.peer) ?? 0) + 1);
|
|
930
|
+
const own = counts.get(item.notice.peer) ?? 0;
|
|
931
|
+
const overrepresented = this.state.outbox.findIndex(existing => existing.kind === 'notify'
|
|
932
|
+
&& existing.notice.peer !== item.notice.peer && (counts.get(existing.notice.peer) ?? 0) > own + 1
|
|
933
|
+
&& existing.notice.kind !== 'paused' && !existing.tries && !this.outboxBusy.has(existing.id));
|
|
934
|
+
if (overrepresented >= 0)
|
|
935
|
+
return overrepresented;
|
|
936
|
+
}
|
|
937
|
+
if (item.kind === 'inform' || item.notice.kind === 'paused') {
|
|
938
|
+
const unattempted = this.state.outbox.findIndex(existing => existing.kind === 'notify' && existing.notice.kind === 'peer-paused'
|
|
939
|
+
&& !existing.tries && !this.outboxBusy.has(existing.id));
|
|
940
|
+
if (unattempted >= 0)
|
|
941
|
+
return unattempted;
|
|
942
|
+
}
|
|
943
|
+
return undefined;
|
|
944
|
+
}
|
|
945
|
+
/** Queue places can yield; the two fixed episode records retain all retry metadata. */
|
|
946
|
+
enqueue(item) {
|
|
947
|
+
const index = this.admissionIndex(item);
|
|
948
|
+
if (index === undefined)
|
|
949
|
+
return;
|
|
950
|
+
if (index >= 0)
|
|
951
|
+
this.state.outbox.splice(index, 1);
|
|
715
952
|
this.state.outbox.push(item);
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
953
|
+
}
|
|
954
|
+
*outboxCandidates() {
|
|
955
|
+
for (const [key, record] of Object.entries(this.state.threadNotices)) {
|
|
956
|
+
const [peer, threadId] = key.split('/');
|
|
957
|
+
if (record.informPending)
|
|
958
|
+
yield { id: `inform:${key}`, kind: 'inform', peer, threadId, pauseId: record.ourMarkerId, text: record.informText };
|
|
959
|
+
}
|
|
960
|
+
for (const kind of ['paused', 'peer-paused']) {
|
|
961
|
+
for (const [key, record] of Object.entries(this.state.threadNotices)) {
|
|
962
|
+
const [peer, threadId] = key.split('/');
|
|
963
|
+
for (const [slot, episode] of record.episodes.entries()) {
|
|
964
|
+
if (episode.kind === kind)
|
|
965
|
+
yield { id: episodeKey(key, slot), kind: 'notify',
|
|
966
|
+
tries: episode.tries, nextAttemptAt: episode.nextAttemptAt, logged: episode.logged, notice: {
|
|
967
|
+
kind, peer, threadId, eventId: episode.eventId, repeats: episode.repeats,
|
|
968
|
+
detail: kind === 'paused' ? 'thread paused on this side' : "the peer's watcher reported the thread paused",
|
|
969
|
+
} };
|
|
970
|
+
}
|
|
971
|
+
}
|
|
719
972
|
}
|
|
973
|
+
for (const [peer, record] of Object.entries(this.state.peerNotices)) {
|
|
974
|
+
for (const kind of ['approval', 'message']) {
|
|
975
|
+
const slot = record[kind];
|
|
976
|
+
if (slot.count !== '000000')
|
|
977
|
+
yield { id: needsKey(peer, kind), kind: 'notify',
|
|
978
|
+
tries: slot.tries, nextAttemptAt: slot.nextAttemptAt, logged: slot.logged, notice: {
|
|
979
|
+
kind: kind === 'message' ? 'new-message' : 'new-approval', peer, threadId: slot.threadId,
|
|
980
|
+
eventId: slot.eventId, count: slot.count, detail: '',
|
|
981
|
+
} };
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/** Admit local work first, allowing eligible peers to pass deferred entries in a full queue. */
|
|
986
|
+
fillOutbox() {
|
|
987
|
+
for (const item of this.fairOutbox([...this.outboxCandidates()]))
|
|
988
|
+
this.enqueue(item);
|
|
989
|
+
}
|
|
990
|
+
fairOutbox(items) {
|
|
991
|
+
const ordered = [];
|
|
992
|
+
for (const priority of ['inform', 'paused', 'other']) {
|
|
993
|
+
const peers = new Map();
|
|
994
|
+
for (const item of items) {
|
|
995
|
+
const group = item.kind === 'inform' ? 'inform' : item.notice.kind === 'paused' ? 'paused' : 'other';
|
|
996
|
+
if (group !== priority)
|
|
997
|
+
continue;
|
|
998
|
+
const peer = item.kind === 'inform' ? item.peer : item.notice.peer;
|
|
999
|
+
if (!peers.has(peer))
|
|
1000
|
+
peers.set(peer, []);
|
|
1001
|
+
peers.get(peer).push(item);
|
|
1002
|
+
}
|
|
1003
|
+
while (peers.size)
|
|
1004
|
+
for (const [peer, queue] of peers) {
|
|
1005
|
+
ordered.push(queue.shift());
|
|
1006
|
+
if (!queue.length)
|
|
1007
|
+
peers.delete(peer);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return ordered;
|
|
1011
|
+
}
|
|
1012
|
+
/** Completion matches both the fixed slot and episode ULID; another queued episode is independent. */
|
|
1013
|
+
finishOutbox(item, delivered = false) {
|
|
1014
|
+
const peer = item.kind === 'inform' ? item.peer : item.notice.peer;
|
|
1015
|
+
const threadId = item.kind === 'inform' ? item.threadId : item.notice.threadId;
|
|
1016
|
+
const record = this.state.threadNotices[`${peer}/${threadId}`];
|
|
1017
|
+
if (item.kind === 'notify' && needsKind(item.notice.kind)) {
|
|
1018
|
+
const slot = this.noticeSlot(item);
|
|
1019
|
+
if (slot) {
|
|
1020
|
+
const arrivals = slot.dispatchArrivals === '------' ? '000000' : slot.dispatchArrivals;
|
|
1021
|
+
if (delivered && arrivals !== '000000')
|
|
1022
|
+
Object.assign(slot, emptyNeedsSlot(), {
|
|
1023
|
+
threadId: slot.threadId, eventId: ulid(this.now()), count: arrivals, firstSeenAt: slot.arrivalFirstSeenAt,
|
|
1024
|
+
});
|
|
1025
|
+
else
|
|
1026
|
+
Object.assign(slot, emptyNeedsSlot());
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
else if (record) {
|
|
1030
|
+
if (item.kind === 'inform' && item.pauseId === record.ourMarkerId)
|
|
1031
|
+
record.informPending = false;
|
|
1032
|
+
if (item.kind === 'notify') {
|
|
1033
|
+
record.lastNoticeSentAt = new Date(this.now()).toISOString();
|
|
1034
|
+
const slot = record.episodes.find(episode => episode.kind === item.notice.kind && episode.eventId === item.notice.eventId);
|
|
1035
|
+
if (slot) {
|
|
1036
|
+
const arrivedDuringDelivery = slot.dispatchArrivals === '------' ? 0 : Number(slot.dispatchArrivals);
|
|
1037
|
+
if (delivered && arrivedDuringDelivery > 0)
|
|
1038
|
+
Object.assign(slot, emptyEpisode(), {
|
|
1039
|
+
kind: slot.kind, eventId: slot.eventId, repeats: String(arrivedDuringDelivery - 1).padStart(6, '0'),
|
|
1040
|
+
});
|
|
1041
|
+
else
|
|
1042
|
+
Object.assign(slot, emptyEpisode());
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
this.state.outbox = this.state.outbox.filter(o => o.id !== item.id);
|
|
720
1047
|
}
|
|
721
1048
|
/**
|
|
722
1049
|
* Markers under me/state/paused/ are the truth. A marker that disappeared means the human resumed: the budget starts
|
|
@@ -744,7 +1071,10 @@ export class Watcher {
|
|
|
744
1071
|
const backlog = {};
|
|
745
1072
|
await this.mutate(() => {
|
|
746
1073
|
for (const key of resumed) {
|
|
1074
|
+
if (this.state.threadNotices[key])
|
|
1075
|
+
this.state.threadNotices[key].ourPaused = false;
|
|
747
1076
|
delete this.state.wakes[key];
|
|
1077
|
+
delete this.state.wakeRetries[key];
|
|
748
1078
|
const held = this.state.held[key];
|
|
749
1079
|
if (held?.messages.length) {
|
|
750
1080
|
backlog[key] = held.messages.length;
|
|
@@ -756,6 +1086,10 @@ export class Watcher {
|
|
|
756
1086
|
}
|
|
757
1087
|
delete this.state.held[key];
|
|
758
1088
|
}
|
|
1089
|
+
for (const key of added) {
|
|
1090
|
+
const record = this.state.threadNotices[key] ??= threadNoticeState();
|
|
1091
|
+
record.ourPaused = true;
|
|
1092
|
+
}
|
|
759
1093
|
this.state.paused = [...new Set([...this.state.paused.filter((key) => !resumed.includes(key)), ...added])].sort();
|
|
760
1094
|
});
|
|
761
1095
|
for (const key of resumed)
|
|
@@ -770,60 +1104,119 @@ export class Watcher {
|
|
|
770
1104
|
/** State that only matters for a day or an hour is dropped, so it cannot grow without bound. */
|
|
771
1105
|
async pruneState() {
|
|
772
1106
|
const at = this.now();
|
|
773
|
-
const today = Math.floor(at / DAY_MS);
|
|
774
1107
|
const stale = Object.keys(this.state.wakes).filter((key) => !this.recentWakes(key).length)
|
|
775
|
-
.concat(Object.keys(this.state.
|
|
1108
|
+
.concat(Object.keys(this.state.approvalRetries).filter(key => !this.state.pending[key]?.approvals?.length))
|
|
1109
|
+
.concat(Object.keys(this.state.wakeRetries).filter(key => !this.state.pending[key]))
|
|
776
1110
|
.concat(Object.keys(this.state.noticeLog).filter((peer) => !this.state.noticeLog[peer].some((t) => at - t < 3600_000)));
|
|
777
|
-
if (!stale.length)
|
|
1111
|
+
if (!stale.length && !this.state.noticeGlobalLog.some(t => at - t >= 3600_000))
|
|
778
1112
|
return;
|
|
779
1113
|
await this.mutate(() => {
|
|
1114
|
+
for (const key of Object.keys(this.state.wakeRetries))
|
|
1115
|
+
if (!this.state.pending[key])
|
|
1116
|
+
delete this.state.wakeRetries[key];
|
|
780
1117
|
for (const key of Object.keys(this.state.wakes))
|
|
781
1118
|
if (!this.recentWakes(key).length)
|
|
782
1119
|
delete this.state.wakes[key];
|
|
783
|
-
for (const key of Object.keys(this.state.
|
|
784
|
-
if (
|
|
785
|
-
delete this.state.
|
|
1120
|
+
for (const key of Object.keys(this.state.approvalRetries))
|
|
1121
|
+
if (!this.state.pending[key]?.approvals?.length)
|
|
1122
|
+
delete this.state.approvalRetries[key];
|
|
1123
|
+
this.state.noticeGlobalLog = this.state.noticeGlobalLog.filter(t => at - t < 3600_000);
|
|
786
1124
|
for (const peer of Object.keys(this.state.noticeLog))
|
|
787
1125
|
if (!this.state.noticeLog[peer].some((t) => at - t < 3600_000))
|
|
788
1126
|
delete this.state.noticeLog[peer];
|
|
789
1127
|
});
|
|
790
1128
|
}
|
|
791
|
-
/** Informs and notifications queued in state; failures back off (1, 2, 4 ... 15 minutes) and give up after
|
|
1129
|
+
/** Informs and notifications queued in state; failures back off (1, 2, 4 ... 15 minutes) and give up after 3 notification attempts or 8 inform failures. */
|
|
792
1130
|
async runOutbox() {
|
|
793
1131
|
const at = this.now();
|
|
1132
|
+
const refill = [...this.outboxCandidates()].some(item => this.admissionIndex(item) !== undefined);
|
|
1133
|
+
if (refill)
|
|
1134
|
+
await this.mutate(() => this.fillOutbox());
|
|
794
1135
|
const runs = [];
|
|
795
|
-
|
|
1136
|
+
const items = this.fairOutbox([...this.state.outbox]);
|
|
1137
|
+
for (const item of items) {
|
|
796
1138
|
if (this.outboxBusy.has(item.id) || (item.nextAttemptAt ?? 0) > at)
|
|
797
1139
|
continue;
|
|
798
1140
|
this.outboxBusy.add(item.id);
|
|
799
1141
|
runs.push((async () => {
|
|
800
1142
|
try {
|
|
801
1143
|
if (item.kind === 'inform')
|
|
802
|
-
await this.deps.postInform?.(item.peer, item.threadId, item.text);
|
|
1144
|
+
await this.deps.postInform?.(item.peer, item.threadId, item.text, item.pauseId);
|
|
803
1145
|
else {
|
|
804
1146
|
const { notice } = item;
|
|
1147
|
+
let reserved = false;
|
|
1148
|
+
await this.mutate(() => {
|
|
1149
|
+
const stored = this.state.outbox.find(o => o.id === item.id);
|
|
1150
|
+
if (stored?.kind !== 'notify')
|
|
1151
|
+
return;
|
|
1152
|
+
const now = this.now();
|
|
1153
|
+
const recent = (this.state.noticeLog[notice.peer] ?? []).filter(t => now - t < 3600_000);
|
|
1154
|
+
if ((stored.tries ?? 0) >= MAX_NOTICE_ATTEMPTS) {
|
|
1155
|
+
this.finishOutbox(item);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const deadline = this.noticeRateDeadline(notice.peer, now);
|
|
1159
|
+
if (deadline) {
|
|
1160
|
+
stored.nextAttemptAt = deadline;
|
|
1161
|
+
this.rememberNotice(stored);
|
|
1162
|
+
this.deps.log('notice-rate-cap', { peer: notice.peer, id: item.id });
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const episode = this.noticeSlot(stored);
|
|
1166
|
+
if (episode) {
|
|
1167
|
+
if ('repeats' in episode)
|
|
1168
|
+
item.notice.repeats = stored.notice.repeats = episode.repeats;
|
|
1169
|
+
else {
|
|
1170
|
+
item.notice.threadId = stored.notice.threadId = episode.threadId;
|
|
1171
|
+
item.notice.count = stored.notice.count = episode.count;
|
|
1172
|
+
episode.arrivalFirstSeenAt = EPOCH;
|
|
1173
|
+
}
|
|
1174
|
+
episode.dispatchArrivals = '000000';
|
|
1175
|
+
}
|
|
1176
|
+
this.state.noticeGlobalLog = [...this.state.noticeGlobalLog.filter(t => now - t < 3600_000), now];
|
|
1177
|
+
item.tries = stored.tries = (stored.tries ?? 0) + 1;
|
|
1178
|
+
this.rememberNotice(stored);
|
|
1179
|
+
this.state.noticeLog[notice.peer] = [...recent, now];
|
|
1180
|
+
reserved = true; // persisted BEFORE any channel operation, even one that delivers then fails
|
|
1181
|
+
});
|
|
1182
|
+
if (!reserved)
|
|
1183
|
+
return;
|
|
805
1184
|
this.deps.log('notify', { kind: notice.kind, peer: notice.peer, threadId: notice.threadId, detail: notice.detail });
|
|
806
1185
|
if (!item.logged && this.deps.notificationHome) {
|
|
807
1186
|
appendNotice(this.deps.notificationHome, notice);
|
|
808
|
-
await this.mutate(() => {
|
|
809
|
-
stored
|
|
1187
|
+
await this.mutate(() => {
|
|
1188
|
+
const stored = this.state.outbox.find(o => o.id === item.id);
|
|
1189
|
+
if (stored?.kind === 'notify') {
|
|
1190
|
+
stored.logged = true;
|
|
1191
|
+
this.rememberNotice(stored);
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
810
1194
|
}
|
|
811
1195
|
await this.deps.notify?.(notice);
|
|
812
1196
|
}
|
|
813
|
-
await this.mutate(() =>
|
|
1197
|
+
await this.mutate(() => this.finishOutbox(item, true));
|
|
814
1198
|
}
|
|
815
1199
|
catch (error) {
|
|
816
|
-
const tries = (item.tries ?? 0) + 1;
|
|
1200
|
+
const tries = item.kind === 'notify' ? (item.tries ?? 1) : (item.tries ?? 0) + 1;
|
|
817
1201
|
this.deps.log(item.kind === 'inform' ? 'inform-error' : 'notify-error', { id: item.id, tries, ...errInfo(error) });
|
|
818
|
-
if (tries >= MAX_OUTBOX_TRIES) {
|
|
1202
|
+
if (tries >= (item.kind === 'notify' ? MAX_NOTICE_ATTEMPTS : MAX_OUTBOX_TRIES)) {
|
|
819
1203
|
this.deps.log('outbox-dropped', { id: item.id, reason: 'too-many-failures' });
|
|
820
|
-
await this.mutate(() =>
|
|
1204
|
+
await this.mutate(() => this.finishOutbox(item));
|
|
821
1205
|
}
|
|
822
1206
|
else {
|
|
823
|
-
await this.mutate(() => {
|
|
1207
|
+
await this.mutate(() => {
|
|
1208
|
+
const stored = this.state.outbox.find(o => o.id === item.id);
|
|
1209
|
+
if (!stored)
|
|
1210
|
+
return;
|
|
824
1211
|
stored.tries = tries;
|
|
825
|
-
stored.nextAttemptAt =
|
|
826
|
-
|
|
1212
|
+
stored.nextAttemptAt = this.now() + Math.min(15 * 60_000, WAKE_RETRY_MS * 2 ** (tries - 1));
|
|
1213
|
+
if (stored.kind === 'notify') {
|
|
1214
|
+
this.rememberNotice(stored);
|
|
1215
|
+
const episode = this.noticeSlot(stored);
|
|
1216
|
+
if (episode)
|
|
1217
|
+
episode.dispatchArrivals = '------';
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
827
1220
|
}
|
|
828
1221
|
}
|
|
829
1222
|
finally {
|
|
@@ -835,6 +1228,16 @@ export class Watcher {
|
|
|
835
1228
|
}
|
|
836
1229
|
// ---------- run loop ----------
|
|
837
1230
|
async pollOnce(via) {
|
|
1231
|
+
if (this.deps.notifyOnly) {
|
|
1232
|
+
const paired = this.deps.loadPeers().filter(peer => peer.status === 'paired' && HANDLE.test(peer.handle)).map(peer => peer.handle);
|
|
1233
|
+
if (paired.some(peer => !Object.hasOwn(this.state.peerNotices, peer))
|
|
1234
|
+
|| Object.keys(this.state.peerNotices).some(peer => !paired.includes(peer))) {
|
|
1235
|
+
await this.mutate(() => {
|
|
1236
|
+
this.syncNoticePeers();
|
|
1237
|
+
this.state.outbox = this.state.outbox.filter(item => item.kind !== 'notify' || !needsKind(item.notice.kind) || paired.includes(item.notice.peer));
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
838
1241
|
await this.syncPaused();
|
|
839
1242
|
await this.pruneState();
|
|
840
1243
|
await Promise.all(this.deps.loadPeers().map((peer) => this.sync(peer, via)));
|