mandala-computer-mcp 0.1.1 → 0.4.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 (72) hide show
  1. package/README.md +139 -24
  2. package/dist/api.d.ts +19 -6
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +355 -76
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +113 -8
  8. package/dist/cli.js.map +1 -1
  9. package/dist/errors.d.ts +100 -12
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +169 -29
  12. package/dist/errors.js.map +1 -1
  13. package/dist/events.d.ts +45 -4
  14. package/dist/events.d.ts.map +1 -1
  15. package/dist/events.js +422 -114
  16. package/dist/events.js.map +1 -1
  17. package/dist/format.d.ts +48 -0
  18. package/dist/format.d.ts.map +1 -1
  19. package/dist/format.js +111 -4
  20. package/dist/format.js.map +1 -1
  21. package/dist/http-body.d.ts +17 -0
  22. package/dist/http-body.d.ts.map +1 -0
  23. package/dist/http-body.js +48 -0
  24. package/dist/http-body.js.map +1 -0
  25. package/dist/http.d.ts.map +1 -1
  26. package/dist/http.js +177 -51
  27. package/dist/http.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/limits.d.ts +17 -0
  33. package/dist/limits.d.ts.map +1 -0
  34. package/dist/limits.js +17 -0
  35. package/dist/limits.js.map +1 -0
  36. package/dist/paths.d.ts +30 -20
  37. package/dist/paths.d.ts.map +1 -1
  38. package/dist/paths.js +89 -25
  39. package/dist/paths.js.map +1 -1
  40. package/dist/poll.d.ts +107 -0
  41. package/dist/poll.d.ts.map +1 -0
  42. package/dist/poll.js +233 -0
  43. package/dist/poll.js.map +1 -0
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +2 -1
  47. package/dist/server.js.map +1 -1
  48. package/dist/tools/agent.d.ts.map +1 -1
  49. package/dist/tools/agent.js +72 -5
  50. package/dist/tools/agent.js.map +1 -1
  51. package/dist/tools/computers.d.ts.map +1 -1
  52. package/dist/tools/computers.js +559 -233
  53. package/dist/tools/computers.js.map +1 -1
  54. package/dist/tools/events.d.ts.map +1 -1
  55. package/dist/tools/events.js +359 -69
  56. package/dist/tools/events.js.map +1 -1
  57. package/dist/tools/guest.d.ts.map +1 -1
  58. package/dist/tools/guest.js +234 -33
  59. package/dist/tools/guest.js.map +1 -1
  60. package/dist/tools/input.d.ts.map +1 -1
  61. package/dist/tools/input.js +92 -8
  62. package/dist/tools/input.js.map +1 -1
  63. package/dist/tools/snapshots.d.ts.map +1 -1
  64. package/dist/tools/snapshots.js +501 -33
  65. package/dist/tools/snapshots.js.map +1 -1
  66. package/dist/tools/templates.d.ts.map +1 -1
  67. package/dist/tools/templates.js +61 -26
  68. package/dist/tools/templates.js.map +1 -1
  69. package/dist/tools/webhooks.d.ts.map +1 -1
  70. package/dist/tools/webhooks.js +116 -17
  71. package/dist/tools/webhooks.js.map +1 -1
  72. package/package.json +3 -2
package/dist/events.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * What a computer is doing, held open on this side of the model.
3
3
  *
4
- * `GET computers/:id/events` (platform OPL-3785) is a websocket that says what
4
+ * `GET computers/:id/events` (OPL-3785) is a websocket that says what
5
5
  * a computer is doing without being asked, so that an agent stops paying for a
6
6
  * screenshot to learn that nothing has changed. Every other client of that
7
7
  * stream hands it to its caller as an iterator, because every other caller sits
@@ -25,13 +25,13 @@
25
25
  * doorbell an addition to this file rather than a replacement for it, and one
26
26
  * whose bell nothing on the other end rings yet.
27
27
  *
28
- * Written against the `events_url` entry in the platform's `web/lib/apidoc.ts`,
29
- * which is the reference this must not contradict.
28
+ * Written against the `events_url` entry in the platform's API reference,
29
+ * which is what this must not contradict.
30
30
  */
31
31
  import { posix } from 'node:path';
32
32
  import { WebSocket as UndiciWebSocket } from 'undici';
33
33
  import { isTransientForPoll, MandalaError } from './errors.js';
34
- import { unwrapComputer } from './format.js';
34
+ import { nothingAdmitted, unwrapComputer } from './format.js';
35
35
  import * as P from './paths.js';
