@tinycloud/courier 0.6.0-beta.29 → 0.6.0-beta.30

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/watch.js CHANGED
@@ -2,7 +2,7 @@ import { isAuthorizationRefusal } from './errors.js';
2
2
  import { defer } from './defer.js';
3
3
  import { decodeTime, ulid } from 'ulid';
4
4
  import { errInfo, CourierError } from './errors.js';
5
- import { k, PREFIX } from './keys.js';
5
+ import { k, PREFIX, stateKeys } from './keys.js';
6
6
  import { isRecord, messageView, oneLine, taskView } from './a2a.js';
7
7
  import { commitReceipts, selectUndelivered } from './inbox.js';
8
8
  import { del, getJson, listAll, putCreate, putMatch, putOverwrite } from './store.js';
@@ -18,6 +18,13 @@ const WAKEN_MEMORY = 500;
18
18
  const WAKE_RETRY_MS = 60_000;
19
19
  /** Delay between starting a re-opened subscription and its catch-up list, so the stream is live first. */
20
20
  const RECONNECT_CATCH_UP_MS = 2000;
21
+ /** Spec §8: while every hook subscription is healthy, the full catch-up listing runs this often (otherwise every `pollMs`). */
22
+ const HEALTHY_RECONCILE_MS = 300_000;
23
+ /** The scheduled renewal and authority check (TC-2917): idle revocation detection and sibling cleanup take up to this. */
24
+ const AUTHORITY_CHECK_MS = 300_000;
25
+ /** A same-agent state conflict: first standby window, doubling to the cap. */
26
+ const CONFLICT_STANDBY_MS = 300_000;
27
+ const CONFLICT_STANDBY_MAX_MS = 1_800_000;
21
28
  /** Spec §7: an approval expires after 24 hours; an older one found at startup wakes nobody. */
22
29
  const APPROVAL_MAX_AGE_MS = 24 * 3600 * 1000;
23
30
  /** Adapter runs are capped at 30 min in total (`--wake-timeout`, one deadline across all its calls). This profile's own claim is respected 35 min unless its holder is provably gone; another profile's or machine's, 45. */
