mandala-computer-mcp 0.3.0 → 0.5.0

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.
Files changed (130) hide show
  1. package/README.md +462 -37
  2. package/dist/api.d.ts +12 -0
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +262 -42
  5. package/dist/api.js.map +1 -1
  6. package/dist/artifacts.d.ts +63 -0
  7. package/dist/artifacts.d.ts.map +1 -0
  8. package/dist/artifacts.js +81 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/cli.d.ts +4 -0
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +35 -21
  13. package/dist/cli.js.map +1 -1
  14. package/dist/credentials.d.ts +33 -0
  15. package/dist/credentials.d.ts.map +1 -0
  16. package/dist/credentials.js +395 -0
  17. package/dist/credentials.js.map +1 -0
  18. package/dist/errors.d.ts +53 -10
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +122 -35
  21. package/dist/errors.js.map +1 -1
  22. package/dist/events.d.ts +11 -2
  23. package/dist/events.d.ts.map +1 -1
  24. package/dist/events.js +213 -95
  25. package/dist/events.js.map +1 -1
  26. package/dist/executions.d.ts +43 -0
  27. package/dist/executions.d.ts.map +1 -0
  28. package/dist/executions.js +162 -0
  29. package/dist/executions.js.map +1 -0
  30. package/dist/format.d.ts +11 -11
  31. package/dist/format.d.ts.map +1 -1
  32. package/dist/format.js +159 -16
  33. package/dist/format.js.map +1 -1
  34. package/dist/http.d.ts.map +1 -1
  35. package/dist/http.js +4 -0
  36. package/dist/http.js.map +1 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/limits.d.ts +17 -0
  42. package/dist/limits.d.ts.map +1 -0
  43. package/dist/limits.js +17 -0
  44. package/dist/limits.js.map +1 -0
  45. package/dist/paths.d.ts +42 -2
  46. package/dist/paths.d.ts.map +1 -1
  47. package/dist/paths.js +54 -3
  48. package/dist/paths.js.map +1 -1
  49. package/dist/poll.d.ts +5 -1
  50. package/dist/poll.d.ts.map +1 -1
  51. package/dist/poll.js +120 -16
  52. package/dist/poll.js.map +1 -1
  53. package/dist/results.d.ts +97 -0
  54. package/dist/results.d.ts.map +1 -0
  55. package/dist/results.js +263 -0
  56. package/dist/results.js.map +1 -0
  57. package/dist/server.d.ts +3 -2
  58. package/dist/server.d.ts.map +1 -1
  59. package/dist/server.js +61 -9
  60. package/dist/server.js.map +1 -1
  61. package/dist/stdio.d.ts +5 -1
  62. package/dist/stdio.d.ts.map +1 -1
  63. package/dist/stdio.js +10 -4
  64. package/dist/stdio.js.map +1 -1
  65. package/dist/tool-filters.d.ts +38 -0
  66. package/dist/tool-filters.d.ts.map +1 -0
  67. package/dist/tool-filters.js +129 -0
  68. package/dist/tool-filters.js.map +1 -0
  69. package/dist/tools/account.d.ts +3 -0
  70. package/dist/tools/account.d.ts.map +1 -0
  71. package/dist/tools/account.js +122 -0
  72. package/dist/tools/account.js.map +1 -0
  73. package/dist/tools/activities.d.ts +3 -0
  74. package/dist/tools/activities.d.ts.map +1 -0
  75. package/dist/tools/activities.js +140 -0
  76. package/dist/tools/activities.js.map +1 -0
  77. package/dist/tools/agent.d.ts.map +1 -1
  78. package/dist/tools/agent.js +72 -5
  79. package/dist/tools/agent.js.map +1 -1
  80. package/dist/tools/artifacts.d.ts +3 -0
  81. package/dist/tools/artifacts.d.ts.map +1 -0
  82. package/dist/tools/artifacts.js +97 -0
  83. package/dist/tools/artifacts.js.map +1 -0
  84. package/dist/tools/chat.d.ts +6 -0
  85. package/dist/tools/chat.d.ts.map +1 -0
  86. package/dist/tools/chat.js +192 -0
  87. package/dist/tools/chat.js.map +1 -0
  88. package/dist/tools/computers.d.ts.map +1 -1
  89. package/dist/tools/computers.js +300 -247
  90. package/dist/tools/computers.js.map +1 -1
  91. package/dist/tools/directory.d.ts +13 -0
  92. package/dist/tools/directory.d.ts.map +1 -0
  93. package/dist/tools/directory.js +88 -0
  94. package/dist/tools/directory.js.map +1 -0
  95. package/dist/tools/events.d.ts.map +1 -1
  96. package/dist/tools/events.js +66 -22
  97. package/dist/tools/events.js.map +1 -1
  98. package/dist/tools/executions.d.ts +3 -0
  99. package/dist/tools/executions.d.ts.map +1 -0
  100. package/dist/tools/executions.js +87 -0
  101. package/dist/tools/executions.js.map +1 -0
  102. package/dist/tools/guest.d.ts.map +1 -1
  103. package/dist/tools/guest.js +57 -17
  104. package/dist/tools/guest.js.map +1 -1
  105. package/dist/tools/input.d.ts.map +1 -1
  106. package/dist/tools/input.js +63 -5
  107. package/dist/tools/input.js.map +1 -1
  108. package/dist/tools/results.d.ts +30 -0
  109. package/dist/tools/results.d.ts.map +1 -0
  110. package/dist/tools/results.js +106 -0
  111. package/dist/tools/results.js.map +1 -0
  112. package/dist/tools/secrets.d.ts +58 -0
  113. package/dist/tools/secrets.d.ts.map +1 -0
  114. package/dist/tools/secrets.js +204 -0
  115. package/dist/tools/secrets.js.map +1 -0
  116. package/dist/tools/signals.d.ts +3 -0
  117. package/dist/tools/signals.d.ts.map +1 -0
  118. package/dist/tools/signals.js +116 -0
  119. package/dist/tools/signals.js.map +1 -0
  120. package/dist/tools/snapshots.d.ts.map +1 -1
  121. package/dist/tools/snapshots.js +213 -167
  122. package/dist/tools/snapshots.js.map +1 -1
  123. package/dist/tools/ssh.d.ts +3 -0
  124. package/dist/tools/ssh.d.ts.map +1 -0
  125. package/dist/tools/ssh.js +186 -0
  126. package/dist/tools/ssh.js.map +1 -0
  127. package/dist/tools/templates.d.ts.map +1 -1
  128. package/dist/tools/templates.js +9 -13
  129. package/dist/tools/templates.js.map +1 -1
  130. package/package.json +1 -1