36
36
  /**
37
37
  * undici's `WebSocket`, not Node's global one.
@@ -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;
@@ -196,6 +198,8 @@ export class Subscription {
196
198
  /** The cursor after the last event actually handed to the model. */
197
199
  #deliveredCursor;
198
200
  #hello;
201
+ /** The buffer position after the greeting's cursor, retained across eviction. */
202
+ #helloFrom = 0;
199
203
  #types;
200
204
  /**
201
205
  * The trees nominated on this stream, oldest nomination first.
@@ -238,6 +242,12 @@ export class Subscription {
238
242
  * changed.
239
243
  */
240
244
  #armGen = new Map();
245
+ /** Next subscription-wide arm identity; active paths alone retain theirs. */
246
+ #nextArmGen = 0;
247
+ /** Trees that have armed at least once in this subscription. */
248
+ #everArmed = new Set();
249
+ /** Re-arms a file-wait response has not explained yet. */
250
+ #undisclosedRearm = new Set();
241
251
  /**
242
252
  * The STANDING loss on each tree, cleared when it arms.
243
253
  *
@@ -266,29 +276,19 @@ export class Subscription {
266
276
  * Trees a connection carrying them would not open, PROVEN so.
267
277
  *
268
278
  * Proven means the experiment came back positive — see {@link #cleared}: the
269
- * tree was withheld, the same stream opened without it, and there is nothing
270
- * else the difference could be. A tree merely suspected is
271
- * {@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.
272
281
  */
273
282
  #watchRefused = new Set();
274
283
  /**
275
- * The tree currently withheld from the URL to find out whether it is the
276
- * reason nothing will open. `undefined` when no experiment is running.
284
+ * A bounded search for nominations the host will not carry.
277
285
  *
278
- * It stays in {@link #watches} throughout, which is what keeps a caller from
279
- * being told its tree was evicted to make room for somebody else's — this is
280
- * 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.
281
290
  */
282
- #shedCandidate;
283
- /**
284
- * Whether a watch has been ruled out as the reason connections are failing.
285
- *
286
- * Set when an experiment comes back negative, cleared the moment anything
287
- * greets. Without it a host that is simply DOWN would shed its way through
288
- * every tree in the set two failures at a time, reporting each in turn as one
289
- * the host would not carry.
290
- */
291
- #shedRuledOut = false;
291
+ #watchRecovery;
292
292
  /** What the last connection that reached an opening frame was carrying. */
293
293
  #lastGood = [];
294
294
  /** Consecutive connections that never reached an opening frame while watching. */
