mandala-computer-mcp 0.3.0 → 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 (52) hide show
  1. package/README.md +12 -8
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +103 -28
  4. package/dist/api.js.map +1 -1
  5. package/dist/errors.d.ts +26 -3
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +54 -4
  8. package/dist/errors.js.map +1 -1
  9. package/dist/events.d.ts +11 -2
  10. package/dist/events.d.ts.map +1 -1
  11. package/dist/events.js +213 -95
  12. package/dist/events.js.map +1 -1
  13. package/dist/format.d.ts.map +1 -1
  14. package/dist/format.js +80 -3
  15. package/dist/format.js.map +1 -1
  16. package/dist/limits.d.ts +17 -0
  17. package/dist/limits.d.ts.map +1 -0
  18. package/dist/limits.js +17 -0
  19. package/dist/limits.js.map +1 -0
  20. package/dist/paths.d.ts +2 -2
  21. package/dist/paths.d.ts.map +1 -1
  22. package/dist/paths.js +4 -3
  23. package/dist/paths.js.map +1 -1
  24. package/dist/poll.d.ts +5 -1
  25. package/dist/poll.d.ts.map +1 -1
  26. package/dist/poll.js +120 -16
  27. package/dist/poll.js.map +1 -1
  28. package/dist/server.d.ts +1 -1
  29. package/dist/server.d.ts.map +1 -1
  30. package/dist/server.js +2 -1
  31. package/dist/server.js.map +1 -1
  32. package/dist/tools/agent.d.ts.map +1 -1
  33. package/dist/tools/agent.js +58 -3
  34. package/dist/tools/agent.js.map +1 -1
  35. package/dist/tools/computers.d.ts.map +1 -1
  36. package/dist/tools/computers.js +282 -240
  37. package/dist/tools/computers.js.map +1 -1
  38. package/dist/tools/events.d.ts.map +1 -1
  39. package/dist/tools/events.js +66 -22
  40. package/dist/tools/events.js.map +1 -1
  41. package/dist/tools/guest.js +7 -2
  42. package/dist/tools/guest.js.map +1 -1
  43. package/dist/tools/input.d.ts.map +1 -1
  44. package/dist/tools/input.js +63 -5
  45. package/dist/tools/input.js.map +1 -1
  46. package/dist/tools/snapshots.d.ts.map +1 -1
  47. package/dist/tools/snapshots.js +169 -159
  48. package/dist/tools/snapshots.js.map +1 -1
  49. package/dist/tools/templates.d.ts.map +1 -1
  50. package/dist/tools/templates.js +9 -13
  51. package/dist/tools/templates.js.map +1 -1
  52. package/package.json +1 -1
@@ -46,7 +46,11 @@ const movesOf = (body) => {
46
46
  // TypeError for a confident wrong answer, which is the worse of the two.
47
47
  // An unreadable ENVELOPE is still `undefined`: a different fact, a different
48
48
  // answer, and the one the callers already handle.
49
- const moves = list.filter((row) => row !== null && typeof row === 'object' && !Array.isArray(row));
49
+ const moves = list.filter((row) => row !== null &&
50
+ typeof row === 'object' &&
51
+ !Array.isArray(row) &&
52
+ typeof row.computer_id === 'string' &&
53
+ Boolean(row.computer_id.trim()));
50
54
  return { moves, dropped: list.length - moves.length };
51
55
  };