@@ -104,7 +111,7 @@ export class Watcher {
104
111
  retryAt = new Map();
105
112
  waking = new Set();
106
113
  approvalsRunning = false;
107
- /** Thread keys currently paused: markers under me/state/paused/ plus those this watcher just wrote. */
114
+ /** Thread keys currently paused: this agent's pause markers plus those this watcher just wrote. */
108
115
  pausedKeys = new Set();
109
116
  /** Serialises everything that reads or writes pause markers, so a stale listing cannot drop a pause just written. */
110
117
  pausedLock = Promise.resolve();
@@ -121,13 +128,25 @@ export class Watcher {
121
128
  /** Set once `run` is told to stop: nothing new is woken after that. */
122
129
  stopping = false;
123
130
  onFatal;
124
- /** Etag of me/state/watch.json as this watcher last saw it: null = absent, undefined = unknown (fetch before writing). */
131
+ /** Etag of this agent's watch.json as this watcher last saw it: null = absent, undefined = unknown (fetch before writing). */
125
132
  stateEtag;
126
133
  taskCache = new Map();
127
134
  metaMirrored = new Set();
128
135
  syncing = new Map();
136
+ /** Peers whose last sync did not finish: a cycle between full catch-ups lists only these again. */
137
+ dirtyPeers = new Set();
138
+ approvalsDirty = false;
139
+ /** Peers whose next sync also reads their renewal marker. */
140
+ renewalDue = new Set();
141
+ /** Peers whose delegation changed (a marker hook event): their next sync checks authority before anything else. */
142
+ authorityDue = new Set();
143
+ /** Open hook streams by subscription id; absent while one is failed, ended or not yet opened. */
144
+ hookStreams = new Map();
145
+ /** Message keys each peer's receipts already list; receipts only grow, so a listing within this set has nothing new. */
146
+ receipted = new Map();
129
147
  stateQueue = Promise.resolve();
130
148
  now;
149
+ keys;
131
150
  constructor(deps, opts = WATCH_DEFAULTS) {
132
151
  this.deps = deps;
133
152
  this.opts = opts;
@@ -135,10 +154,48 @@ export class Watcher {
135
154
  this.profileId = deps.holder?.profileId ?? this.runId;
136
155
  this.pid = deps.holder?.pid ?? process.pid;
137
156
  this.isAlive = deps.holder?.isAlive ?? pidAlive;
157
+ this.keys = stateKeys(deps.agent);
158
+ }
159
+ /**
160
+ * Before TC-2917 every agent of the owner shared `me/state/`. On an agent's first start its own keys get a copy of
161
+ * what concerns its own configured peers (eligible now or not): pause markers first, then watch.json, whose existence
162
+ * marks the move done. Every write is create-only, so a crash or a second process repeats it harmlessly; the shared
163
+ * keys are left in place.
164
+ */
165
+ async migrateLegacyState() {
166
+ const { selfKv, log } = this.deps;
167
+ if (!this.deps.agent || await getJson(selfKv, this.keys.watch))
168
+ return;
169
+ const mine = new Set(this.deps.peerHandles?.() ?? this.deps.loadPeers().map(peer => peer.handle));
170
+ const ownKey = (key) => mine.has(key.split('/')[0]);
171
+ let markers = 0;
172
+ for (const key of await listAll(selfKv, k.pausedPrefix)) {
173
+ const match = /\/paused\/([^/]+)\/([^/]+)\.json$/.exec(key);
174
+ if (!match || !SAFE_ID.test(match[1]) || !SAFE_ID.test(match[2]) || !mine.has(match[1]))
175
+ continue;
176
+ const marker = await getJson(selfKv, key);
177
+ if (!marker)
178
+ continue;
179
+ await this.mirrorCreate(this.keys.paused(match[1], match[2]), marker.value);
180
+ markers++;
181
+ }
182
+ const legacy = (await getJson(selfKv, k.watchState))?.value;
183
+ const state = isRecord(legacy) && legacy.v === 1 ? { ...emptyWatchState(), ...legacy } : emptyWatchState();
184
+ const byThread = (record) => Object.fromEntries(Object.entries(record ?? {}).filter(([key]) => ownKey(key)));
185
+ const byPeer = (record) => Object.fromEntries(Object.entries(record ?? {}).filter(([peer]) => mine.has(peer)));
186
+ const copy = {
187
+ ...state,
188
+ pending: byThread(state.pending), wakes: byThread(state.wakes), wakeRetries: byThread(state.wakeRetries),
189
+ approvalRetries: byThread(state.approvalRetries), held: byThread(state.held), threadNotices: byThread(state.threadNotices),
190
+ paused: (state.paused ?? []).filter(ownKey), peerNotices: byPeer(state.peerNotices), noticeLog: byPeer(state.noticeLog),
191
+ outbox: (state.outbox ?? []).filter(item => mine.has(item.kind === 'inform' ? item.peer : item.notice.peer)),
192
+ };
193
+ await this.mirrorCreate(this.keys.watch, copy);
194
+ log('state-migrated', { pending: Object.keys(copy.pending).length, paused: markers });
138
195
  }
139
196
  // ---------- state ----------
140
197
  async loadState() {
141
- const got = await getJson(this.deps.selfKv, k.watchState);
198
+ const got = await getJson(this.deps.selfKv, this.keys.watch);
142
199
  const value = got?.value;
143
200
  this.stateEtag = got?.etag ?? null;
144
201
  this.state = isRecord(value) && value.v === 1
@@ -211,7 +268,7 @@ export class Watcher {
211
268
  }
212
269
  /**
213
270
  * Serialised: callers mutate `this.state` inside `fn`, then it is written with if-match against the etag this
214
- * watcher last saw. A second `watch` on the same profile therefore fails with `state-conflict` instead of
271
+ * watcher last saw. A second `watch` for the same agent therefore fails with `state-conflict` instead of
215
272
  * silently overwriting (last writer wins) the other's pending wakes.
216
273
  */
217
274
  mutate(fn) {
@@ -219,16 +276,16 @@ export class Watcher {
219
276
  const result = fn();
220
277
  const { selfKv } = this.deps;
221
278
  if (this.stateEtag === undefined)
222
- this.stateEtag = (await getJson(selfKv, k.watchState))?.etag ?? null;
279
+ this.stateEtag = (await getJson(selfKv, this.keys.watch))?.etag ?? null;
223
280
  try {
224
281
  const written = this.stateEtag
225
- ? await putMatch(selfKv, k.watchState, this.state, this.stateEtag)
226
- : await putCreate(selfKv, k.watchState, this.state);
282
+ ? await putMatch(selfKv, this.keys.watch, this.state, this.stateEtag)
283
+ : await putCreate(selfKv, this.keys.watch, this.state);
227
284
  this.stateEtag = written ?? undefined;
228
285
  }
229
286
  catch (error) {
230
287
  if (error instanceof CourierError && error.code === 'conflict') {
231
- throw this.stateConflict('me/state/watch.json was changed by another process; a second `watch` is running for this profile. This watcher is stopping.');
288
+ throw this.stateConflict('watch.json was changed by another process: a second `watch` is running for this Courier agent.');
232
289
  }
233
290
  throw error;
234
291
  }
@@ -257,7 +314,7 @@ export class Watcher {
257
314
  */
258
315
  async claimWake(peer, threadId, firstId) {
259
316
  const { selfKv, log } = this.deps;
260
- const key = k.wakeClaim(peer, threadId, firstId);
317
+ const key = this.keys.wakeClaim(peer, threadId, firstId);
261
318
  const claim = { v: 1, profile: this.profileId, run: this.runId, pid: this.pid, at: new Date(this.now()).toISOString() };
262
319
  for (let attempt = 0; attempt < 2; attempt++) {
263
320
  try {
@@ -310,7 +367,7 @@ export class Watcher {
310
367
  }
311
368
  /** Drops this run's own claim (best effort): a failed wake must not keep the thread from being woken. */
312
369
  async releaseClaim(peer, threadId, firstId) {
313
- const key = k.wakeClaim(peer, threadId, firstId);
370
+ const key = this.keys.wakeClaim(peer, threadId, firstId);
314
371
  try {
315
372
  const existing = await getJson(this.deps.selfKv, key);
316
373
  if (existing?.value.run === this.runId)
@@ -323,7 +380,7 @@ export class Watcher {
323
380
  /** Claim markers are only needed while a double wake is possible; drop those older than two days. */
324
381
  async pruneClaims() {
325
382
  try {
326
- for (const key of await listAll(this.deps.selfKv, k.wakeClaimsPrefix)) {
383
+ for (const key of await listAll(this.deps.selfKv, this.keys.wakeClaimsPrefix)) {
327
384
  const id = /\/([^/]+)\.json$/.exec(key)?.[1];
328
385
  const sentAt = id ? Date.parse(sentAtOf(id)) : NaN;
329
386
  if (Number.isFinite(sentAt) && this.now() - sentAt > 2 * DAY_MS)
@@ -335,27 +392,51 @@ export class Watcher {
335
392
  }
336
393
  }
337
394
  // ---------- catch-up and delivery ----------
338
- /** Serialises syncs per peer; a request that arrives mid-sync reruns once after it. */
339
- async sync(peer, via) {
395
+ /**
396
+ * Serialises syncs per peer; a request that arrives mid-sync reruns once after it. `pausedListed`: the caller has
397
+ * just listed the pause markers for this cycle, so the sync shares that listing instead of making its own.
398
+ */
399
+ async sync(peer, via, pausedListed = false) {
340
400
  const running = this.syncing.get(peer.handle);
341
401
  if (running) {
342
402
  running.again = true;
403
+ running.listPaused ||= !pausedListed;
343
404
  return;
344
405
  }
345
- const entry = { again: false, via };
406
+ const entry = { again: false, via, listPaused: !pausedListed };
346
407
  this.syncing.set(peer.handle, entry);
347
408
  try {
348
409
  do {
349
410
  entry.again = false;
411
+ const listPaused = entry.listPaused;
412
+ entry.listPaused = false;
413
+ const renew = this.renewalDue.has(peer.handle);
414
+ this.dirtyPeers.delete(peer.handle);
350
415
  try {
351
- await this.syncPeer(peer, entry.via);
416
+ await this.syncPeer(peer, entry.via, listPaused, renew);
352
417
  }
353
418
  catch (error) {
354
- if (isAuthorizationRefusal(error)) {
419
+ if (!isAuthorizationRefusal(error)) {
420
+ this.dirtyPeers.add(peer.handle);
421
+ this.deps.log('sync-error', { peer: peer.handle, ...errInfo(error) });
422
+ continue;
423
+ }
424
+ let renewed;
425
+ try {
426
+ renewed = await this.afterRefusal(peer, renew);
427
+ }
428
+ catch (failure) {
429
+ // Neither renewed nor refused for certain: retried by the next cycle, nothing recorded.
430
+ this.dirtyPeers.add(peer.handle);
431
+ this.deps.log('sync-error', { peer: peer.handle, ...errInfo(failure) });
432
+ continue;
433
+ }
434
+ if (!renewed) {
355
435
  await this.deps.onAuthorityRefusal?.(`peer:${peer.handle}`);
356
436
  return;
357
437
  }
358
- this.deps.log('sync-error', { peer: peer.handle, ...errInfo(error) });
438
+ peer = renewed;
439
+ entry.again = true;
359
440
  }
360
441
  } while (entry.again);
361
442
  }
@@ -363,25 +444,89 @@ export class Watcher {
363
444
  this.syncing.delete(peer.handle);
364
445
  }
365
446
  }
366
- async syncPeer(peer, via) {
447
+ /**
448
+ * A refusal may mean the peer moved to a new grant or revoked ours. Re-read its renewal marker (unless this sync just
449
+ * did) and check its authority at once. Returns the peer to retry only under a different grant, never the refused one.
450
+ */
451
+ async afterRefusal(peer, markerRead) {
452
+ const refused = peer.inbound?.cid;
453
+ try {
454
+ if (!markerRead) {
455
+ await this.deps.kvFor(peer, true);
456
+ this.renewalDue.delete(peer.handle);
457
+ }
458
+ await this.deps.checkAuthority?.(peer.handle);
459
+ }
460
+ catch (error) {
461
+ if (isAuthorizationRefusal(error))
462
+ return undefined;
463
+ throw error;
464
+ }
465
+ const current = this.deps.loadPeers().find(candidate => candidate.handle === peer.handle);
466
+ return current?.inbound && current.inbound.cid !== refused ? current : undefined;
467
+ }
468
+ /**
469
+ * Acting for a peer (importing what it sent, waking for it, informing it) takes a fresh verdict from the node, never
470
+ * a cached one: the 300 s schedule only bounds how soon an idle revocation is noticed. False once the peer is gone.
471
+ */
472
+ async authorized(peer) {
473
+ const handle = typeof peer === 'string' ? peer : peer.handle;
474
+ if (!this.deps.checkAuthority)
475
+ return true; // a library caller without an authority source
476
+ await this.deps.checkAuthority(handle);
477
+ return this.deps.loadPeers().some(current => current.handle === handle);
478
+ }
479
+ /** The scheduled renewal and authority check for every peer; each peer's next sync also reads its renewal marker. */
480
+ async checkAuthority() {
481
+ await this.deps.checkAuthority?.();
482
+ for (const peer of this.deps.loadPeers())
483
+ this.renewalDue.add(peer.handle);
484
+ }
485
+ async syncPeer(peer, via, listPaused, renew) {
367
486
  if (peer.status !== 'paired' || !peer.inbound || !peer.theirHandleForMe)
368
487
  return;
369
488
  const { selfKv, log } = this.deps;
370
489
  const mailbox = peer.theirHandleForMe;
371
- const peerKv = await this.deps.kvFor(peer);
372
- // Pause markers are read with every listing, so a `courier resume` or `courier pause` takes effect on the very
373
- // next message instead of at the next poll (a message delivered while the watcher still thought the thread was
374
- // paused would never wake anyone).
490
+ const peerKv = await this.deps.kvFor(peer, renew);
491
+ if (renew)
492
+ this.renewalDue.delete(peer.handle);
493
+ let allowed;
494
+ if (this.authorityDue.delete(peer.handle)) {
495
+ // The marker changed: check now, even with nothing to import; the verdict also serves this sync's imports.
496
+ try {
497
+ allowed = Promise.resolve(await this.authorized(peer));
498
+ }
499
+ catch (error) {
500
+ this.authorityDue.add(peer.handle);
501
+ throw error;
502
+ }
503
+ }
504
+ const authorized = () => allowed ??= this.authorized(peer);
505
+ // Pause markers are read with every listing (a cycle lists them once for all its peers just before), so a
506
+ // `courier resume` or `courier pause` takes effect on the very next message instead of at the next poll (a
507
+ // message delivered while the watcher still thought the thread was paused would never wake anyone).
375
508
  const prefix = peer.sessionId ? `${k.threadRoot(mailbox, peer.sessionId)}/` : `${PREFIX}/to/${mailbox}/threads/`;
376
- const [keys] = await Promise.all([listAll(peerKv, prefix), this.syncPaused(), this.syncKnownThreads(peer)]);
377
- const receipts = (await getJson(selfKv, k.receipts(peer.handle)))?.value ?? { v: 1, threads: {} };
378
- receipts.threads ??= {};
509
+ const [keys] = await Promise.all([listAll(peerKv, prefix), listPaused ? this.syncPaused() : undefined]);
510
+ // Receipts are read only when the listing holds a message they were not already known to cover.
511
+ const seen = this.receipted.get(peer.handle) ?? new Set();
512
+ const unseen = selectUndelivered(keys, { v: 1, threads: {} }).filter(item => !seen.has(`${item.threadId}/${item.messageId}`));
513
+ let receipts = { v: 1, threads: {} };
514
+ if (unseen.length) {
515
+ receipts = (await getJson(selfKv, k.receipts(peer.handle)))?.value ?? receipts;
516
+ receipts.threads ??= {};
517
+ for (const [threadId, thread] of Object.entries(receipts.threads))
518
+ for (const id of thread.delivered ?? [])
519
+ seen.add(`${threadId}/${id}`);
520
+ this.receipted.set(peer.handle, seen);
521
+ }
379
522
  const delivered = [];
380
- for (const selected of selectUndelivered(keys, receipts)) {
523
+ for (const selected of unseen.filter(item => !seen.has(`${item.threadId}/${item.messageId}`))) {
381
524
  if (!SAFE_ID.test(selected.threadId) || !SAFE_ID.test(selected.messageId)) {
382
525
  log('ignored', { peer: peer.handle, reason: 'unsafe-id' });
383
526
  continue;
384
527
  }
528
+ if (!(await authorized()))
529
+ return;
385
530
  try {
386
531
  const got = await getJson(peerKv, selected.key);
387
532
  if (!got)
@@ -395,11 +540,12 @@ export class Watcher {
395
540
  }
396
541
  catch (error) {
397
542
  // Not delivered, so no receipt: this message is retried on the next sync; the others go ahead.
543
+ this.dirtyPeers.add(peer.handle);
398
544
  log('sync-error', { peer: peer.handle, messageId: selected.messageId, ...errInfo(error) });
399
545
  }
400
546
  }
401
547
  if (!delivered.length) {
402
- await this.mirrorThreads(peer, peerKv, keys);
548
+ await this.mirrorThreads(peer, peerKv, keys, authorized);
403
549
  return;
404
550
  }
405
551
  const subjects = new Map();
@@ -417,6 +563,10 @@ export class Watcher {
417
563
  sentAt, latencyMs: sentAt ? at - Date.parse(sentAt) : null,
418
564
  });
419
565
  }
566
+ // Only our local listing establishes a known thread, and only a peer's pause notice needs one.
567
+ if (delivered.some(item => item.intent === 'inform' && (item.notice === 'thread-paused' || item.notice === 'thread-resumed'))) {
568
+ await this.syncKnownThreads(peer);
569
+ }
420
570
  await this.mutate(() => {
421
571
  if (this.deps.notifyOnly) {
422
572
  this.syncNoticePeers();
@@ -468,7 +618,9 @@ export class Watcher {
468
618
  this.fillOutbox();
469
619
  });
470
620
  await commitReceipts(selfKv, new Map([[peer.handle, delivered.map(({ threadId, messageId }) => ({ threadId, messageId }))]]));
471
- await this.mirrorThreads(peer, peerKv, keys);
621
+ for (const item of delivered)
622
+ seen.add(`${item.threadId}/${item.messageId}`);
623
+ await this.mirrorThreads(peer, peerKv, keys, authorized);
472
624
  }
473
625
  /** Only locally authored meta/messages/tasks, or a local wake/pause witness, establish a known thread. */
474
626
  async syncKnownThreads(peer) {
@@ -503,17 +655,18 @@ export class Watcher {
503
655
  * meta.json is immutable (create-only); tasks are mutable, so copy the latest until they are terminal. Runs after
504
656
  * message delivery and each item is isolated: a bad or unwritable peer task never blocks receipts or wakes.
505
657
  */
506
- async mirrorThreads(peer, peerKv, keys) {
658
+ async mirrorThreads(peer, peerKv, keys, authorized) {
507
659
  for (const key of keys) {
508
660
  try {
509
- await this.mirrorThreadItem(peer, peerKv, key);
661
+ await this.mirrorThreadItem(peer, peerKv, key, authorized);
510
662
  }
511
663
  catch (error) {
664
+ this.dirtyPeers.add(peer.handle);
512
665
  this.deps.log('mirror-error', { peer: peer.handle, ...errInfo(error) });
513
666
  }
514
667
  }
515
668
  }
516
- async mirrorThreadItem(peer, peerKv, key) {
669
+ async mirrorThreadItem(peer, peerKv, key, authorized) {
517
670
  const meta = /\/threads\/([^/]+)\/meta\.json$/.exec(key);
518
671
  if (meta) {
519
672
  if (!SAFE_ID.test(meta[1]))
@@ -523,6 +676,8 @@ export class Watcher {
523
676
  const got = await getJson(peerKv, key);
524
677
  if (!got)
525
678
  return;
679
+ if (!(await authorized()))
680
+ return;
526
681
  await this.mirrorCreate(k.mirrorMeta(peer.handle, meta[1]), got.value);
527
682
  this.metaMirrored.add(key);
528
683
  return;
@@ -538,6 +693,8 @@ export class Watcher {
538
693
  return;
539
694
  if (cached && got.etag && cached.etag === got.etag)
540
695
  return;
696
+ if (!(await authorized()))
697
+ return;
541
698
  await putOverwrite(this.deps.selfKv, k.mirrorTask(peer.handle, task[1], task[2]), got.value);
542
699
  const state = taskView(got.value).state;
543
700
  this.taskCache.set(key, { etag: got.etag, terminal: Boolean(state && TERMINAL_TASK_STATES.has(state)) });
@@ -555,10 +712,12 @@ export class Watcher {
555
712
  try {
556
713
  do {
557
714
  this.approvalsAgain = false;
715
+ this.approvalsDirty = false;
558
716
  try {
559
717
  await this.syncApprovalsOnce(via);
560
718
  }
561
719
  catch (error) {
720
+ this.approvalsDirty = true;
562
721
  this.deps.log('sync-error', { source: 'approvals', ...errInfo(error) });
563
722
  }
564
723
  } while (this.approvalsAgain);
@@ -590,6 +749,7 @@ export class Watcher {
590
749
  }
591
750
  catch (error) {
592
751
  // Unread (transient failure): left unseen so the next sync retries it.
752
+ this.approvalsDirty = true;
593
753
  log('sync-error', { source: 'approvals', approvalId: id, ...errInfo(error) });
594
754
  }
595
755
  }
@@ -733,6 +893,27 @@ export class Watcher {
733
893
  this.retryAt.set(key, at + CLAIM_SKIP_MS);
734
894
  return;
735
895
  }
896
+ // Acting for the peer: a fresh verdict from the node, never a cached one. A removed or refused peer's wake is dropped.
897
+ let allowed;
898
+ try {
899
+ allowed = await this.authorized(pending.peer);
900
+ }
901
+ catch (error) {
902
+ await this.releaseClaim(pending.peer, pending.threadId, claimId);
903
+ this.retryAt.set(key, at + WAKE_RETRY_MS);
904
+ this.deps.log('wake-deferred', { peer: pending.peer, threadId: pending.threadId, reason: 'authority-unverified', ...errInfo(error) });
905
+ return;
906
+ }
907
+ if (!allowed) {
908
+ await this.releaseClaim(pending.peer, pending.threadId, claimId);
909
+ await this.mutate(() => {
910
+ delete this.state.pending[key];
911
+ delete this.state.wakeRetries[key];
912
+ delete this.state.approvalRetries[key];
913
+ });
914
+ this.deps.log('wake-dropped', { peer: pending.peer, threadId: pending.threadId, messageIds: woken, approvalIds: approvedIds, reason: 'peer-not-authorized' });
915
+ return;
916
+ }
736
917
  try {
737
918
  // The attempt is counted against the budget BEFORE the adapter runs, and whether it then succeeds, fails or
738
919
  // times out: an adapter that always throws must not be retried forever. (A crash mid-wake counts too.)
@@ -803,7 +984,7 @@ export class Watcher {
803
984
  const at = new Date(this.now()).toISOString();
804
985
  const pauseId = await nextNoticeId(this.deps.selfKv, pending.peer, pending.threadId, this.now());
805
986
  const wrote = await this.exclusive(async () => {
806
- const created = await writePauseMarker(this.deps.selfKv, { v: 1, peer: pending.peer, threadId: pending.threadId, by: 'budget', at, reason, informId: pauseId });
987
+ const created = await writePauseMarker(this.deps.selfKv, { v: 1, peer: pending.peer, threadId: pending.threadId, by: 'budget', at, reason, informId: pauseId }, this.keys.paused(pending.peer, pending.threadId));
807
988
  this.pausedKeys.add(key);
808
989
  return created;
809
990
  });
@@ -1052,13 +1233,13 @@ export class Watcher {
1052
1233
  this.state.outbox = this.state.outbox.filter(o => o.id !== item.id);
1053
1234
  }
1054
1235
  /**
1055
- * Markers under me/state/paused/ are the truth. A marker that disappeared means the human resumed: the budget starts
1236
+ * This agent's pause markers are the truth. A marker that disappeared means the human resumed: the budget starts
1056
1237
  * over and what arrived meanwhile wakes the agent once. Serialised, so overlapping calls never apply a stale listing.
1057
1238
  */
1058
1239
  syncPaused() {
1059
1240
  return this.exclusive(async () => {
1060
1241
  try {
1061
- const keys = await listAll(this.deps.selfKv, k.pausedPrefix);
1242
+ const keys = await listAll(this.deps.selfKv, this.keys.pausedPrefix);
1062
1243
  const listed = new Set();
1063
1244
  for (const key of keys) {
1064
1245
  const match = /\/paused\/([^/]+)\/([^/]+)\.json$/.exec(key);
@@ -1146,8 +1327,29 @@ export class Watcher {
1146
1327
  this.outboxBusy.add(item.id);
1147
1328
  runs.push((async () => {
1148
1329
  try {
1149
- if (item.kind === 'inform')
1330
+ if (item.kind === 'inform') {
1331
+ // Writing to a peer is acting for it: a fresh verdict. A removed or refused peer's inform is dropped; a
1332
+ // check that could not give a verdict defers it (fails closed) without counting as a failed attempt.
1333
+ let allowed;
1334
+ try {
1335
+ allowed = await this.authorized(item.peer);
1336
+ }
1337
+ catch (error) {
1338
+ this.deps.log('inform-deferred', { id: item.id, peer: item.peer, threadId: item.threadId, reason: 'authority-unverified', ...errInfo(error) });
1339
+ await this.mutate(() => {
1340
+ const stored = this.state.outbox.find(o => o.id === item.id);
1341
+ if (stored)
1342
+ stored.nextAttemptAt = this.now() + WAKE_RETRY_MS;
1343
+ });
1344
+ return;
1345
+ }
1346
+ if (!allowed) {
1347
+ this.deps.log('inform-dropped', { id: item.id, peer: item.peer, threadId: item.threadId, reason: 'peer-not-authorized' });
1348
+ await this.mutate(() => this.finishOutbox(item));
1349
+ return;
1350
+ }
1150
1351
  await this.deps.postInform?.(item.peer, item.threadId, item.text, item.pauseId);
1352
+ }
1151
1353
  else {
1152
1354
  const { notice } = item;
1153
1355
  let reserved = false;
@@ -1233,9 +1435,12 @@ export class Watcher {
1233
1435
  await Promise.all(runs);
1234
1436
  }
1235
1437
  // ---------- run loop ----------
1438
+ /** One full catch-up: every peer and approvals, under one pause-marker listing. */
1236
1439
  async pollOnce(via, preflight = true) {
1237
- if (preflight)
1440
+ if (preflight) {
1238
1441
  await this.deps.beforePoll?.();
1442
+ await this.checkAuthority();
1443
+ }
1239
1444
  if (this.deps.notifyOnly) {
1240
1445
  const paired = this.deps.loadPeers().filter(peer => peer.status === 'paired' && HANDLE.test(peer.handle)).map(peer => peer.handle);
1241
1446
  if (paired.some(peer => !Object.hasOwn(this.state.peerNotices, peer))
@@ -1248,18 +1453,82 @@ export class Watcher {
1248
1453
  }
1249
1454
  await this.syncPaused();
1250
1455
  await this.pruneState();
1251
- await Promise.all(this.deps.loadPeers().map((peer) => this.sync(peer, via)));
1456
+ await Promise.all(this.deps.loadPeers().map((peer) => this.sync(peer, via, true)));
1252
1457
  await this.syncApprovals(via);
1253
1458
  }
1459
+ /** Between full catch-ups, a cycle lists again only what is dirty: a peer or approvals whose last sync did not finish. */
1460
+ async syncDirty() {
1461
+ const peers = this.deps.loadPeers().filter(peer => this.dirtyPeers.has(peer.handle));
1462
+ for (const handle of this.dirtyPeers)
1463
+ if (!peers.some(peer => peer.handle === handle))
1464
+ this.dirtyPeers.delete(handle);
1465
+ if (peers.length) {
1466
+ await this.syncPaused();
1467
+ await Promise.all(peers.map(peer => this.sync(peer, 'poll', true)));
1468
+ }
1469
+ if (this.approvalsDirty)
1470
+ await this.syncApprovals('poll');
1471
+ }
1472
+ /** Healthy: every subscription has an open stream that delivered an event or has lasted one ticket without failing. */
1473
+ hooksHealthy(ids) {
1474
+ const at = this.now();
1475
+ for (const id of ids) {
1476
+ const stream = this.hookStreams.get(id);
1477
+ if (!stream || !(stream.proven || at - stream.openedAt >= this.opts.ticketSeconds * 1000))
1478
+ return false;
1479
+ }
1480
+ return true;
1481
+ }
1254
1482
  /**
1255
- * Runs until `signal` aborts. With `once`, does one catch-up and wakes everything immediately. Throws
1256
- * `state-conflict` (after stopping everything) if another watcher is writing this profile's state.
1483
+ * Runs until `signal` aborts. With `once`, does one catch-up and wakes everything immediately, and throws
1484
+ * `state-conflict` (after stopping everything) if another watcher is writing this agent's state.
1485
+ *
1486
+ * Without `once` (a service, which systemd restarts whatever it exits with), a conflict never ends the process: the
1487
+ * watcher stops subscribing, waking and writing, and stands by. It reads watch.json once per window (5 minutes,
1488
+ * doubling to 30) and takes over, with one catch-up, only after a whole window in which the other writer wrote nothing.
1489
+ * The other watcher is the same agent's, so its messages stay watched meanwhile.
1257
1490
  */
1258
1491
  async run(external, options = {}) {
1492
+ if (options.once)
1493
+ return this.session(external, options);
1494
+ const { log, selfKv } = this.deps;
1495
+ let windowMs = CONFLICT_STANDBY_MS;
1496
+ for (;;) {
1497
+ const started = this.now();
1498
+ try {
1499
+ return await this.session(external, options);
1500
+ }
1501
+ catch (error) {
1502
+ if (!(error instanceof CourierError && error.code === 'state-conflict') || external.aborted)
1503
+ throw error;
1504
+ }
1505
+ if (this.now() - started >= CONFLICT_STANDBY_MAX_MS)
1506
+ windowMs = CONFLICT_STANDBY_MS;
1507
+ log('standby', {
1508
+ reason: 'state-conflict', retryInMs: windowMs,
1509
+ message: `Another \`watch\` is writing this Courier agent's watch.json. This watcher has stopped waking and checks again in ${Math.round(windowMs / 60_000)} min; stop one of the two.`,
1510
+ });
1511
+ let seen = (await getJson(selfKv, this.keys.watch).catch(() => undefined))?.etag;
1512
+ for (;;) {
1513
+ await sleep(windowMs, external);
1514
+ if (external.aborted)
1515
+ return;
1516
+ windowMs = Math.min(windowMs * 2, CONFLICT_STANDBY_MAX_MS);
1517
+ const etag = (await getJson(selfKv, this.keys.watch).catch(() => undefined))?.etag;
1518
+ if (etag === seen)
1519
+ break;
1520
+ seen = etag;
1521
+ }
1522
+ log('standby-over', { reason: 'other-writer-quiet' });
1523
+ }
1524
+ }
1525
+ async session(external, options) {
1259
1526
  const { log } = this.deps;
1260
1527
  const stop = new AbortController();
1261
1528
  const signal = stop.signal;
1262
1529
  const onExternalAbort = () => stop.abort();
1530
+ this.fatalError = undefined;
1531
+ this.stopping = false;
1263
1532
  signal.addEventListener('abort', () => { this.stopping = true; }, { once: true });
1264
1533
  external.addEventListener('abort', onExternalAbort, { once: true });
1265
1534
  if (external.aborted)
@@ -1269,6 +1538,9 @@ export class Watcher {
1269
1538
  let ticker;
1270
1539
  try {
1271
1540
  await this.deps.beforePoll?.();
1541
+ await this.checkAuthority();
1542
+ let authorityAt = this.now();
1543
+ await this.migrateLegacyState();
1272
1544
  await this.loadState();
1273
1545
  log('start', { debounceMs: this.opts.debounceMs, pollMs: this.opts.pollMs, ticketSeconds: this.opts.ticketSeconds, once: Boolean(options.once) });
1274
1546
  await this.pruneClaims();
@@ -1282,7 +1554,15 @@ export class Watcher {
1282
1554
  desired.set(`peer:${peer.handle}:${peer.inbound.cid}`, {
1283
1555
  sub: { space: peer.spaceId, service: 'kv', pathPrefix: peer.sessionId ? k.threadRoot(peer.theirHandleForMe, peer.sessionId) : `${PREFIX}/to/${peer.theirHandleForMe}` },
1284
1556
  hooks: () => this.deps.peerHooks(peer),
1285
- onEvent: () => void this.sync(peer, 'hook'),
1557
+ onEvent: (event) => {
1558
+ // A peer renewing or replacing its delegation to us writes this marker (a path-less event may be it):
1559
+ // read the marker and check the peer's authority now, not at the next scheduled check.
1560
+ if (event && (event.path === undefined || event.path.endsWith('/pairing/delegation.json'))) {
1561
+ this.renewalDue.add(peer.handle);
1562
+ this.authorityDue.add(peer.handle);
1563
+ }
1564
+ void this.sync(peer, 'hook');
1565
+ },
1286
1566
  });
1287
1567
  }
1288
1568
  desired.set('approvals', {
@@ -1294,6 +1574,7 @@ export class Watcher {
1294
1574
  if (!desired.has(id)) {
1295
1575
  controller.abort();
1296
1576
  supervisors.delete(id);
1577
+ this.hookStreams.delete(id);
1297
1578
  }
1298
1579
  }
1299
1580
  for (const [id, source] of desired) {
@@ -1312,16 +1593,31 @@ export class Watcher {
1312
1593
  // Open the subscriptions first, then catch up: a write between the two is seen by one or the other.
1313
1594
  reconcile();
1314
1595
  await sleep(RECONNECT_CATCH_UP_MS, signal);
1596
+ let fullAt = this.now();
1315
1597
  if (!signal.aborted)
1316
1598
  await this.pollOnce('startup', false);
1317
1599
  ticker = setInterval(() => void this.tick().catch((e) => log('wake-error', errInfo(e))), 1000);
1318
1600
  while (!signal.aborted) {
1319
- await sleep(this.opts.pollMs, signal);
1601
+ // Authority housekeeping and dirty peers keep a ≤300 s cadence however long `--poll` is.
1602
+ await sleep(Math.min(this.opts.pollMs, AUTHORITY_CHECK_MS), signal);
1320
1603
  if (signal.aborted)
1321
1604
  break;
1322
1605
  await this.deps.beforePoll?.();
1606
+ if (this.now() - authorityAt >= AUTHORITY_CHECK_MS) {
1607
+ authorityAt = this.now();
1608
+ await this.checkAuthority();
1609
+ }
1323
1610
  reconcile();
1324
- await this.pollOnce('poll', false);
1611
+ // Healthy hooks deliver at once, so the full listing is only the fallback for an event they missed: every
1612
+ // `--poll` while a subscription is unhealthy, otherwise every 5 minutes (or `--poll`, if longer).
1613
+ const fullEvery = this.hooksHealthy(supervisors.keys()) ? Math.max(this.opts.pollMs, HEALTHY_RECONCILE_MS) : this.opts.pollMs;
1614
+ if (this.now() - fullAt >= fullEvery) {
1615
+ fullAt = this.now();
1616
+ await this.pollOnce('poll', false);
1617
+ }
1618
+ else {
1619
+ await this.syncDirty();
1620
+ }
1325
1621
  if (this.now() - this.lastClaimPrune > 3600_000) {
1326
1622
  this.lastClaimPrune = this.now();
1327
1623
  await this.pruneClaims();
@@ -1345,7 +1641,10 @@ export class Watcher {
1345
1641
  if (this.fatalError)
1346
1642
  throw this.fatalError;
1347
1643
  }
1348
- /** One hook subscription. Authorization refusals stop this subscription; transient failures back off. */
1644
+ /**
1645
+ * One hook subscription. Authorization refusals stop this subscription; transient failures back off. A failed or
1646
+ * ended stream catches up at once, and the watcher lists everything every `pollMs` until a new stream is healthy.
1647
+ */
1349
1648
  async supervise(id, sub, hooks, onEvent, signal) {
1350
1649
  let failures = 0;
1351
1650
  let opened = false;
@@ -1360,21 +1659,30 @@ export class Watcher {
1360
1659
  onEvent(); });
1361
1660
  opened = true;
1362
1661
  const service = await hooks();
1662
+ const stream = { openedAt: this.now(), proven: false };
1663
+ this.hookStreams.set(id, stream);
1363
1664
  for await (const event of service.subscribe([sub], { ttlSeconds: this.opts.ticketSeconds, signal })) {
1364
1665
  failures = 0;
1666
+ stream.proven = true;
1365
1667
  this.deps.log('hook-event', { source: id.split(':').slice(0, 2).join(':'), path: event.path ?? null });
1366
1668
  onEvent(event);
1367
1669
  }
1368
- if (!signal.aborted)
1670
+ this.hookStreams.delete(id);
1671
+ if (!signal.aborted) {
1672
+ onEvent();
1369
1673
  await sleep(5_000, signal);
1674
+ }
1370
1675
  }
1371
1676
  catch (error) {
1677
+ this.hookStreams.delete(id);
1372
1678
  if (isAuthorizationRefusal(error)) {
1373
1679
  await this.deps.onAuthorityRefusal?.(id);
1374
1680
  return;
1375
1681
  }
1376
1682
  failures++;
1377
1683
  this.deps.log('hook-error', { source: id.split(':').slice(0, 2).join(':'), ...errInfo(error) });
1684
+ if (!signal.aborted)
1685
+ onEvent();
1378
1686
  await sleep(Math.min(300_000, 15_000 * 2 ** Math.min(failures - 1, 5)), signal);
1379
1687
  }
1380
1688
  }