@@ -318,6 +318,8 @@ export class Subscription {
318
318
  */
319
319
  #sent = [];
320
320
  #greeted = false;
321
+ /** Monotonic so overlapping waits can each detect a connection interruption. */
322
+ #connectionVersion = 0;
321
323
  /**
322
324
  * Whether the socket now closing was closed by THIS side to re-nominate.
323
325
  *
@@ -384,6 +386,13 @@ export class Subscription {
384
386
  get state() {
385
387
  return this.#state;
386
388
  }
389
+ /** Cached capabilities survive a disconnect; this describes the current connection. */
390
+ get connected() {
391
+ return this.#greeted && this.#state.status === 'open';
392
+ }
393
+ get connectionVersion() {
394
+ return this.#connectionVersion;
395
+ }
387
396
  get idleMs() {
388
397
  return Date.now() - this.#lastUsed;
389
398
  }
@@ -421,6 +430,14 @@ export class Subscription {
421
430
  armGeneration(path) {
422
431
  return this.#armGen.get(path) ?? 0;
423
432
  }
433
+ /** Whether a completed re-arm still needs to be explained to this tree's caller. */
434
+ hasUndisclosedRearm(path) {
435
+ return this.#undisclosedRearm.has(path);
436
+ }
437
+ /** Claim a re-arm for the response that is about to explain it. */
438
+ takeUndisclosedRearm(path) {
439
+ return this.#undisclosedRearm.delete(path);
440
+ }
424
441
  /** The last thing this tree said it had lost, if it has said one since arming. */
425
442
  lostFor(path) {
426
443
  return this.#watchLost.get(path);
@@ -508,11 +525,36 @@ export class Subscription {
508
525
  // its tree limit, and the directory may since exist. "Call again" is the
509
526
  // advice every one of those refusals gives, so calling again has to mean
510
527
  // something.
511
- const retrying = this.#watchRefused.delete(path) || this.#shedCandidate === path;
512
- if (this.#shedCandidate === path)
513
- this.#shedCandidate = undefined;
528
+ // `unwatchable` belongs in this list and was the one condition missing from
529
+ // it. It is the only standing loss a nomination can lift — the guest decides
530
+ // watchability when it is ASKED, which is at nomination time, so a directory
531
+ // that has since been created is answered for only by asking again. Left
532
+ // out, the tree stayed in `#sent`, the early return below skipped the
533
+ // reopen, and the clearing on the next connection's `hello` was unreachable:
534
+ // `wait_for_file_change` refused every later call on a tree that was by then
535
+ // perfectly watchable, while its own refusal ended by promising that calling
536
+ // again would find it armed.
537
+ //
538
+ // Deleted here, exactly as `#watchRefused` is, and for the same reason: the
539
+ // reopen below IS the act of asking again, so the old answer must not still
540
+ // be standing while the new one is in flight. Left in place it reopens and
541
+ // then defeats itself — `armedWait` returns on a standing `unwatchable`
542
+ // before the new connection can greet, so the retry costs a round trip and
543
+ // changes nothing. Nothing is carried forward wrongly by dropping it: a
544
+ // guest whose directory is still missing answers `unwatchable` again on the
545
+ // connection this opens, and a reopen that never greets leaves the wait to
546
+ // end on its own deadline, which is what the refused retry beside it does.
547
+ const unwatchable = this.#watchLost.get(path) === 'unwatchable';
548
+ if (unwatchable)
549
+ this.#watchLost.delete(path);
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;
514
557
  this.#upgradeFailures = 0;
515
- this.#shedRuledOut = false;
516
558
  const at = this.#watches.indexOf(path);
517
559
  if (at >= 0) {
518
560
  // Most recently asked about goes last, so the eviction below always takes
@@ -525,8 +567,7 @@ export class Subscription {
525
567
  // reopen for. A tree being retried is neither.
526
568
  if (!retrying && this.#sent.includes(path))
527
569
  return {};
528
- this.#renominate = true;
529
- this.#socket?.close();
570
+ this.#reopen();
530
571
  this.#wakeAll();
531
572
  return {};
532
573
  }
@@ -539,29 +580,42 @@ export class Subscription {
539
580
  this.#watchLost.delete(evicted);
540
581
  this.#hostName.delete(evicted);
541
582
  this.#interrupted.delete(evicted);
583
+ this.#everArmed.delete(evicted);
584
+ this.#undisclosedRearm.delete(evicted);
542
585
  this.#watchRefused.delete(evicted);
543
- // An experiment about a tree nobody nominates any more has nothing left
544
- // to prove, and letting it finish would file a refusal against a path
545
- // this stream is no longer asking for.
546
- if (this.#shedCandidate === evicted)
547
- this.#shedCandidate = undefined;
548
- // The arm generation is deliberately NOT deleted. It has to stay
549
- // monotonic per path, because a waiter parked on this tree is holding a
550
- // number from before the eviction: reset to zero and re-nominated, the
551
- // tree would come back at one and that waiter would read an eviction as
552
- // a re-arm — "reporting starts here, re-read the tree" about a tree that
553
- // had simply been taken away from it. Eviction is told by membership,
554
- // 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);
555
590
  }
556
591
  }
557
- // Closed rather than aborted: `#loop` is still running and its next turn
558
- // reads `#watches` for the new connection. The flag is what keeps that turn
559
- // from paying the reconnect floor for a reconnection this side chose.
560
- this.#renominate = true;
561
- this.#socket?.close();
592
+ this.#reopen();
562
593
  this.#wakeAll();
563
594
  return { evicted };
564
595
  }
596
+ /**
597
+ * End the current connection so the next one carries the new watch set.
598
+ *
599
+ * Closed rather than aborted: `#loop` is still running and its next turn
600
+ * reads `#watches` for the new connection. `#renominate` is what keeps that
601
+ * turn from paying the reconnect floor for a reconnection this side chose.
602
+ *
603
+ * The flag is set ONLY when there is a socket to close, because that is what
604
+ * it claims: the connection now ending was ended by this side. Between
605
+ * connections there is none — `finish()` clears `#socket` the moment one
606
+ * ends, and the loop then spends a `GET /computers/:id` and a sleep before
607
+ * assigning the next — and a flag set in that window is consumed by the
608
+ * outcome of a connection this side did not cut short. That connection's
609
+ * failure to reach its opening frame is genuine evidence about the host, and
610
+ * swallowing it skips the backoff step, the `#blamed()` that would eventually
611
+ * shed the tree responsible, and the floor in front of the immediate retry.
612
+ */
613
+ #reopen() {
614
+ if (!this.#socket)
615
+ return;
616
+ this.#renominate = true;
617
+ this.#socket.close();
618
+ }
565
619
  /** Open the socket, if it is not already open. Returns at once. */
566
620
  start() {
567
621
  if (this.#running)
@@ -594,7 +648,12 @@ export class Subscription {
594
648
  this.touch();
595
649
  const attached = !this.#read;
596
650
  this.#read = true;
597
- 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);
598
657
  const end = opts.through !== undefined ? opts.through + 1 : this.#nextIndex;
599
658
  const window = this.#ring.filter((b) => b.index >= from && b.index < end);
600
659
  // The OLDEST `limit`, not the newest, and the position advances only over
@@ -625,12 +684,12 @@ export class Subscription {
625
684
  // An unknown count plus a known one is still unknown. Adding the two
626
685
  // would report a precise number for a hole nobody can measure, which
627
686
  // is the one thing a loss report must not do.
628
- events: this.#loss?.events === null ? null : (this.#loss?.events ?? 0) + omitted,
629
- reason: this.#loss
630
- ? `${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`
631
690
  : `${omitted} events older than the one you waited for did not fit in limit and were stepped over`,
632
691
  }
633
- : this.#loss;
692
+ : standing;
634
693
  this.#loss = undefined;
635
694
  return {
636
695
  events: batch.map((b) => b.event),
@@ -641,6 +700,33 @@ export class Subscription {
641
700
  attached,
642
701
  };
643
702
  }
703
+ /**
704
+ * Whether this read needs the current computer state attached to its loss.
705
+ *
706
+ * Asked before {@link read}, because reconciliation awaits network reads and
707
+ * a caller can cancel during them. Consuming first would advance the shared
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.
715
+ */
716
+ needsReconciliation(opts) {
717
+ const { from, loss } = this.#place(opts.since);
718
+ if (loss)
719
+ return true;
720
+ if (opts.through === undefined)
721
+ return false;
722
+ const end = opts.through + 1;
723
+ let count = 0;
724
+ for (const buffered of this.#ring) {
725
+ if (buffered.index >= from && buffered.index < end)
726
+ count++;
727
+ }
728
+ return count > opts.limit;
729
+ }
644
730
  /**
645
731
  * Where a read or a wait starts: the model's own place, or the cursor it named.
646
732
  *
@@ -650,33 +736,75 @@ export class Subscription {
650
736
  * server holds, and saying so is the whole of the gap discipline — never a
651
737
  * frame the model has to interpret, always a sentence and, from the tool, the
652
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.
653
743
  */
654
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) {
655
757
  // Where the model is, which is not where the socket is: it may be four
656
758
  // turns behind, and everything between the two is exactly what it has not
657
759
  // been handed yet.
658
- if (since === undefined)
659
- return Math.max(this.#delivered, this.#oldest);
760
+ if (since === undefined) {
761
+ return { from: Math.max(this.#delivered, this.#oldest), loss: this.#loss };
762
+ }
660
763
  const at = this.#ring.findIndex((b) => b.event.cursor === since);
661
764
  if (at >= 0)
662
- return this.#ring[at].index + 1;
663
- // The position at the moment this connection attached, which is
664
- // legitimately older than anything in the ring on a quiet computer. The one
665
- // deliberate rewind: a caller asking for it is asking to be re-sent this
666
- // connection's whole buffer, and saying so exactly.
667
- if (since === this.#hello?.cursor)
668
- return this.#oldest;
765
+ return { from: this.#ring[at].index + 1, loss: this.#loss };
766
+ // An opening cursor can outlive everything that followed it in the ring.
767
+ // Evicting already delivered events is harmless for implicit reads, but a
768
+ // deliberate rewind asks for those events again and must hear about the hole.
769
+ if (since === this.#hello?.cursor) {
770
+ const missing = Math.max(0, this.#oldest - this.#helloFrom);
771
+ const loss = missing && this.#loss?.events !== null
772
+ ? {
773
+ // Unread overflow and this rewind describe overlapping evicted
774
+ // prefixes. Count their union, rather than adding the same loss twice.
775
+ events: Math.max(missing, this.#loss?.events ?? 0),
776
+ reason: 'events requested from that opening cursor were dropped from this session’s buffer',
777
+ }
778
+ : this.#loss;
779
+ return { from: Math.max(this.#helloFrom, this.#oldest), loss };
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 };
669
787
  // Otherwise: the unread frontier, NOT the oldest thing still in the ring.
670
788
  // A delivered event stays in the ring until the cap evicts it, so answering
671
789
  // an unplaceable cursor with `#oldest` re-sent events the model already
672
790
  // had — while attaching a loss note that said they "were not kept", which
673
791
  // was false about exactly the events being re-sent.
674
- this.#loss ??= {
675
- events: null,
676
- reason: 'that cursor is not a place this session can find, so whatever happened between it and ' +
677
- '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
+ },
678
807
  };
679
- return Math.max(this.#delivered, this.#oldest);
680
808
  }
681
809
  /**
682
810
  * Wait for an event this predicate accepts, or for the deadline.
@@ -814,12 +942,42 @@ export class Subscription {
814
942
  cancel?.addEventListener('abort', done, { once: true });
815
943
  });
816
944
  }
817
- /** The cursor for a position, or the connection's own when nothing was read. */
945
+ /**
946
+ * The cursor for a position — WHERE THE CALLER IS, which is not always where
947
+ * the socket is.
948
+ *
949
+ * The last event handed over answers it whenever there was one. When there was
950
+ * not, the answer is still the model's own place and not `#resume`, which is
951
+ * after the last event this connection RECEIVED. The two are the same on an
952
+ * ordinary empty poll, and that is why the difference went unnoticed: no
953
+ * `through`, an empty window means the position has caught up with the ring.
954
+ *
955
+ * A `through` read is the case where they part. A wait resolves on an event
956
+ * and reads up to it, and by then another call on the same computer — which
957
+ * these tools are built to allow — may already have taken it: the window comes
958
+ * back empty while `more` is still positive. Answering `#resume` there hands
959
+ * back `more_waiting: N` beside a cursor that, passed in as `since`, resolves
960
+ * PAST those N events, with no loss to say they were skipped. So the position
961
+ * is read off the ring at `#delivered`, which is the one place that agrees
962
+ * with the `more` in the same answer.
963
+ */
818
964
  #position(last) {
819
965
  const cursor = last?.event.cursor;
820
966
  if (typeof cursor === 'string' && cursor)
821
967
  return cursor;
822
- return this.#resume ?? this.#hello?.cursor ?? '';
968
+ const at = this.#ring.find((b) => b.index === this.#delivered - 1)?.event.cursor;
969
+ if (typeof at === 'string' && at)
970
+ return at;
971
+ if (this.#delivered === this.#oldest && this.#beforeOldestCursor) {
972
+ return this.#beforeOldestCursor;
973
+ }
974
+ // Nothing in the ring to place it by: either nothing has been delivered on
975
+ // this stream at all, or the event the position sits after has been evicted.
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 ?? '';
823
981
  }
824
982
  #wakeAll() {
825
983
  for (const wake of [...this.#wake])
@@ -827,8 +985,15 @@ export class Subscription {
827
985
  }
828
986
  #push(event) {
829
987
  this.#ring.push({ index: this.#nextIndex++, event });
988
+ // A resumed stream may replay the greeting's cursor after the greeting.
989
+ // Once that event arrives, its position is stronger than the initial frontier.
990
+ if (event.cursor === this.#hello?.cursor)
991
+ this.#helloFrom = this.#nextIndex;
830
992
  if (this.#ring.length > MAX_BUFFERED) {
831
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;
832
997
  // Only what the model had not been handed is a loss. Dropping events it
833
998
  // already read is the ring doing its job, and counting those would report
834
999
  // a hole where there is none.
@@ -909,6 +1074,11 @@ export class Subscription {
909
1074
  // tree, under an arming the caller is already waiting on.
910
1075
  if (this.#renominate) {
911
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;
912
1082
  continue;
913
1083
  }
914
1084
  // A connection that got as far as its opening frame is not a failure,
@@ -966,22 +1136,55 @@ export class Subscription {
966
1136
  throw err;
967
1137
  }
968
1138
  const status = c.status ?? 'unknown';
969
- if (status === 'suspended') {
1139
+ // SETTLING IS FOREVER, so each of these has to be a state nobody is already
1140
+ // moving out of. `status` cannot say that on its own: it is read from the
1141
+ // guest process, and a start that has been ADMITTED has no process yet, so
1142
+ // it reads `stopped` through a cold boot and `suspended` through a resume —
1143
+ // whose session record is spent only on the way out of a start that worked.
1144
+ // A stream opened against a computer mid-resume used to die permanently and
1145
+ // name the resume as the reason (OPL-4631). nothingAdmitted is the
1146
+ // platform's own word for idle, and absence of the field is not that word.
1147
+ if (status === 'suspended' && nothingAdmitted(c)) {
970
1148
  throw new SettledError(`${this.computerId} suspended, and the event stream is the one part of this API that does ` +
971
1149
  'not resume a computer for you. Listening is not using, so a computer nobody touches ' +
972
1150
  'suspends underneath its own stream. start_computer, then ask again.');
973
1151
  }
974
- if (status === 'stopped' || status === 'build-failed') {
975
- throw new SettledError(`${this.computerId} is ${status}, and only a running computer has an event stream. ` +
1152
+ // Two terminal answers, and they are not the same answer. A disk that was
1153
+ // never finished is not started by anybody — the platform's own remedy is
1154
+ // "delete it and build it again" — so telling that caller to start_computer
1155
+ // sends them at a call that refuses them (Codex review).
1156
+ if (status === 'build-failed') {
1157
+ throw new SettledError(`${this.computerId}'s disk was never finished, so there is nothing to stream and nothing ` +
1158
+ 'to start. Delete it and build it again.');
1159
+ }
1160
+ if (status === 'stopped' && nothingAdmitted(c)) {
1161
+ throw new SettledError(`${this.computerId} is stopped, and only a running computer has an event stream. ` +
976
1162
  'start_computer, then ask again.');
977
1163
  }
1164
+ // A computer whose disk was unlinked by a deletion that stopped partway
1165
+ // will never start again — the reference says so, and says deleting it
1166
+ // again is what clears it — so it belongs with the terminal states rather
1167
+ // than in the backoff below (Codex review).
1168
+ if (status === 'half-removed') {
1169
+ throw new SettledError(`${this.computerId} is half-removed: its disk is gone, it will never start again, and ` +
1170
+ 'only deleting it again clears it. There is nothing to stream.');
1171
+ }
978
1172
  if (status !== 'running') {
979
- // `starting`, `moving`, `creating` — states that clear on their own, so
980
- // they get the backoff rather than the refusal. Settling on everything
981
- // that was not `running` broke the flow the README advertises: a
982
- // create_computer followed at once by wait_for_event("computer.ready")
983
- // meets `starting`, which is the ordinary weather of a machine coming up
984
- // and is precisely what the caller is waiting through.
1173
+ // What is left is a state that MAY clear, which is a weaker claim than
1174
+ // the one this comment used to make and the honest one: `building`; a
1175
+ // `stopped` or `suspended` computer whose start has been admitted and is
1176
+ // loading; and the two readings that are not answers at all — a status
1177
+ // this client does not know, and a stopped or suspended computer whose
1178
+ // pool the host did not report. Retrying those is right for the same
1179
+ // reason waiting is: nothing here has said the machine will not come up.
1180
+ // What it is not is a promise that one will.
1181
+ //
1182
+ // That last pair is what this branch was FOR, and it could not reach it.
1183
+ // The comment here used to name `starting`, `moving` and `creating`; the
1184
+ // platform reports none of them — its statuses are `running`, `stopped`,
1185
+ // `suspended`, `building`, `build-failed` and `half-removed` — so the
1186
+ // flow it described, a create_computer followed at once by
1187
+ // wait_for_event("computer.ready"), met `stopped` and settled above.
985
1188
  throw new MandalaError(`${this.computerId} is ${status}; waiting for it to be running`);
986
1189
  }
987
1190
  const vnc = c.vnc;
@@ -1073,12 +1276,17 @@ export class Subscription {
1073
1276
  // `watching` is measured against, and what says whether a nomination
1074
1277
  // made a moment ago is on this connection or on the next one.
1075
1278
  //
1076
- // The nominations MINUS whatever is being withheld: a tree proven to be
1077
- // one this host will not carry, and the one currently under suspicion
1078
- // for it. Both stay in `#watches`, because they are still what the
1079
- // caller asked for and the difference belongs on the wire rather than
1080
- // in this client's record of the request.
1081
- 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)));
1082
1290
  for (const w of this.#sent)
1083
1291
  target.searchParams.append('watch', w);
1084
1292
  socket = this.#socketFor(target.toString());
@@ -1100,6 +1308,8 @@ export class Subscription {
1100
1308
  // A connection that has ended is not carrying anything, whatever it was
1101
1309
  // greeted with. Without this the window between one connection ending
1102
1310
  // and the next opening reads as a tree still on the wire.
1311
+ if (opened)
1312
+ this.#connectionVersion++;
1103
1313
  this.#greeted = false;
1104
1314
  if (this.#socket === socket)
1105
1315
  this.#socket = undefined;
@@ -1166,6 +1376,15 @@ export class Subscription {
1166
1376
  windows: Array.isArray(frame.windows) ? frame.windows : undefined,
1167
1377
  watching: watched(frame.watching),
1168
1378
  };
1379
+ const buffered = this.#ring.find((b) => b.event.cursor === hello.cursor);
1380
+ if (buffered)
1381
+ this.#helloFrom = buffered.index + 1;
1382
+ else if (hello.cursor === this.#beforeOldestCursor)
1383
+ this.#helloFrom = this.#oldest;
1384
+ else if (hello.cursor !== this.#hello?.cursor)
1385
+ this.#helloFrom = this.#nextIndex;
1386
+ // The same greeting on a reconnect must retain its original frontier,
1387
+ // including any history that has since been evicted.
1169
1388
  this.#hello = hello;
1170
1389
  this.#types = hello.events;
1171
1390
  this.#greeted = true;
@@ -1173,7 +1392,7 @@ export class Subscription {
1173
1392
  // could be observed from otherwise — and a connection that WORKS does not
1174
1393
  // end for minutes or hours. An experiment whose positive result was only
1175
1394
  // read on the way out is an experiment with no result.
1176
- this.#cleared();
1395
+ const continueWatchRecovery = this.#cleared();
1177
1396
  this.#adoptWatching(hello.watching);
1178
1397
  this.#resume ??= hello.cursor || undefined;
1179
1398
  this.#start ??= hello.cursor || undefined;
@@ -1194,6 +1413,12 @@ export class Subscription {
1194
1413
  // that is a new session. One extra readiness is the cheaper wrong answer.
1195
1414
  if (hello.ready && !resuming)
1196
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();
1197
1422
  this.#wakeAll();
1198
1423
  }
1199
1424
  /**
@@ -1241,6 +1466,11 @@ export class Subscription {
1241
1466
  }
1242
1467
  sent.forEach((nominated, i) => {
1243
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;
1244
1474
  if (w.path !== nominated)
1245
1475
  this.#hostName.set(nominated, w.path);
1246
1476
  armed.set(nominated, w.armed);
@@ -1258,7 +1488,7 @@ export class Subscription {
1258
1488
  // would drop a permanent condition that nothing puts back. `hello.watching`
1259
1489
  // has no field for it, so the flag is the only record there is.
1260
1490
  if (w.armed && !this.isArmed(nominated)) {
1261
- this.#bumpArm(nominated);
1491
+ this.#armedTransition(nominated);
1262
1492
  this.#watchLost.delete(nominated);
1263
1493
  }
1264
1494
  else if (this.#watchLost.get(nominated) === 'unwatchable') {
@@ -1285,53 +1515,117 @@ export class Subscription {
1285
1515
  * reconnecting forever with nothing ever saying why.
1286
1516
  *
1287
1517
  * But a host that is down fails in exactly the same way, so two missed
1288
- * handshakes are not proof of anything. The experiment is: drop the newest
1289
- * tree and try again WITHOUT it. If that connection greets, the tree was the
1290
- * problem and this server can say so. If it fails too, the tree was innocent
1291
- * — it goes back, and nothing is claimed about the host beyond its being
1292
- * 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.
1293
1523
  */
1294
1524
  #cleared() {
1295
- // Whatever was withheld is now proven guilty: this is the same stream
1296
- // without it, and it opened.
1297
- if (this.#shedCandidate !== undefined) {
1298
- this.#watchRefused.add(this.#shedCandidate);
1299
- 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);
1556
+ }
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);
1300
1561
  }
1301
- this.#lastGood = [...this.#sent];
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;
1302
1573
  this.#upgradeFailures = 0;
1303
- this.#shedRuledOut = false;
1574
+ return false;
1304
1575
  }
1305
1576
  /** @see {@link #cleared} — the other half, for a connection that never greeted. */
1306
1577
  #blamed() {
1307
- if (this.#shedCandidate !== undefined) {
1308
- // The experiment came back negative. The tree goes back on the URL and
1309
- // watches stop being blamed until something connects — otherwise a host
1310
- // that is simply down would shed its way through every tree in the set,
1311
- // reporting each in turn as one the host would not carry.
1312
- this.#shedCandidate = undefined;
1313
- 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';
1583
+ this.#upgradeFailures = 0;
1584
+ this.#wakeAll();
1585
+ return;
1586
+ }
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';
1314
1592
  this.#upgradeFailures = 0;
1315
1593
  this.#wakeAll();
1316
1594
  return;
1317
1595
  }
1318
- if (!this.#sent.length || this.#shedRuledOut)
1596
+ if (!this.#sent.length)
1319
1597
  return;
1320
1598
  if (++this.#upgradeFailures < WATCH_SHED_AFTER)
1321
1599
  return;
1322
1600
  this.#upgradeFailures = 0;
1323
- // Whatever changed since this stream last worked, and only then the newest.
1324
- // After an LRU refresh "newest" means "most recently asked about", which is
1325
- // the opposite of a good suspect: the tree a caller keeps asking about is
1326
- // the one least likely to be new. What the last connection that actually
1327
- // greeted was carrying is the real before-and-after.
1328
1601
  const carried = this.#sent;
1329
- this.#shedCandidate =
1330
- 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' };
1331
1613
  this.#wakeAll();
1332
1614
  }
1333
1615
  #bumpArm(path) {
1334
- 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);
1619
+ }
1620
+ /** Record an arm, retaining later arms until a file wait explains their gap. */
1621
+ #armedTransition(path) {
1622
+ if (!this.#watches.includes(path))
1623
+ return;
1624
+ if (this.#everArmed.has(path))
1625
+ this.#undisclosedRearm.add(path);
1626
+ else
1627
+ this.#everArmed.add(path);
1628
+ this.#bumpArm(path);
1335
1629
  }
1336
1630
  /**
1337
1631
  * What a `file.changed` says about the TREE, as opposed to about a file.
@@ -1375,7 +1669,7 @@ export class Subscription {
1375
1669
  // clears the flag when the link goes down — and the wait that should have
1376
1670
  // said "re-read the tree" would have gone on waiting on a tree whose
1377
1671
  // history had a hole in it.
1378
- this.#bumpArm(watch);
1672
+ this.#armedTransition(watch);
1379
1673
  this.#armed.set(watch, true);
1380
1674
  return;
1381
1675
  }
@@ -1535,11 +1829,25 @@ export class EventHub {
1535
1829
  * resume — reaped for idleness, stopped on a computer somebody suspended —
1536
1830
  * and one there is nothing left to resume: a deleted computer's cursor names
1537
1831
  * a position in a stream that no longer exists.
1832
+ *
1833
+ * `expect` is the subscription the caller drained, and dropping BY IDENTITY
1834
+ * rather than by id is what makes this safe under the concurrency these tools
1835
+ * are designed for. Two calls on one computer overlap by design — a
1836
+ * `wait_for_event` parked while a `poll_events` runs — and each holds its own
1837
+ * handle. Without the check: A finds its stream stopped, drains it and drops
1838
+ * it; the model's next call opens a fresh, healthy S2; B, still holding the
1839
+ * stale S1, wakes, sees the status it recorded, drains an empty ring and
1840
+ * drops — closing S2, a stream nothing was wrong with, out from under whoever
1841
+ * had just opened it. Looked up by id, `drop` had no way to tell the two
1842
+ * apart. A caller with no handle to name (the idle sweep passes the entry it
1843
+ * is iterating) still drops whatever is there.
1538
1844
  */
1539
- drop(computerId, reason, remember = false) {
1845
+ drop(computerId, reason, remember = false, expect) {
1540
1846
  const sub = this.#subs.get(computerId);
1541
1847
  if (!sub)
1542
1848
  return;
1849
+ if (expect !== undefined && sub !== expect)
1850
+ return;
1543
1851
  const at = sub.resumeCursor;
1544
1852
  const watches = sub.nominations;
1545
1853
  sub.close(reason);
@@ -1580,7 +1888,7 @@ export class EventHub {
1580
1888
  // be reported once. The idle window is what eventually takes it.
1581
1889
  if (sub.idleMs < IDLE_REAP_MS)
1582
1890
  continue;
1583
- this.drop(id, 'nothing asked about this computer for five minutes', true);
1891
+ this.drop(id, 'nothing asked about this computer for five minutes', true, sub);
1584
1892
  }
1585
1893
  if (!this.#subs.size)
1586
1894
  this.#stopSweep();