package/dist/events.js CHANGED
@@ -179,6 +179,8 @@ export class Subscription {
179
179
  #nextIndex = 0;
180
180
  /** The index after the last event handed to the model. */
181
181
  #delivered = 0;
182
+ /** The cursor immediately before the oldest event still in the ring. */
183
+ #beforeOldestCursor;
182
184
  /** Set once the model has read anything at all, so a first read can say so. */
183
185
  #read = false;
184
186
  #loss;
@@ -240,6 +242,8 @@ export class Subscription {
240
242
  * changed.
241
243
  */
242
244
  #armGen = new Map();
245
+ /** Next subscription-wide arm identity; active paths alone retain theirs. */
246
+ #nextArmGen = 0;
243
247
  /** Trees that have armed at least once in this subscription. */
244
248
  #everArmed = new Set();
245
249
  /** Re-arms a file-wait response has not explained yet. */
@@ -272,29 +276,19 @@ export class Subscription {
272
276
  * Trees a connection carrying them would not open, PROVEN so.
273
277
  *
274
278
  * Proven means the experiment came back positive — see {@link #cleared}: the
275
- * tree was withheld, the same stream opened without it, and there is nothing
276
- * else the difference could be. A tree merely suspected is
277
- * {@link #shedCandidate} and is not in here.
279
+ * tree failed when added to a known working set, then that same set opened
280
+ * without it. A tree still being tested is not in here.
278
281
  */
279
282
  #watchRefused = new Set();
280
283
  /**
281
- * The tree currently withheld from the URL to find out whether it is the
282
- * reason nothing will open. `undefined` when no experiment is running.
284
+ * A bounded search for nominations the host will not carry.
283
285
  *
284
- * It stays in {@link #watches} throughout, which is what keeps a caller from
285
- * being told its tree was evicted to make room for somebody else's — this is
286
- * a suspicion, not a decision about what the caller asked for.
286
+ * The last successful set is tried first. Once that opens, each addition is
287
+ * added back alone. A failed addition is removed once more for confirmation,
288
+ * so a host outage during the probe cannot turn an innocent tree into a
289
+ * refusal. Nominations stay in {@link #watches} throughout the experiment.
287
290
  */
288
- #shedCandidate;
289
- /**
290
- * Whether a watch has been ruled out as the reason connections are failing.
291
- *
292
- * Set when an experiment comes back negative, cleared the moment anything
293
- * greets. Without it a host that is simply DOWN would shed its way through
294
- * every tree in the set two failures at a time, reporting each in turn as one
295
- * the host would not carry.
296
- */
297
- #shedRuledOut = false;
291
+ #watchRecovery;
298
292
  /** What the last connection that reached an opening frame was carrying. */
299
293
  #lastGood = [];
300
294
  /** Consecutive connections that never reached an opening frame while watching. */
@@ -553,11 +547,14 @@ export class Subscription {
553
547
  const unwatchable = this.#watchLost.get(path) === 'unwatchable';
554
548
  if (unwatchable)
555
549
  this.#watchLost.delete(path);
556
- const retrying = this.#watchRefused.delete(path) || this.#shedCandidate === path || unwatchable;
557
- if (this.#shedCandidate === path)
558
- this.#shedCandidate = undefined;
550
+ // A fresh request supersedes an in-flight diagnosis. The next connection
551
+ // carries the newly requested set and, if needed, diagnoses that set from
552
+ // the beginning rather than applying a conclusion to a set that changed.
553
+ const recovering = this.#watchRecovery !== undefined;
554
+ if (recovering)
555
+ this.#watchRecovery = undefined;
556
+ const retrying = this.#watchRefused.delete(path) || recovering || unwatchable;
559
557
  this.#upgradeFailures = 0;
560
- this.#shedRuledOut = false;
561
558
  const at = this.#watches.indexOf(path);
562
559
  if (at >= 0) {
563
560
  // Most recently asked about goes last, so the eviction below always takes
@@ -586,18 +583,10 @@ export class Subscription {
586
583
  this.#everArmed.delete(evicted);
587
584
  this.#undisclosedRearm.delete(evicted);
588
585
  this.#watchRefused.delete(evicted);
589
- // An experiment about a tree nobody nominates any more has nothing left
590
- // to prove, and letting it finish would file a refusal against a path
591
- // this stream is no longer asking for.
592
- if (this.#shedCandidate === evicted)
593
- this.#shedCandidate = undefined;
594
- // The arm generation is deliberately NOT deleted. It has to stay
595
- // monotonic per path, because a waiter parked on this tree is holding a
596
- // number from before the eviction: reset to zero and re-nominated, the
597
- // tree would come back at one and that waiter would read an eviction as
598
- // a re-arm — "reporting starts here, re-read the tree" about a tree that
599
- // had simply been taken away from it. Eviction is told by membership,
600
- // which is what `nominates` is for.
586
+ // Historical paths retain no metadata. Generation identities come from
587
+ // a subscription-wide counter, so deleting this entry still cannot let
588
+ // an older waiter confuse a later re-nomination with its original arm.
589
+ this.#armGen.delete(evicted);
601
590
  }
602
591
  }
603
592
  this.#reopen();
@@ -659,7 +648,12 @@ export class Subscription {
659
648
  this.touch();
660
649
  const attached = !this.#read;
661
650
  this.#read = true;
662
- const from = this.resolveFrom(opts.since);
651
+ // The one place a gap the `since` cursor implies is turned into a standing
652
+ // loss, because this is the call that hands it to the model. Establishing it
653
+ // in the placement itself would stamp it on the subscription from every
654
+ // preview — and a preview whose caller then cancels, or a concurrent read on
655
+ // the same subscription, would take the gap away with it.
656
+ const { from, loss: standing } = this.#place(opts.since);
663
657
  const end = opts.through !== undefined ? opts.through + 1 : this.#nextIndex;
664
658
  const window = this.#ring.filter((b) => b.index >= from && b.index < end);
665
659
  // The OLDEST `limit`, not the newest, and the position advances only over
@@ -690,12 +684,12 @@ export class Subscription {
690
684
  // An unknown count plus a known one is still unknown. Adding the two
691
685
  // would report a precise number for a hole nobody can measure, which
692
686
  // is the one thing a loss report must not do.
693
- events: this.#loss?.events === null ? null : (this.#loss?.events ?? 0) + omitted,
694
- reason: this.#loss
695
- ? `${this.#loss.reason}; and ${omitted} more than limit allowed were stepped over to reach the event you waited for`
687
+ events: standing?.events === null ? null : (standing?.events ?? 0) + omitted,
688
+ reason: standing
689
+ ? `${standing.reason}; and ${omitted} more than limit allowed were stepped over to reach the event you waited for`
696
690
  : `${omitted} events older than the one you waited for did not fit in limit and were stepped over`,
697
691
  }
698
- : this.#loss;
692
+ : standing;
699
693
  this.#loss = undefined;
700
694
  return {
701
695
  events: batch.map((b) => b.event),
@@ -711,12 +705,17 @@ export class Subscription {
711
705
  *
712
706
  * Asked before {@link read}, because reconciliation awaits network reads and
713
707
  * a caller can cancel during them. Consuming first would advance the shared
714
- * cursor and clear the loss into a response that caller never receives. This
715
- * preview may establish a loss for an unknown `since`, but consumes nothing.
708
+ * cursor and clear the loss into a response that caller never receives.
709
+ *
710
+ * A pure preview, and that is load-bearing rather than tidiness. It sees the
711
+ * gap an unplaceable `since` implies, but it does not record one: a subscription
712
+ * is shared by every call on its computer, so a predicate that stamped `#loss`
713
+ * would hand that gap to whichever call read next — the cancelled caller's hole
714
+ * reported against a later read that named a different cursor, or none at all.
716
715
  */
717
716
  needsReconciliation(opts) {
718
- const from = this.resolveFrom(opts.since);
719
- if (this.#loss)
717
+ const { from, loss } = this.#place(opts.since);
718
+ if (loss)
720
719
  return true;
721
720
  if (opts.through === undefined)
722
721
  return false;
@@ -737,42 +736,75 @@ export class Subscription {
737
736
  * server holds, and saying so is the whole of the gap discipline — never a
738
737
  * frame the model has to interpret, always a sentence and, from the tool, the
739
738
  * state it would otherwise have gone to reconcile against.
739
+ *
740
+ * Reads nothing back into the subscription: see {@link #place}, which is where
741
+ * the sentence that gap discipline owes the model is composed, and {@link read},
742
+ * which is the only caller allowed to make it standing.
740
743
  */
741
744
  resolveFrom(since) {
745
+ return this.#place(since).from;
746
+ }
747
+ /**
748
+ * Placing a `since` cursor, and the loss a read from there would report.
749
+ *
750
+ * Pure. The returned `loss` is the standing loss MERGED with whatever this
751
+ * cursor implies, so a caller that reports it reports one hole rather than two
752
+ * overlapping accounts of the same evicted prefix — and a caller that only
753
+ * wanted the position, or only wanted to know whether there is a hole, leaves
754
+ * the subscription exactly as it found it.
755
+ */
756
+ #place(since) {
742
757
  // Where the model is, which is not where the socket is: it may be four
743
758
  // turns behind, and everything between the two is exactly what it has not
744
759
  // been handed yet.
745
- if (since === undefined)
746
- return Math.max(this.#delivered, this.#oldest);
760
+ if (since === undefined) {
761
+ return { from: Math.max(this.#delivered, this.#oldest), loss: this.#loss };
762
+ }
747
763
  const at = this.#ring.findIndex((b) => b.event.cursor === since);
748
764
  if (at >= 0)
749
- return this.#ring[at].index + 1;
765
+ return { from: this.#ring[at].index + 1, loss: this.#loss };
750
766
  // An opening cursor can outlive everything that followed it in the ring.
751
767
  // Evicting already delivered events is harmless for implicit reads, but a
752
768
  // deliberate rewind asks for those events again and must hear about the hole.
753
769
  if (since === this.#hello?.cursor) {
754
770
  const missing = Math.max(0, this.#oldest - this.#helloFrom);
755
- if (missing && this.#loss?.events !== null) {
756
- this.#loss = {
771
+ const loss = missing && this.#loss?.events !== null
772
+ ? {
757
773
  // Unread overflow and this rewind describe overlapping evicted
758
774
  // prefixes. Count their union, rather than adding the same loss twice.
759
775
  events: Math.max(missing, this.#loss?.events ?? 0),
760
776
  reason: 'events requested from that opening cursor were dropped from this session’s buffer',
761
- };
762
- }
763
- return Math.max(this.#helloFrom, this.#oldest);
777
+ }
778
+ : this.#loss;
779
+ return { from: Math.max(this.#helloFrom, this.#oldest), loss };
764
780
  }
781
+ // An empty `through` read can advance the delivery position to the oldest
782
+ // survivor without handing over an event. Its cursor is the predecessor
783
+ // retained during eviction, which is no longer in the ring but is still an
784
+ // exact, lossless place from which to resume those survivors.
785
+ if (since === this.#beforeOldestCursor)
786
+ return { from: this.#oldest, loss: this.#loss };
765
787
  // Otherwise: the unread frontier, NOT the oldest thing still in the ring.
766
788
  // A delivered event stays in the ring until the cap evicts it, so answering
767
789
  // an unplaceable cursor with `#oldest` re-sent events the model already
768
790
  // had — while attaching a loss note that said they "were not kept", which
769
791
  // was false about exactly the events being re-sent.
770
- this.#loss ??= {
771
- events: null,
772
- reason: 'that cursor is not a place this session can find, so whatever happened between it and ' +
773
- 'the events below was not kept here',
792
+ //
793
+ // `events: null` whatever else is standing, and that is the same rule
794
+ // {@link read} applies to its own overflow: a cursor this session cannot
795
+ // place may be any distance back, so a count taken from this buffer offered
796
+ // as the size of that hole would be a precise answer to a question nobody
797
+ // can measure. The standing reason is kept alongside, because an eviction
798
+ // here and an unplaceable cursor are two true things about one gap.
799
+ const unplaceable = 'that cursor is not a place this session can find, so whatever happened between it and ' +
800
+ 'the events below was not kept here';
801
+ return {
802
+ from: Math.max(this.#delivered, this.#oldest),
803
+ loss: {
804
+ events: null,
805
+ reason: this.#loss ? `${this.#loss.reason}; and ${unplaceable}` : unplaceable,
806
+ },
774
807
  };
775
- return Math.max(this.#delivered, this.#oldest);
776
808
  }
777
809
  /**
778
810
  * Wait for an event this predicate accepts, or for the deadline.
@@ -936,12 +968,16 @@ export class Subscription {
936
968
  const at = this.#ring.find((b) => b.index === this.#delivered - 1)?.event.cursor;
937
969
  if (typeof at === 'string' && at)
938
970
  return at;
971
+ if (this.#delivered === this.#oldest && this.#beforeOldestCursor) {
972
+ return this.#beforeOldestCursor;
973
+ }
939
974
  // Nothing in the ring to place it by: either nothing has been delivered on
940
975
  // this stream at all, or the event the position sits after has been evicted.
941
- // The last cursor DELIVERED before `#resume`, for the reason
942
- // {@link resumeCursor} gives — a position behind the truth is re-read, a
943
- // position ahead of it is a hole nothing reports.
944
- return this.#deliveredCursor ?? this.#resume ?? this.#hello?.cursor ?? '';
976
+ // The last cursor DELIVERED, or the frontier where this subscription
977
+ // started before anything has been handed over. Both follow the same rule
978
+ // as {@link resumeCursor}: a position behind the receive head may replay,
979
+ // while a position ahead of unread records silently skips them.
980
+ return this.#deliveredCursor ?? this.#start ?? this.#hello?.cursor ?? '';
945
981
  }
946
982
  #wakeAll() {
947
983
  for (const wake of [...this.#wake])
@@ -955,6 +991,9 @@ export class Subscription {
955
991
  this.#helloFrom = this.#nextIndex;
956
992
  if (this.#ring.length > MAX_BUFFERED) {
957
993
  const evicted = this.#ring.splice(0, this.#ring.length - MAX_BUFFERED);
994
+ const beforeOldest = evicted[evicted.length - 1]?.event.cursor;
995
+ this.#beforeOldestCursor =
996
+ typeof beforeOldest === 'string' && beforeOldest ? beforeOldest : undefined;
958
997
  // Only what the model had not been handed is a loss. Dropping events it
959
998
  // already read is the ring doing its job, and counting those would report
960
999
  // a hole where there is none.
@@ -1035,6 +1074,11 @@ export class Subscription {
1035
1074
  // tree, under an arming the caller is already waiting on.
1036
1075
  if (this.#renominate) {
1037
1076
  this.#renominate = false;
1077
+ // Recovery often closes a fallback or successful probe immediately
1078
+ // after its greeting. It still proved the host reachable, so discard
1079
+ // the failure backoff accumulated before that greeting.
1080
+ if (reached)
1081
+ backoff = BACKOFF_MS;
1038
1082
  continue;
1039
1083
  }
1040
1084
  // A connection that got as far as its opening frame is not a failure,
@@ -1232,12 +1276,17 @@ export class Subscription {
1232
1276
  // `watching` is measured against, and what says whether a nomination
1233
1277
  // made a moment ago is on this connection or on the next one.
1234
1278
  //
1235
- // The nominations MINUS whatever is being withheld: a tree proven to be
1236
- // one this host will not carry, and the one currently under suspicion
1237
- // for it. Both stay in `#watches`, because they are still what the
1238
- // caller asked for and the difference belongs on the wire rather than
1239
- // in this client's record of the request.
1240
- this.#sent = this.#watches.filter((w) => !this.#watchRefused.has(w) && w !== this.#shedCandidate);
1279
+ // Proven refusals stay off the wire. During diagnosis, the last working
1280
+ // set is sent alone or with exactly one candidate; all nominations stay
1281
+ // in `#watches`, because an experiment is not an eviction.
1282
+ const recovery = this.#watchRecovery;
1283
+ const included = recovery
1284
+ ? new Set([
1285
+ ...recovery.baseline,
1286
+ ...(recovery.phase === 'probe' && recovery.candidate ? [recovery.candidate] : []),
1287
+ ])
1288
+ : undefined;
1289
+ this.#sent = this.#watches.filter((w) => !this.#watchRefused.has(w) && (!included || included.has(w)));
1241
1290
  for (const w of this.#sent)
1242
1291
  target.searchParams.append('watch', w);
1243
1292
  socket = this.#socketFor(target.toString());
@@ -1330,6 +1379,8 @@ export class Subscription {
1330
1379
  const buffered = this.#ring.find((b) => b.event.cursor === hello.cursor);
1331
1380
  if (buffered)
1332
1381
  this.#helloFrom = buffered.index + 1;
1382
+ else if (hello.cursor === this.#beforeOldestCursor)
1383
+ this.#helloFrom = this.#oldest;
1333
1384
  else if (hello.cursor !== this.#hello?.cursor)
1334
1385
  this.#helloFrom = this.#nextIndex;
1335
1386
  // The same greeting on a reconnect must retain its original frontier,
@@ -1341,7 +1392,7 @@ export class Subscription {
1341
1392
  // could be observed from otherwise — and a connection that WORKS does not
1342
1393
  // end for minutes or hours. An experiment whose positive result was only
1343
1394
  // read on the way out is an experiment with no result.
1344
- this.#cleared();
1395
+ const continueWatchRecovery = this.#cleared();
1345
1396
  this.#adoptWatching(hello.watching);
1346
1397
  this.#resume ??= hello.cursor || undefined;
1347
1398
  this.#start ??= hello.cursor || undefined;
@@ -1362,6 +1413,12 @@ export class Subscription {
1362
1413
  // that is a new session. One extra readiness is the cheaper wrong answer.
1363
1414
  if (hello.ready && !resuming)
1364
1415
  this.#pushReady(hello.cursor);
1416
+ // Advance only after this greeting has been fully adopted. Closing in
1417
+ // `#cleared` would let its remaining work set `open` and armed state after
1418
+ // the socket had already finished, briefly making an intermediate probe
1419
+ // look like the connection about to replace it.
1420
+ if (continueWatchRecovery)
1421
+ this.#reopen();
1365
1422
  this.#wakeAll();
1366
1423
  }
1367
1424
  /**
@@ -1409,6 +1466,11 @@ export class Subscription {
1409
1466
  }
1410
1467
  sent.forEach((nominated, i) => {
1411
1468
  const w = watching[i];
1469
+ // Keep the original index: `watching[i]` answers `sent[i]`, even when an
1470
+ // earlier nomination was evicted while this connection was closing.
1471
+ // The evicted entry itself must write nothing back into active metadata.
1472
+ if (!this.#watches.includes(nominated))
1473
+ return;
1412
1474
  if (w.path !== nominated)
1413
1475
  this.#hostName.set(nominated, w.path);
1414
1476
  armed.set(nominated, w.armed);
@@ -1453,56 +1515,112 @@ export class Subscription {
1453
1515
  * reconnecting forever with nothing ever saying why.
1454
1516
  *
1455
1517
  * But a host that is down fails in exactly the same way, so two missed
1456
- * handshakes are not proof of anything. The experiment is: drop the newest
1457
- * tree and try again WITHOUT it. If that connection greets, the tree was the
1458
- * problem and this server can say so. If it fails too, the tree was innocent
1459
- * — it goes back, and nothing is claimed about the host beyond its being
1460
- * unreachable, which the reconnect loop was already handling.
1518
+ * handshakes are not proof of anything. Recovery first returns to the last
1519
+ * set that opened. If that fallback opens, each new nomination is added back
1520
+ * alone. A candidate that fails is withheld once more and blamed only when
1521
+ * the known-good set opens again. If that set also fails, recovery keeps it
1522
+ * as the reconnect target and retests the candidate after the host returns.
1461
1523
  */
1462
1524
  #cleared() {
1463
- // Whatever was withheld is now proven guilty: this is the same stream
1464
- // without it, and it opened.
1465
- if (this.#shedCandidate !== undefined) {
1466
- this.#watchRefused.add(this.#shedCandidate);
1467
- this.#shedCandidate = undefined;
1525
+ const recovery = this.#watchRecovery;
1526
+ if (!recovery) {
1527
+ this.#lastGood = [...this.#sent];
1528
+ this.#upgradeFailures = 0;
1529
+ return false;
1530
+ }
1531
+ // A `fallback` that opens proves only that the host is up and that the last
1532
+ // working set still works. It is deliberately NOT a branch here, not even
1533
+ // for a single addition: the full set failed twice, and a host that was down
1534
+ // for those two attempts fails exactly as a refused nomination does, so the
1535
+ // one thing the fallback opening cannot tell us is which of the two it was.
1536
+ // Filing the only pending tree as refused from here read an outage as a
1537
+ // refusal — the addition fell out of the watch set, and the model was told
1538
+ // this host would not carry a tree it would have carried. The probe below
1539
+ // costs one round trip and answers the question instead of guessing at it.
1540
+ if (recovery.phase === 'recover') {
1541
+ // A fallback failed too, so the outage made the preceding evidence
1542
+ // ambiguous. Now that the known-good set is back, test that candidate
1543
+ // again instead of filing a refusal from before the outage.
1544
+ recovery.candidate ??= recovery.pending.shift();
1545
+ if (recovery.candidate !== undefined) {
1546
+ recovery.phase = 'probe';
1547
+ this.#lastGood = [...recovery.baseline];
1548
+ this.#upgradeFailures = 0;
1549
+ return true;
1550
+ }
1551
+ }
1552
+ else if (recovery.phase === 'probe' && recovery.candidate !== undefined) {
1553
+ // This candidate opened beside the known-good set, so retain it while the
1554
+ // remaining additions are tested.
1555
+ recovery.baseline.push(recovery.candidate);
1468
1556
  }
1469
- this.#lastGood = [...this.#sent];
1557
+ else if (recovery.phase === 'confirm' && recovery.candidate !== undefined) {
1558
+ // The candidate failed beside this set and the same set has now reopened
1559
+ // without it. Only here is a refusal proven.
1560
+ this.#watchRefused.add(recovery.candidate);
1561
+ }
1562
+ recovery.candidate = undefined;
1563
+ const next = recovery.pending.shift();
1564
+ if (next !== undefined) {
1565
+ recovery.candidate = next;
1566
+ recovery.phase = 'probe';
1567
+ this.#lastGood = [...recovery.baseline];
1568
+ this.#upgradeFailures = 0;
1569
+ return true;
1570
+ }
1571
+ this.#lastGood = [...recovery.baseline];
1572
+ this.#watchRecovery = undefined;
1470
1573
  this.#upgradeFailures = 0;
1471
- this.#shedRuledOut = false;
1574
+ return false;
1472
1575
  }
1473
1576
  /** @see {@link #cleared} — the other half, for a connection that never greeted. */
1474
1577
  #blamed() {
1475
- if (this.#shedCandidate !== undefined) {
1476
- // The experiment came back negative. The tree goes back on the URL and
1477
- // watches stop being blamed until something connects — otherwise a host
1478
- // that is simply down would shed its way through every tree in the set,
1479
- // reporting each in turn as one the host would not carry.
1480
- this.#shedCandidate = undefined;
1481
- this.#shedRuledOut = true;
1578
+ const recovery = this.#watchRecovery;
1579
+ if (recovery?.phase === 'probe') {
1580
+ // A failed probe alone is not proof: the host may have gone down after
1581
+ // the fallback opened. Retry the known-good set before blaming the tree.
1582
+ recovery.phase = 'confirm';
1482
1583
  this.#upgradeFailures = 0;
1483
1584
  this.#wakeAll();
1484
1585
  return;
1485
1586
  }
1486
- if (!this.#sent.length || this.#shedRuledOut)
1587
+ if (recovery) {
1588
+ // The known-good set failed too, so this is a host outage rather than
1589
+ // evidence against a watch. Keep reconnecting that set; once it opens,
1590
+ // the ambiguous candidate is probed again from fresh evidence.
1591
+ recovery.phase = 'recover';
1592
+ this.#upgradeFailures = 0;
1593
+ this.#wakeAll();
1594
+ return;
1595
+ }
1596
+ if (!this.#sent.length)
1487
1597
  return;
1488
1598
  if (++this.#upgradeFailures < WATCH_SHED_AFTER)
1489
1599
  return;
1490
1600
  this.#upgradeFailures = 0;
1491
- // Whatever changed since this stream last worked, and only then the newest.
1492
- // After an LRU refresh "newest" means "most recently asked about", which is
1493
- // the opposite of a good suspect: the tree a caller keeps asking about is
1494
- // the one least likely to be new. What the last connection that actually
1495
- // greeted was carrying is the real before-and-after.
1496
1601
  const carried = this.#sent;
1497
- this.#shedCandidate =
1498
- carried.find((w) => !this.#lastGood.includes(w)) ?? carried[carried.length - 1];
1602
+ const baseline = this.#lastGood.filter((watch) => carried.includes(watch) && !this.#watchRefused.has(watch));
1603
+ let pending = carried.filter((watch) => !baseline.includes(watch));
1604
+ // If an unchanged set stops opening, retain the old single-watch experiment:
1605
+ // remove one tree and see whether the rest still opens. This can detect a
1606
+ // watch the host stopped accepting without reading an outage as a refusal.
1607
+ if (!pending.length) {
1608
+ const candidate = carried[carried.length - 1];
1609
+ pending = [candidate];
1610
+ baseline.splice(baseline.indexOf(candidate), 1);
1611
+ }
1612
+ this.#watchRecovery = { baseline, pending, phase: 'fallback' };
1499
1613
  this.#wakeAll();
1500
1614
  }
1501
1615
  #bumpArm(path) {
1502
- this.#armGen.set(path, (this.#armGen.get(path) ?? 0) + 1);
1616
+ if (!this.#watches.includes(path))
1617
+ return;
1618
+ this.#armGen.set(path, ++this.#nextArmGen);
1503
1619
  }
1504
1620
  /** Record an arm, retaining later arms until a file wait explains their gap. */
1505
1621
  #armedTransition(path) {
1622
+ if (!this.#watches.includes(path))
1623
+ return;
1506
1624
  if (this.#everArmed.has(path))
1507
1625
  this.#undisclosedRearm.add(path);
1508
1626
  else