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.
- package/README.md +462 -37
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +262 -42
- package/dist/api.js.map +1 -1
- package/dist/artifacts.d.ts +63 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +81 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +35 -21
- package/dist/cli.js.map +1 -1
- package/dist/credentials.d.ts +33 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +395 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +53 -10
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +122 -35
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +11 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +213 -95
- package/dist/events.js.map +1 -1
- package/dist/executions.d.ts +43 -0
- package/dist/executions.d.ts.map +1 -0
- package/dist/executions.js +162 -0
- package/dist/executions.js.map +1 -0
- package/dist/format.d.ts +11 -11
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +159 -16
- package/dist/format.js.map +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +4 -0
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +17 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +17 -0
- package/dist/limits.js.map +1 -0
- package/dist/paths.d.ts +42 -2
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +54 -3
- package/dist/paths.js.map +1 -1
- package/dist/poll.d.ts +5 -1
- package/dist/poll.d.ts.map +1 -1
- package/dist/poll.js +120 -16
- package/dist/poll.js.map +1 -1
- package/dist/results.d.ts +97 -0
- package/dist/results.d.ts.map +1 -0
- package/dist/results.js +263 -0
- package/dist/results.js.map +1 -0
- package/dist/server.d.ts +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +61 -9
- package/dist/server.js.map +1 -1
- package/dist/stdio.d.ts +5 -1
- package/dist/stdio.d.ts.map +1 -1
- package/dist/stdio.js +10 -4
- package/dist/stdio.js.map +1 -1
- package/dist/tool-filters.d.ts +38 -0
- package/dist/tool-filters.d.ts.map +1 -0
- package/dist/tool-filters.js +129 -0
- package/dist/tool-filters.js.map +1 -0
- package/dist/tools/account.d.ts +3 -0
- package/dist/tools/account.d.ts.map +1 -0
- package/dist/tools/account.js +122 -0
- package/dist/tools/account.js.map +1 -0
- package/dist/tools/activities.d.ts +3 -0
- package/dist/tools/activities.d.ts.map +1 -0
- package/dist/tools/activities.js +140 -0
- package/dist/tools/activities.js.map +1 -0
- package/dist/tools/agent.d.ts.map +1 -1
- package/dist/tools/agent.js +72 -5
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/artifacts.d.ts +3 -0
- package/dist/tools/artifacts.d.ts.map +1 -0
- package/dist/tools/artifacts.js +97 -0
- package/dist/tools/artifacts.js.map +1 -0
- package/dist/tools/chat.d.ts +6 -0
- package/dist/tools/chat.d.ts.map +1 -0
- package/dist/tools/chat.js +192 -0
- package/dist/tools/chat.js.map +1 -0
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +300 -247
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/directory.d.ts +13 -0
- package/dist/tools/directory.d.ts.map +1 -0
- package/dist/tools/directory.js +88 -0
- package/dist/tools/directory.js.map +1 -0
- package/dist/tools/events.d.ts.map +1 -1
- package/dist/tools/events.js +66 -22
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/executions.d.ts +3 -0
- package/dist/tools/executions.d.ts.map +1 -0
- package/dist/tools/executions.js +87 -0
- package/dist/tools/executions.js.map +1 -0
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +57 -17
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +63 -5
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/results.d.ts +30 -0
- package/dist/tools/results.d.ts.map +1 -0
- package/dist/tools/results.js +106 -0
- package/dist/tools/results.js.map +1 -0
- package/dist/tools/secrets.d.ts +58 -0
- package/dist/tools/secrets.d.ts.map +1 -0
- package/dist/tools/secrets.js +204 -0
- package/dist/tools/secrets.js.map +1 -0
- package/dist/tools/signals.d.ts +3 -0
- package/dist/tools/signals.d.ts.map +1 -0
- package/dist/tools/signals.js +116 -0
- package/dist/tools/signals.js.map +1 -0
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +213 -167
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/ssh.d.ts +3 -0
- package/dist/tools/ssh.d.ts.map +1 -0
- package/dist/tools/ssh.js +186 -0
- package/dist/tools/ssh.js.map +1 -0
- package/dist/tools/templates.d.ts.map +1 -1
- package/dist/tools/templates.js +9 -13
- package/dist/tools/templates.js.map +1 -1
- 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
|
|
276
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
285
|
-
*
|
|
286
|
-
* a
|
|
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
|
-
#
|
|
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
|
-
|
|
557
|
-
if
|
|
558
|
-
|
|
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
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
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
|
-
|
|
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:
|
|
694
|
-
reason:
|
|
695
|
-
? `${
|
|
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
|
-
:
|
|
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.
|
|
715
|
-
*
|
|
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
|
|
719
|
-
if (
|
|
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
|
-
|
|
756
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
|
|
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
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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.
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
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
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
this.#
|
|
1467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1574
|
+
return false;
|
|
1472
1575
|
}
|
|
1473
1576
|
/** @see {@link #cleared} — the other half, for a connection that never greeted. */
|
|
1474
1577
|
#blamed() {
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
|
|
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 (
|
|
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.#
|
|
1498
|
-
|
|
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
|
-
|
|
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
|