52
56
  /**
@@ -83,8 +87,26 @@ const moveOffered = (id, err) => refused(err.movePossible
83
87
  const moveShape = (m) => [m.cpu && `${m.cpu} vCPU`, m.ram_mb && `${m.ram_mb} MB RAM`, m.disk_gb && `${m.disk_gb} GB disk`]
84
88
  .filter(Boolean)
85
89
  .join(' · ') || 'no change';
86
- /** One row of list_moves. */
87
- const moveLine = (m) => `${m.computer_id}: ${m.state}${m.live ? ' (running)' : ''} — ${moveShape(m)}${m.detail ? ` — ${m.detail}` : ''}`;
90
+ /**
91
+ * One row of list_moves, and the three things `live` can say rather than two.
92
+ *
93
+ * `live` is the flag this tool's own description tells a model to poll on, so
94
+ * the one answer it must never give is a confident "not running" about a row
95
+ * that did not say. A truthy test gave exactly that: a row whose flag was absent
96
+ * or was not a boolean read as finished, and a caller polling for the move to end
97
+ * stops there — while a disk is still being copied between two hosts, and while
98
+ * the platform goes on refusing the next move on this account because this one
99
+ * has not finished. The watch loop already treats an unreadable flag as a poll it
100
+ * could not get an answer to; this is the same fact, said in a listing.
101
+ */
102
+ const moveLine = (m) => {
103
+ const liveness = m.live === true
104
+ ? ' (running)'
105
+ : m.live === false
106
+ ? ''
107
+ : ' (LIVENESS UNKNOWN — this row did not say whether the move is still running, so do not read it as finished)';
108
+ return `${m.computer_id}: ${m.state}${liveness} — ${moveShape(m)}${m.detail ? ` — ${m.detail}` : ''}`;
109
+ };
88
110
  /**
89
111
  * The sentence in front of a usage report, and the reason this tool does not
90
112
  * simply hand back the JSON the way list_sizes does.
@@ -568,89 +590,100 @@ export const registerComputers = (server, session, opts) => {
568
590
  // tool that says nothing for minutes is one a client cancels — see
569
591
  // heartbeat, and OPL-4579 for the wait that proved it.
570
592
  const beat = heartbeat(extra, server.server);
571
- await beat(`Moving ${id} — the platform has accepted it.`);
572
- let last = started;
573
- let blocked;
574
- while (!untilDeadline.aborted) {
575
- if (extra.signal?.aborted) {
576
- return refused(`Cancelled while waiting for ${id} to move. THE MOVE IS STILL RUNNING — nothing was stopped, ` +
577
- `because a disk crossing between two hosts cannot be called back. list_moves says where it ` +
578
- `got to.`, last);
579
- }
580
- let table;
581
- let raw;
582
- try {
583
- raw = await api.json('GET', P.MOVES);
584
- table = movesOf(raw);
585
- }
586
- catch (err) {
587
- if (extra.signal?.aborted)
593
+ try {
594
+ await beat(`Moving ${id} — the platform has accepted it.`);
595
+ let last = started;
596
+ let blocked;
597
+ while (!untilDeadline.aborted) {
598
+ if (extra.signal?.aborted) {
599
+ return refused(`Cancelled while waiting for ${id} to move. THE MOVE IS STILL RUNNING — nothing was stopped, ` +
600
+ `because a disk crossing between two hosts cannot be called back. list_moves says where it ` +
601
+ `got to.`, last);
602
+ }
603
+ let table;
604
+ let raw;
605
+ try {
606
+ raw = await api.json('GET', P.MOVES);
607
+ table = movesOf(raw);
608
+ }
609
+ catch (err) {
610
+ if (extra.signal?.aborted)
611
+ continue;
612
+ if (err instanceof CancelledError) {
613
+ if (untilDeadline.aborted)
614
+ break;
615
+ blocked = err.message;
616
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
617
+ await sleep(POLL_MS, signal);
618
+ continue;
619
+ }
620
+ // The poll reads the control plane's own table, so the statuses
621
+ // worth riding out are the ones that mean "ask again" — exactly
622
+ // wait_for_computer's list. Anything else is a real failure, and
623
+ // the move is still running behind it, which a thrown error's
624
+ // handler has no way to say. So it is said here.
625
+ if (!isTransientForPoll(err)) {
626
+ return refused(`${err instanceof Error ? err.message : String(err)}\n\nTHE MOVE IS STILL RUNNING — this ` +
627
+ `was the poll failing, not the move. list_moves says where it got to.`, last);
628
+ }
629
+ blocked = err instanceof Error ? err.message : String(err);
630
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
631
+ await sleep(pollDelay(err), signal);
588
632
  continue;
589
- if (err instanceof CancelledError) {
590
- if (untilDeadline.aborted)
591
- break;
592
- blocked = err.message;
633
+ }
634
+ // A table that is not a list is the platform failing to answer, not
635
+ // an answer that the move is gone. It rides out the same way a poll
636
+ // that threw does, so the deadline's sentence says the platform could
637
+ // not be asked rather than claiming a deletion nothing established.
638
+ if (!table) {
639
+ blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
593
640
  await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
594
641
  await sleep(POLL_MS, signal);
595
642
  continue;
596
643
  }
597
- // The poll reads the control plane's own table, so the statuses
598
- // worth riding out are the ones that mean "ask again" — exactly
599
- // wait_for_computer's list. Anything else is a real failure, and
600
- // the move is still running behind it, which a thrown error's
601
- // handler has no way to say. So it is said here.
602
- if (!isTransientForPoll(err)) {
603
- return refused(`${err instanceof Error ? err.message : String(err)}\n\nTHE MOVE IS STILL RUNNING — this ` +
604
- `was the poll failing, not the move. list_moves says where it got to.`, last);
644
+ blocked = undefined;
645
+ const mine = table.moves.find((m) => m.computer_id === id);
646
+ // A move that is no longer listed is one the platform reaped, and it
647
+ // reaps for one reason: the computer was deleted. Not a state to keep
648
+ // polling for.
649
+ //
650
+ // Unless a row could not be READ, in which case absence is not
651
+ // established: the move may be sitting in the row this poll had to
652
+ // drop. That is a poll that could not be answered rather than an
653
+ // answer, so it rides out exactly as a transient failure does, and
654
+ // the deadline's sentence says the platform could not be asked
655
+ // instead of claiming a deletion nothing showed.
656
+ if (!mine && table.dropped) {
657
+ blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
658
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
659
+ await sleep(POLL_MS, signal);
660
+ continue;
605
661
  }
606
- blocked = err instanceof Error ? err.message : String(err);
607
- await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
608
- await sleep(pollDelay(err), signal);
609
- continue;
610
- }
611
- // A table that is not a list is the platform failing to answer, not
612
- // an answer that the move is gone. It rides out the same way a poll
613
- // that threw does, so the deadline's sentence says the platform could
614
- // not be asked rather than claiming a deletion nothing established.
615
- if (!table) {
616
- blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
617
- await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
618
- await sleep(POLL_MS, signal);
619
- continue;
620
- }
621
- blocked = undefined;
622
- const mine = table.moves.find((m) => m.computer_id === id);
623
- // A move that is no longer listed is one the platform reaped, and it
624
- // reaps for one reason: the computer was deleted. Not a state to keep
625
- // polling for.
626
- //
627
- // Unless a row could not be READ, in which case absence is not
628
- // established: the move may be sitting in the row this poll had to
629
- // drop. That is a poll that could not be answered rather than an
630
- // answer, so it rides out exactly as a transient failure does, and
631
- // the deadline's sentence says the platform could not be asked
632
- // instead of claiming a deletion nothing showed.
633
- if (!mine && table.dropped) {
634
- blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
635
- await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
662
+ if (!mine) {
663
+ return refused(`The move of ${id} is no longer listed. That happens when the computer is deleted — check ` +
664
+ `list_computers.`, last);
665
+ }
666
+ if (typeof mine.live !== 'boolean') {
667
+ blocked = `GET /moves answered with an unreadable live flag for ${id}`;
668
+ await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
669
+ await sleep(POLL_MS, signal);
670
+ continue;
671
+ }
672
+ last = mine;
673
+ if (!mine.live)
674
+ return finishedMove(id, mine);
675
+ await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
636
676
  await sleep(POLL_MS, signal);
637
- continue;
638
677
  }
639
- if (!mine) {
640
- return refused(`The move of ${id} is no longer listed. That happens when the computer is deleted — check ` +
641
- `list_computers.`, last);
642
- }
643
- last = mine;
644
- if (!mine.live)
645
- return finishedMove(id, mine);
646
- await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
647
- await sleep(POLL_MS, signal);
678
+ return refused(blocked
679
+ ? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
680
+ `${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
681
+ : `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
682
+ `nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
683
+ }
684
+ finally {
685
+ await beat.stop();
648
686
  }
649
- return refused(blocked
650
- ? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
651
- `${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
652
- : `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
653
- `nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
654
687
  }));
655
688
  server.registerTool('list_moves', {
656
689
  title: 'List moves in progress and their outcomes',
@@ -745,191 +778,200 @@ export const registerComputers = (server, session, opts) => {
745
778
  : untilDeadline;
746
779
  const api = session.api.with(signal);
747
780
  const beat = heartbeat(extra, server.server);
748
- let last = 'unknown';
749
- // Kept so the give-up message can name it. A hypervisor that was
750
- // unreachable for the whole window is the single most useful thing to
751
- // report, and swallowing every transient would end the wait saying only
752
- // that the status was never seen.
753
- let blocked;
754
- while (!untilDeadline.aborted) {
755
- // The caller giving up ends the wait. The signal aborts the request
756
- // in flight, but nothing about an aborted request stops the next
757
- // iteration from starting one — so a cancelled call would go on
758
- // polling the platform for the rest of its timeout_s, up to fifteen
759
- // minutes of traffic on behalf of nobody.
760
- if (extra.signal?.aborted)
761
- return cancelled(id, last);
762
- // The status read is exactly as transient-prone as the guest probe
763
- // below it — a hypervisor that cannot be reached answers 503, which
764
- // is the ordinary weather of a machine still coming up. Letting that
765
- // out would abort the one tool whose entire job is to keep asking.
766
- let c;
767
- try {
768
- c = unwrapComputer(await api.json('GET', P.computer(id)));
769
- }
770
- catch (err) {
771
- // The caller's own signal is checked first, and by identity rather
772
- // than by reading the error: the request is now bound to two
773
- // deadlines, and only one of them means anybody stopped caring.
781
+ try {
782
+ let last = 'unknown';
783
+ // Open the progress channel before the first status read. That read
784
+ // has the same network and response deadlines as every later poll,
785
+ // so it can be the whole wait rather than a quick prelude to it.
786
+ await beat(`Waiting for ${id} — asking the platform for its status.`);
787
+ // Kept so the give-up message can name it. A hypervisor that was
788
+ // unreachable for the whole window is the single most useful thing to
789
+ // report, and swallowing every transient would end the wait saying only
790
+ // that the status was never seen.
791
+ let blocked;
792
+ while (!untilDeadline.aborted) {
793
+ // The caller giving up ends the wait. The signal aborts the request
794
+ // in flight, but nothing about an aborted request stops the next
795
+ // iteration from starting one — so a cancelled call would go on
796
+ // polling the platform for the rest of its timeout_s, up to fifteen
797
+ // minutes of traffic on behalf of nobody.
774
798
  if (extra.signal?.aborted)
775
799
  return cancelled(id, last);
776
- // A body stream can also fail without either signal firing (an
777
- // undici idle timeout is an AbortError). That is a transport
778
- // failure, not a cancellation, and is retried below as transient.
779
- // Only the deadline signal proves the wait's own timer arrived.
780
- if (err instanceof CancelledError) {
781
- if (untilDeadline.aborted) {
782
- blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
783
- break;
784
- }
785
- blocked = err.message;
786
- await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
787
- await sleep(POLL_MS, signal);
788
- continue;
789
- }
790
- if (!isTransientForPoll(err))
791
- throw err;
792
- blocked = err instanceof Error ? err.message : String(err);
793
- await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
794
- await sleep(pollDelay(err), signal);
795
- continue;
796
- }
797
- blocked = undefined;
798
- session.noteResolution(id, c.resolution);
799
- last = c.status ?? 'unknown';
800
- // ONE beat per turn, and this is not it when a guest probe is about to
801
- // run. Beating here and again in the probe's failure branch sent two
802
- // notifications per poll — and because the two lines DIFFER, each read
803
- // as news to the throttle and neither was ever held, so the steady
804
- // state of the commonest long wait was twice the per-poll rate the
805
- // interval exists to avoid (/code-review).
806
- //
807
- // Fixed by making the two say the SAME thing rather than by silencing
808
- // this one, which was the first attempt and dropped the wrong half of
809
- // the pair (/code-review again). The probe below is the longest call
810
- // in the loop — an undici header timeout or a proxy 524 can hold it
811
- // for minutes — so the beat that must survive is the one IN FRONT of
812
- // it. The 409 branch repeats this line and the throttle holds it; a
813
- // probe that fails some other way says so, which is news and goes out.
814
- await beat(until === 'guest' && last === 'running'
815
- ? `Waiting for ${id} — running; asking the guest.`
816
- : `Waiting for ${id} — ${last}.`);
817
- if (last === 'build-failed') {
818
- // `refused`, for the reason `cancelled` is: the wait never reached
819
- // what it was told to wait for, and this one never will. A caller
820
- // reading `isError` to decide whether to go on would otherwise see
821
- // a build that failed and a guest that answered as the same result.
822
- //
823
- // `build.source` is what the machine was built *from*, not why the
824
- // build failed — printed bare after "Build failed:" it reads as the
825
- // reason and names an image instead of a cause. `start_error` is
826
- // the field that carries a diagnostic, so prefer it and label the
827
- // source as the source when that is all there is.
828
- const why = c.start_error
829
- ? `: ${c.start_error}`
830
- : c.build?.source
831
- ? ` (built from ${c.build.source}) — the platform gave no reason`
832
- : ' — the platform gave no reason';
833
- return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
834
- }
835
- // Its files were partly removed and its disk is gone: the platform
836
- // refuses to start or use it, and only deleting it again clears it.
837
- // Waiting spent the whole budget and then reported "last seen
838
- // half-removed", which is the state the caller passed in (Codex
839
- // review). Not qualified by the pool: nothing can be admitted for a
840
- // machine with no disk.
841
- if (last === 'half-removed') {
842
- return refused(`${id} is half-removed: its files were partly removed, it cannot be started or used again, and delete_computer is what clears it.`, withoutCredentials(c));
843
- }
844
- // Neither of the next two resolves on its own, so spinning on either
845
- // burns the whole timeout waiting for something nobody is going to do.
846
- //
847
- // Unless somebody is. `status` is read from the guest process, so a
848
- // start that has been ADMITTED reads as `stopped` while it boots and
849
- // as `suspended` while it resumes — the session record is spent only
850
- // on the way out of a start that worked. Refusing there tells a model
851
- // to call start_computer on a computer that is already starting, and
852
- // the obvious next thing it does is start it a second time.
853
- //
854
- // nothingAdmitted is the platform's own word for idle, and it is
855
- // absent-aware: a host that did not answer has not said nothing is
856
- // coming, so the wait goes on rather than refusing (OPL-4631).
857
- if (last === 'suspended' && nothingAdmitted(c)) {
858
- return refused(`${id} is suspended, and that state does not clear by itself. start_computer resumes the saved session in about a second.`, withoutCredentials(c));
859
- }
860
- if (last === 'stopped' && nothingAdmitted(c)) {
861
- return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
862
- }
863
- if (last === 'running') {
864
- if (until === 'running')
865
- return said(`Running: ${describe(c)}`, withoutCredentials(c));
866
- // "The guest is up" is not a status the platform reports, so it is
867
- // asked rather than waited for: a trivial exec either answers, or
868
- // refuses with the 409 that says the agent is not up yet.
800
+ // The status read is exactly as transient-prone as the guest probe
801
+ // below it — a hypervisor that cannot be reached answers 503, which
802
+ // is the ordinary weather of a machine still coming up. Letting that
803
+ // out would abort the one tool whose entire job is to keep asking.
804
+ let c;
869
805
  try {
870
- await api.send('POST', P.computerAction(id, 'exec'), {
871
- body: P.execBody({ command: 'true', timeout_s: 5 }),
872
- });
873
- return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
806
+ c = unwrapComputer(await api.json('GET', P.computer(id)));
874
807
  }
875
808
  catch (err) {
876
- // The same two deadlines as the status read above, and for the
877
- // same reason: this catch used to judge the error alone, so a
878
- // cancellation during the guest probe left the wait throwing what
879
- // read as a platform outage instead of saying the caller had
880
- // hung up. Half the loop knew to check the signal and half did
881
- // not, which is the worse of the two ways to be inconsistent.
809
+ // The caller's own signal is checked first, and by identity rather
810
+ // than by reading the error: the request is now bound to two
811
+ // deadlines, and only one of them means anybody stopped caring.
882
812
  if (extra.signal?.aborted)
883
813
  return cancelled(id, last);
814
+ // A body stream can also fail without either signal firing (an
815
+ // undici idle timeout is an AbortError). That is a transport
816
+ // failure, not a cancellation, and is retried below as transient.
817
+ // Only the deadline signal proves the wait's own timer arrived.
884
818
  if (err instanceof CancelledError) {
885
819
  if (untilDeadline.aborted) {
886
- blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
820
+ blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
887
821
  break;
888
822
  }
889
823
  blocked = err.message;
890
- await beat(`Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
824
+ await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
891
825
  await sleep(POLL_MS, signal);
892
826
  continue;
893
827
  }
894
828
  if (!isTransientForPoll(err))
895
829
  throw err;
896
- // What actually refused, rather than one sentence for every
897
- // failure. A 409 IS the guest not being up yet — that is the
898
- // probe working, and it repeats the line beat in front of the
899
- // probe so the throttle holds it. A 503 is a hypervisor nobody
900
- // can reach and a transport abort is neither: saying "the guest
901
- // is not answering" over those tells the person watching the log
902
- // the one thing this channel exists to get right.
903
- //
904
- // And it SETS `blocked`, which it never did — not before this
905
- // change and not after the first version of it. A wait that spent
906
- // its whole window failing the probe on 503s gave up saying "was
907
- // last seen running" and named nothing, while the progress
908
- // channel had been reporting the hypervisor the entire time: the
909
- // same contradiction this comment set out to remove, pointed the
910
- // other way (/code-review). Deliberately not for the 409, where
911
- // the platform did answer and `blocked` would be a lie about it.
912
- if (!(err instanceof ConflictError)) {
913
- blocked = err instanceof Error ? err.message : String(err);
914
- }
915
- await beat(err instanceof ConflictError
916
- ? `Waiting for ${id} — running; asking the guest.`
917
- : `Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
918
- // The guest probe's own failure decides this turn's interval, for
919
- // pollDelay's reason. The ordinary path below keeps POLL_MS.
830
+ blocked = err instanceof Error ? err.message : String(err);
831
+ await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
920
832
  await sleep(pollDelay(err), signal);
921
833
  continue;
922
834
  }
835
+ blocked = undefined;
836
+ session.noteResolution(id, c.resolution);
837
+ last = c.status ?? 'unknown';
838
+ // ONE beat per turn, and this is not it when a guest probe is about to
839
+ // run. Beating here and again in the probe's failure branch sent two
840
+ // notifications per poll — and because the two lines DIFFER, each read
841
+ // as news to the throttle and neither was ever held, so the steady
842
+ // state of the commonest long wait was twice the per-poll rate the
843
+ // interval exists to avoid (/code-review).
844
+ //
845
+ // Fixed by making the two say the SAME thing rather than by silencing
846
+ // this one, which was the first attempt and dropped the wrong half of
847
+ // the pair (/code-review again). The probe below is the longest call
848
+ // in the loop — an undici header timeout or a proxy 524 can hold it
849
+ // for minutes — so the beat that must survive is the one IN FRONT of
850
+ // it. The 409 branch repeats this line and the throttle holds it; a
851
+ // probe that fails some other way says so, which is news and goes out.
852
+ await beat(until === 'guest' && last === 'running'
853
+ ? `Waiting for ${id} — running; asking the guest.`
854
+ : `Waiting for ${id} — ${last}.`);
855
+ if (last === 'build-failed') {
856
+ // `refused`, for the reason `cancelled` is: the wait never reached
857
+ // what it was told to wait for, and this one never will. A caller
858
+ // reading `isError` to decide whether to go on would otherwise see
859
+ // a build that failed and a guest that answered as the same result.
860
+ //
861
+ // `build.source` is what the machine was built *from*, not why the
862
+ // build failed — printed bare after "Build failed:" it reads as the
863
+ // reason and names an image instead of a cause. `start_error` is
864
+ // the field that carries a diagnostic, so prefer it and label the
865
+ // source as the source when that is all there is.
866
+ const why = c.start_error
867
+ ? `: ${c.start_error}`
868
+ : c.build?.source
869
+ ? ` (built from ${c.build.source}) — the platform gave no reason`
870
+ : ' — the platform gave no reason';
871
+ return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
872
+ }
873
+ // Its files were partly removed and its disk is gone: the platform
874
+ // refuses to start or use it, and only deleting it again clears it.
875
+ // Waiting spent the whole budget and then reported "last seen
876
+ // half-removed", which is the state the caller passed in (Codex
877
+ // review). Not qualified by the pool: nothing can be admitted for a
878
+ // machine with no disk.
879
+ if (last === 'half-removed') {
880
+ return refused(`${id} is half-removed: its files were partly removed, it cannot be started or used again, and delete_computer is what clears it.`, withoutCredentials(c));
881
+ }
882
+ // Neither of the next two resolves on its own, so spinning on either
883
+ // burns the whole timeout waiting for something nobody is going to do.
884
+ //
885
+ // Unless somebody is. `status` is read from the guest process, so a
886
+ // start that has been ADMITTED reads as `stopped` while it boots and
887
+ // as `suspended` while it resumes — the session record is spent only
888
+ // on the way out of a start that worked. Refusing there tells a model
889
+ // to call start_computer on a computer that is already starting, and
890
+ // the obvious next thing it does is start it a second time.
891
+ //
892
+ // nothingAdmitted is the platform's own word for idle, and it is
893
+ // absent-aware: a host that did not answer has not said nothing is
894
+ // coming, so the wait goes on rather than refusing (OPL-4631).
895
+ if (last === 'suspended' && nothingAdmitted(c)) {
896
+ return refused(`${id} is suspended, and that state does not clear by itself. start_computer resumes the saved session in about a second.`, withoutCredentials(c));
897
+ }
898
+ if (last === 'stopped' && nothingAdmitted(c)) {
899
+ return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
900
+ }
901
+ if (last === 'running') {
902
+ if (until === 'running')
903
+ return said(`Running: ${describe(c)}`, withoutCredentials(c));
904
+ // "The guest is up" is not a status the platform reports, so it is
905
+ // asked rather than waited for: a trivial exec either answers, or
906
+ // refuses with the 409 that says the agent is not up yet.
907
+ try {
908
+ await api.send('POST', P.computerAction(id, 'exec'), {
909
+ body: P.execBody({ command: 'true', timeout_s: 5 }),
910
+ });
911
+ return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
912
+ }
913
+ catch (err) {
914
+ // The same two deadlines as the status read above, and for the
915
+ // same reason: this catch used to judge the error alone, so a
916
+ // cancellation during the guest probe left the wait throwing what
917
+ // read as a platform outage instead of saying the caller had
918
+ // hung up. Half the loop knew to check the signal and half did
919
+ // not, which is the worse of the two ways to be inconsistent.
920
+ if (extra.signal?.aborted)
921
+ return cancelled(id, last);
922
+ if (err instanceof CancelledError) {
923
+ if (untilDeadline.aborted) {
924
+ blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
925
+ break;
926
+ }
927
+ blocked = err.message;
928
+ await beat(`Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
929
+ await sleep(POLL_MS, signal);
930
+ continue;
931
+ }
932
+ if (!isTransientForPoll(err))
933
+ throw err;
934
+ // What actually refused, rather than one sentence for every
935
+ // failure. A 409 IS the guest not being up yet — that is the
936
+ // probe working, and it repeats the line beat in front of the
937
+ // probe so the throttle holds it. A 503 is a hypervisor nobody
938
+ // can reach and a transport abort is neither: saying "the guest
939
+ // is not answering" over those tells the person watching the log
940
+ // the one thing this channel exists to get right.
941
+ //
942
+ // And it SETS `blocked`, which it never did — not before this
943
+ // change and not after the first version of it. A wait that spent
944
+ // its whole window failing the probe on 503s gave up saying "was
945
+ // last seen running" and named nothing, while the progress
946
+ // channel had been reporting the hypervisor the entire time: the
947
+ // same contradiction this comment set out to remove, pointed the
948
+ // other way (/code-review). Deliberately not for the 409, where
949
+ // the platform did answer and `blocked` would be a lie about it.
950
+ if (!(err instanceof ConflictError)) {
951
+ blocked = err instanceof Error ? err.message : String(err);
952
+ }
953
+ await beat(err instanceof ConflictError
954
+ ? `Waiting for ${id} — running; asking the guest.`
955
+ : `Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
956
+ // The guest probe's own failure decides this turn's interval, for
957
+ // pollDelay's reason. The ordinary path below keeps POLL_MS.
958
+ await sleep(pollDelay(err), signal);
959
+ continue;
960
+ }
961
+ }
962
+ await sleep(POLL_MS, signal);
923
963
  }
924
- await sleep(POLL_MS, signal);
964
+ // Also a refusal: the deadline passed without the condition being met,
965
+ // which is the same shape of answer as a cancellation and not the same
966
+ // as success. The message still says to call again, because the state
967
+ // it was waiting on may yet arrive.
968
+ return refused(blocked
969
+ ? `Gave up after ${timeout_s}s; the platform could not be asked about ${id} for the whole wait — the last attempt said: ${blocked}. Nothing was changed — call again to keep waiting.`
970
+ : `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
971
+ }
972
+ finally {
973
+ await beat.stop();
925
974
  }
926
- // Also a refusal: the deadline passed without the condition being met,
927
- // which is the same shape of answer as a cancellation and not the same
928
- // as success. The message still says to call again, because the state
929
- // it was waiting on may yet arrive.
930
- return refused(blocked
931
- ? `Gave up after ${timeout_s}s; the platform could not be asked about ${id} for the whole wait — the last attempt said: ${blocked}. Nothing was changed — call again to keep waiting.`
932
- : `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
933
975
  }));
934
976
  server.registerTool('get_desktop_url', {
935
977
  title: 'Get a link to watch the desktop',