@adhisang/minecraft-modding-mcp 7.0.0-rc.2 → 7.0.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.
@@ -181,6 +181,33 @@ export async function settleTreeCleanupWithin(operation, timeoutMs, onTimeout =
181
181
  operation.then(finish, () => finish(false));
182
182
  });
183
183
  }
184
+ /**
185
+ * Describes a thrown value without being able to throw while doing it.
186
+ *
187
+ * The recovery paths in this file start by turning the thrown value into a
188
+ * string for the client's error message. Written inline as
189
+ * `error instanceof Error ? error.message : String(error)`, that conversion
190
+ * runs BEFORE any guard the recovery installs — and it is not safe: `String`
191
+ * calls `toString`, which a thrown object may define to throw, and `.message`
192
+ * may be an accessor that throws (an `Error` subclass, or any object passed
193
+ * through `Object.defineProperty`). A throw there escaped the recovery
194
+ * entirely and reached the frame reader, which drops the frame as a parse
195
+ * error: the request the recovery existed to answer was lost, with no reply,
196
+ * no deadline and its queue still parked.
197
+ *
198
+ * Every step is inside the guard, `instanceof` included — a Proxy can throw
199
+ * from its `getPrototypeOf` trap — and the fallback is a constant, so this
200
+ * function has no throwing path of its own.
201
+ */
202
+ function describeThrown(error) {
203
+ try {
204
+ const described = error instanceof Error ? error.message : error;
205
+ return typeof described === "string" ? described : String(described);
206
+ }
207
+ catch {
208
+ return "<error description unavailable>";
209
+ }
210
+ }
184
211
  function isRequest(message) {
185
212
  return "method" in message && "id" in message;
186
213
  }
@@ -677,6 +704,22 @@ export class StdioSupervisor {
677
704
  */
678
705
  syntheticTombstones = new Map();
679
706
  child;
707
+ /**
708
+ * The one response `handleWorkerMessage` is part-way through settling.
709
+ *
710
+ * It removes a response's pending entry BEFORE writing the reply, so between
711
+ * those two statements the id is owed a response that NOTHING records: the
712
+ * entry is gone, no deadline is armed for anything but validate-project, and
713
+ * the worker considers the request answered. A fault in that window used to
714
+ * be indistinguishable from an id already settled, and the request stayed
715
+ * unanswered for the life of an otherwise healthy session.
716
+ *
717
+ * Set the moment the entry is deleted and cleared the moment the write
718
+ * returns, so it is defined only inside that window and only ever names one
719
+ * request. `answerFaultedWorkerResponse` is the sole reader, and it consumes
720
+ * the marker rather than merely reading it.
721
+ */
722
+ settlingWorkerResponse;
680
723
  childReady = false;
681
724
  /**
682
725
  * Monotonic timestamp of the current generation's adoption, or undefined
@@ -887,15 +930,20 @@ export class StdioSupervisor {
887
930
  });
888
931
  };
889
932
  /**
890
- * Admission entry point, wrapped so a fault can never make a request vanish.
933
+ * Admission entry point, wrapped so a fault cannot silently drop a request.
891
934
  *
892
935
  * Anything thrown while classifying, queueing or forwarding a client frame
893
936
  * propagates out of the frame reader's `onFrame`, where the reader swallows
894
937
  * it as a parse error — the request then gets NO reply and the client waits
895
938
  * on that id forever (and on a modern-era request the one-way era lock has
896
- * already happened). Answering the id with -32603 keeps the
897
- * exactly-one-response guarantee; id-less frames (notifications, malformed
898
- * ids) have nothing to answer and are only logged.
939
+ * already happened). The catch below answers the id with -32603 instead.
940
+ *
941
+ * The reply is conditional, and on exactly one thing: that nothing this
942
+ * admission installed is still live at the id. A surviving instance is
943
+ * already tracked and will be settled by an ordinary path, so answering
944
+ * alongside it would make two terminal replies for one request; the catch
945
+ * reports and stands down in that case rather than answering. Id-less frames
946
+ * (notifications, malformed ids) have nothing to answer and are only logged.
899
947
  */
900
948
  handleClientMessage(message) {
901
949
  const admittedId = isRequest(message) ? getTrackedRequestId(message) : undefined;
@@ -906,47 +954,173 @@ export class StdioSupervisor {
906
954
  this.routeClientMessage(message);
907
955
  }
908
956
  catch (error) {
909
- const detail = error instanceof Error ? error.message : String(error);
910
- this.eventWriter("error", "supervisor.admission_failed", {
911
- id: "id" in message ? message.id : undefined,
912
- method: "method" in message ? message.method : undefined,
913
- message: detail
957
+ // `describeThrown`, not an inline conversion: this line runs BEFORE
958
+ // every guard below, so a thrown value whose `toString` or `.message`
959
+ // throws would escape the recovery here and cost the request its reply.
960
+ const detail = describeThrown(error);
961
+ // EVERY step below is individually guarded, because this whole block runs
962
+ // inside the frame reader's `onFrame`: a throw escaping here is reported
963
+ // as a parse error and the request is dropped — the exact loss this
964
+ // handler exists to prevent, reintroduced by its own recovery. Guarding
965
+ // per step rather than as a block is what keeps a faulting event writer
966
+ // from costing the client its terminal reply, and a faulting reply from
967
+ // costing the queue its drain.
968
+ //
969
+ // The two writers are separate channels, not one: in the DEFAULT
970
+ // configuration `eventWriter` is `log`, which writes to stderr, while a
971
+ // reply with no injected `clientWriter` goes to stdout. A failure of one
972
+ // is therefore no evidence about the other, in either direction — which
973
+ // is the reason each step carries its own guard rather than the block
974
+ // carrying one.
975
+ this.runRecoveryStep("admission.event", () => {
976
+ this.eventWriter("error", "supervisor.admission_failed", {
977
+ id: "id" in message ? message.id : undefined,
978
+ method: "method" in message ? message.method : undefined,
979
+ message: detail
980
+ });
914
981
  });
915
982
  // Admission installs state before it can fault (see rollbackFailedAdmission),
916
983
  // and the -32603 below is terminal for the id — so anything half-installed
917
984
  // has to go before the reply, or it will answer the id a second time.
918
- const rolledBack = admittedId !== undefined &&
919
- preexisting !== undefined &&
920
- this.rollbackFailedAdmission(requestKey(admittedId), preexisting);
985
+ let rolledBack = false;
986
+ this.runRecoveryStep("admission.rollback", () => {
987
+ rolledBack =
988
+ admittedId !== undefined &&
989
+ preexisting !== undefined &&
990
+ this.rollbackFailedAdmission(requestKey(admittedId), preexisting);
991
+ });
992
+ // A rollback that FAULTED may have removed part of the state and left
993
+ // the rest, and it reports neither — so the step above proves only that
994
+ // it did not throw, never that the id is clear. Redo the removal through
995
+ // the primitives themselves rather than through the method that may just
996
+ // have thrown (a fallback that re-enters the failed step is not a
997
+ // fallback), then read the id back. Both halves are idempotent: after a
998
+ // rollback that succeeded they find nothing and change nothing.
999
+ let admissionInstanceSurvives = false;
1000
+ if (admittedId !== undefined && preexisting !== undefined) {
1001
+ const admittedKey = requestKey(admittedId);
1002
+ const scope = preexisting;
1003
+ this.runRecoveryStep("admission.rollback_finality", () => {
1004
+ try {
1005
+ if (this.dropQueuedInstances(admittedKey, scope))
1006
+ rolledBack = true;
1007
+ const live = this.pendingRequests.get(admittedKey);
1008
+ if (live && !scope.has(live) && this.releaseForwardedRequest(admittedKey, live)) {
1009
+ rolledBack = true;
1010
+ }
1011
+ }
1012
+ finally {
1013
+ // In a `finally` because the removal above may itself throw, and a
1014
+ // removal that threw part-way through is exactly the case the read
1015
+ // back has to cover. Should the read ITSELF throw, the step's guard
1016
+ // contains it and the flag stays false, so the reply goes out: a
1017
+ // request answered is the failure this handler exists to prevent,
1018
+ // and it is the safer of the two directions to fail in.
1019
+ admissionInstanceSurvives = this.hasInstanceOutside(admittedKey, scope);
1020
+ }
1021
+ });
1022
+ }
921
1023
  if (!isRequest(message))
922
1024
  return;
923
1025
  const id = getTrackedRequestId(message);
924
1026
  if (id === undefined)
925
1027
  return;
926
- this.writeToClient({
927
- jsonrpc: "2.0",
928
- id,
929
- error: {
930
- code: -32603,
931
- message: `MCP supervisor failed to admit the request: ${detail}`
932
- }
933
- }, this.modeForMessage(message));
934
- if (rolledBack) {
935
- // The released entry may have been the last occupant of a dispatch
936
- // barrier, and its cleared deadline was the timer that used to (much
937
- // later) unblock the queue behind it. Drain now instead. A second fault
938
- // must not escape: the client is already answered, and an escaping throw
939
- // reaches the frame reader, which swallows it as a parse error.
940
- try {
941
- this.drainQueue();
942
- }
943
- catch (drainError) {
944
- this.eventWriter("error", "supervisor.admission_failed", {
1028
+ if (admissionInstanceSurvives) {
1029
+ // Nothing could take the id away from this admission's own instance,
1030
+ // and that instance still owns it: it is tracked, so the worker's
1031
+ // answer, the worker-exit terminalization or an armed validate-project
1032
+ // deadline will settle it. Adding -32603 on top would be the second
1033
+ // terminal reply for one request, which is the one thing this handler
1034
+ // may never do. It is reported and left alone instead.
1035
+ //
1036
+ // Known limitation: a non-validate tool arms no deadline, so if the
1037
+ // fault also kept the request from reaching the worker, the id waits
1038
+ // for the worker's exit. That is the accepted cost of not adding a
1039
+ // universal per-request deadline; a blanket one would cut off the long
1040
+ // calls this server legitimately makes.
1041
+ this.runRecoveryStep("admission.rollback_incomplete", () => {
1042
+ this.eventWriter("error", "supervisor.admission_rollback_incomplete", {
945
1043
  id,
946
- method: message.method,
947
- message: drainError instanceof Error ? drainError.message : String(drainError)
1044
+ method: message.method
948
1045
  });
949
- }
1046
+ });
1047
+ }
1048
+ else {
1049
+ this.runRecoveryStep("admission.reply", () => {
1050
+ this.writeToClient({
1051
+ jsonrpc: "2.0",
1052
+ id,
1053
+ error: {
1054
+ code: -32603,
1055
+ message: `MCP supervisor failed to admit the request: ${detail}`
1056
+ }
1057
+ }, this.modeForMessage(message));
1058
+ });
1059
+ }
1060
+ if (rolledBack) {
1061
+ // Something WAS removed — which is true on the stand-down branch too,
1062
+ // where a queued instance went but a forwarded one survived. The
1063
+ // released entry may have been the last occupant of a dispatch
1064
+ // barrier, and its cleared deadline was the timer that used to (much
1065
+ // later) unblock the queue behind it. Drain now instead — whether or
1066
+ // not the reply above got out, since a client channel that refused the
1067
+ // reply is no reason to leave the queue parked. A second fault must not
1068
+ // escape either: an escaping throw reaches the frame reader, which
1069
+ // swallows it as a parse error.
1070
+ this.runRecoveryStep("admission.drain", () => {
1071
+ try {
1072
+ this.drainQueue();
1073
+ }
1074
+ catch (drainError) {
1075
+ this.eventWriter("error", "supervisor.admission_failed", {
1076
+ id,
1077
+ method: message.method,
1078
+ message: describeThrown(drainError)
1079
+ });
1080
+ }
1081
+ });
1082
+ }
1083
+ }
1084
+ }
1085
+ /**
1086
+ * Runs one step of a fault-recovery path so that it cannot throw.
1087
+ *
1088
+ * The two frame-reader callers — the admission catch in handleClientMessage
1089
+ * and the worker-frame catch in handleWorkerData — execute inside a
1090
+ * {@link JsonRpcFrameReader}'s `onFrame`, where the reader deliberately
1091
+ * converts an escaping throw into a plain parse error: the frame is then
1092
+ * silently dropped, which for a recovery path means the request it was
1093
+ * rescuing is lost after all. `dispatchQueuedRequest` is the third caller,
1094
+ * and its exposure is wider rather than narrower: a drain is reached from
1095
+ * those two `onFrame` paths, but also from timer callbacks
1096
+ * (handleValidateProjectDeadline) and from process-event handlers
1097
+ * (handleWorkerExit), where nothing above it is prepared to contain a throw
1098
+ * at all.
1099
+ *
1100
+ * Reporting goes through `log` rather than the injected `eventWriter` so
1101
+ * that an INJECTED writer — the collaborator most likely to have thrown — is
1102
+ * not also the reporter. That NARROWS the failure; it does not remove it.
1103
+ * `eventWriter` defaults to `log` (see the constructor), so in the default
1104
+ * configuration, where nothing is injected, the reporter IS the writer that
1105
+ * just faulted. The report therefore carries its own try/catch whose handler
1106
+ * does nothing: past this point there is no reporting channel left, and a
1107
+ * throw here would drop the frame this method exists to save.
1108
+ */
1109
+ runRecoveryStep(step, run) {
1110
+ try {
1111
+ run();
1112
+ }
1113
+ catch (error) {
1114
+ try {
1115
+ // describeThrown cannot throw, so the outer try/catch here guards only
1116
+ // the `log` call itself.
1117
+ log("error", "supervisor.recovery_step_failed", {
1118
+ step,
1119
+ message: describeThrown(error)
1120
+ });
1121
+ }
1122
+ catch {
1123
+ // Deliberately empty — see above. Nothing is left to report through.
950
1124
  }
951
1125
  }
952
1126
  }
@@ -985,7 +1159,23 @@ export class StdioSupervisor {
985
1159
  * Returns whether anything was rolled back.
986
1160
  */
987
1161
  rollbackFailedAdmission(key, preexisting) {
988
- let rolledBack = false;
1162
+ let rolledBack = this.dropQueuedInstances(key, preexisting);
1163
+ const pending = this.pendingRequests.get(key);
1164
+ if (pending && !preexisting.has(pending) && this.releaseForwardedRequest(key, pending)) {
1165
+ rolledBack = true;
1166
+ }
1167
+ return rolledBack;
1168
+ }
1169
+ /**
1170
+ * Removes every QUEUED instance at `key` that is absent from `preexisting`.
1171
+ *
1172
+ * Split out of rollbackFailedAdmission so the admission catch's finality
1173
+ * check can repeat the removal without re-entering the method that may just
1174
+ * have thrown. Returns whether anything was removed; calling it twice is
1175
+ * harmless, because the second call finds nothing.
1176
+ */
1177
+ dropQueuedInstances(key, preexisting) {
1178
+ let removed = false;
989
1179
  for (let index = this.queuedRequests.length - 1; index >= 0; index -= 1) {
990
1180
  const entry = this.queuedRequests[index];
991
1181
  if (requestKey(entry.pending.id) !== key || preexisting.has(entry.pending))
@@ -1003,13 +1193,20 @@ export class StdioSupervisor {
1003
1193
  if (this.validateBarrierKey === key && this.runningValidateKey !== key) {
1004
1194
  this.validateBarrierKey = undefined;
1005
1195
  }
1006
- rolledBack = true;
1196
+ removed = true;
1007
1197
  }
1008
- const pending = this.pendingRequests.get(key);
1009
- if (pending && !preexisting.has(pending) && this.releaseForwardedRequest(key, pending)) {
1010
- rolledBack = true;
1198
+ return removed;
1199
+ }
1200
+ /**
1201
+ * Whether any request instance live at `key` is absent from `preexisting` —
1202
+ * that is, whether the admission being rolled back still owns its id.
1203
+ */
1204
+ hasInstanceOutside(key, preexisting) {
1205
+ for (const instance of this.liveInstancesAt(key)) {
1206
+ if (!preexisting.has(instance))
1207
+ return true;
1011
1208
  }
1012
- return rolledBack;
1209
+ return false;
1013
1210
  }
1014
1211
  routeClientMessage(message) {
1015
1212
  debugSupervisor("client_message", {
@@ -1416,6 +1613,18 @@ export class StdioSupervisor {
1416
1613
  else {
1417
1614
  if (pending.deadlineTimer)
1418
1615
  this.timerClearer(pending.deadlineTimer);
1616
+ // Unlike the admission-time queue-limit site, this one runs AFTER
1617
+ // admission may have raised the validate barrier for this very
1618
+ // request, and the terminal reply below is the last event the id will
1619
+ // ever produce — so the barrier has to come down here or nothing
1620
+ // behind it would ever dispatch again. Same guard as every other
1621
+ // release: the barrier may be held by a DIFFERENT, RUNNING request
1622
+ // that reuses this id, and releasing that one would admit concurrent
1623
+ // work alongside a live validate-project.
1624
+ const abandonedKey = requestKey(pending.id);
1625
+ if (this.validateBarrierKey === abandonedKey && this.runningValidateKey !== abandonedKey) {
1626
+ this.validateBarrierKey = undefined;
1627
+ }
1419
1628
  this.writeSyntheticReply(pending, buildSupervisorQueueLimitReply(pending.id, pending.method ?? message.method));
1420
1629
  }
1421
1630
  this.scheduleRestart();
@@ -1457,8 +1666,12 @@ export class StdioSupervisor {
1457
1666
  const [{ pending }] = this.queuedRequests.splice(queuedIndex, 1);
1458
1667
  if (pending.deadlineTimer)
1459
1668
  this.timerClearer(pending.deadlineTimer);
1460
- if (this.validateBarrierKey === key)
1669
+ // A queued entry never owns runningValidateKey. The barrier may be held
1670
+ // by a DIFFERENT, RUNNING request that reuses this id; releasing it here
1671
+ // would admit concurrent work alongside a live validate-project.
1672
+ if (this.validateBarrierKey === key && this.runningValidateKey !== key) {
1461
1673
  this.validateBarrierKey = undefined;
1674
+ }
1462
1675
  this.drainQueue();
1463
1676
  return;
1464
1677
  }
@@ -1651,8 +1864,13 @@ export class StdioSupervisor {
1651
1864
  return;
1652
1865
  }
1653
1866
  this.queuedRequests.shift();
1654
- this.forwardRequest(next.message, next.pending);
1655
- return;
1867
+ // Return only when the dispatch actually happened: a validate-project
1868
+ // runs alone, but one that never reached the worker was settled
1869
+ // instead and released the barrier with it, so the queue behind it is
1870
+ // free to move in the same pass.
1871
+ if (this.dispatchQueuedRequest(next))
1872
+ return;
1873
+ continue;
1656
1874
  }
1657
1875
  if (this.validateBarrierKey && this.validateBarrierKey !== nextKey) {
1658
1876
  const barrierIndex = this.queuedRequests.findIndex((entry) => requestKey(entry.pending.id) === this.validateBarrierKey);
@@ -1660,7 +1878,113 @@ export class StdioSupervisor {
1660
1878
  return;
1661
1879
  }
1662
1880
  this.queuedRequests.shift();
1663
- this.forwardRequest(next.message, next.pending);
1881
+ this.dispatchQueuedRequest(next);
1882
+ }
1883
+ }
1884
+ /**
1885
+ * Forwards one queued entry, containing a fault in the forward itself.
1886
+ *
1887
+ * `forwardRequest` installs the pending entry and takes the validate
1888
+ * slot/barrier BEFORE it writes to the worker's stdin, so a throw from the
1889
+ * encode or the write leaves the request holding an id it never reached the
1890
+ * worker on. Unguarded, that request is unreachable: it has been shifted out
1891
+ * of the queue, so no later drain can dispatch it, and no worker will answer
1892
+ * an id it never saw — and only validate-project arms a deadline, so nothing
1893
+ * else settles it either. The one containing guard above this
1894
+ * (`runRecoveryStep` around the recovery drain) contains the exception
1895
+ * without recovering the request, and stops the drain on top of that,
1896
+ * leaving everything behind it parked as well.
1897
+ *
1898
+ * Both are repaired here: the entry is released — which records the finality
1899
+ * tombstone, so a worker that did somehow see the bytes cannot answer over
1900
+ * the reply below — terminally answered, and the drain carries on to the
1901
+ * next entry. Containment is per-entry because the fault this catches is
1902
+ * per-message: an encode failure on one payload, or a stdin `write` that
1903
+ * throws. A stdin that is merely destroyed never reaches this catch at all —
1904
+ * `forwardRequest` tests for that first and takes its own no-child fallback,
1905
+ * which does not throw.
1906
+ *
1907
+ * The `initialize` carve-out in `releaseForwardedRequest` is honoured: if it
1908
+ * declines, nothing is answered here and the handshake lifecycle keeps the
1909
+ * entry. Admission parks `initialize` in `queuedNotifications` rather than in
1910
+ * `queuedRequests`, so the ordinary path never puts one in front of this
1911
+ * drain; `forwardRequest`'s own no-child fallback is the one way an
1912
+ * initialize can end up queued, and the carve-out is what covers it.
1913
+ *
1914
+ * Returns whether the entry reached the worker.
1915
+ */
1916
+ dispatchQueuedRequest(entry) {
1917
+ try {
1918
+ this.forwardRequest(entry.message, entry.pending);
1919
+ return true;
1920
+ }
1921
+ catch (error) {
1922
+ const detail = describeThrown(error);
1923
+ this.runRecoveryStep("queue.dispatch", () => {
1924
+ this.eventWriter("error", "supervisor.queued_dispatch_failed", {
1925
+ id: entry.pending.id,
1926
+ method: entry.pending.method,
1927
+ toolName: entry.pending.toolName,
1928
+ message: detail
1929
+ });
1930
+ });
1931
+ const key = requestKey(entry.pending.id);
1932
+ let released = false;
1933
+ let attemptedRelease = false;
1934
+ this.runRecoveryStep("queue.dispatch_settle", () => {
1935
+ // Only THIS instance may be settled here. A fault before forwardRequest
1936
+ // installed anything leaves the request re-queued by its own no-child
1937
+ // fallback (a later drain owns it) or already answered by the
1938
+ // queue-limit rejection; either way the id may meanwhile belong to a
1939
+ // different live request, which keeps its own guarantee.
1940
+ if (this.pendingRequests.get(key) !== entry.pending)
1941
+ return;
1942
+ attemptedRelease = true;
1943
+ released = this.releaseForwardedRequest(key, entry.pending);
1944
+ });
1945
+ if (attemptedRelease && !released) {
1946
+ // releaseForwardedRequest deletes the entry and records its tombstone
1947
+ // BEFORE it returns, so a throw part-way through (recordFinalityTombstone's
1948
+ // only throw site today is the debug-log call during tombstone
1949
+ // eviction) can still have taken the id away from its entry. `released`
1950
+ // cannot tell "declined" apart from "threw after mutating", so the id
1951
+ // is read back rather than assumed — mirrors
1952
+ // answerFaultedWorkerResponse's worker_message.release_verify.
1953
+ //
1954
+ // Gated on `attemptedRelease`, not merely `!released`: the identity
1955
+ // check above (`pendingRequests.get(key) !== entry.pending`) returns
1956
+ // early, WITHOUT calling releaseForwardedRequest, whenever this entry
1957
+ // was never installed into pendingRequests to begin with — which is
1958
+ // exactly what happens when forwardRequest's no-child fallback re-queues
1959
+ // this same entry (queue not full) or already answered it itself (queue
1960
+ // full) before throwing later in that same fallback (e.g. from
1961
+ // scheduleRestart). In either of those cases the id is either still
1962
+ // waiting for a real dispatch that will answer it for real later, or
1963
+ // already answered — and this verify step cannot distinguish "never
1964
+ // installed" from "installed, then removed by a throwing release" using
1965
+ // `pendingRequests.has(key)` alone. Running it anyway would record a
1966
+ // spurious tombstone and send a synthetic reply for a request that gets
1967
+ // (or already got) a real one, i.e. two replies for the same id.
1968
+ this.runRecoveryStep("queue.dispatch_settle_verify", () => {
1969
+ if (entry.pending.method === "initialize" || this.pendingRequests.has(key))
1970
+ return;
1971
+ this.recordFinalityTombstone(key, entry.pending.mode);
1972
+ released = true;
1973
+ });
1974
+ }
1975
+ if (released) {
1976
+ this.runRecoveryStep("queue.dispatch_reply", () => {
1977
+ this.writeSyntheticReply(entry.pending, {
1978
+ jsonrpc: "2.0",
1979
+ id: entry.pending.id,
1980
+ error: {
1981
+ code: -32603,
1982
+ message: `MCP supervisor failed to dispatch the queued request: ${detail}`
1983
+ }
1984
+ });
1985
+ });
1986
+ }
1987
+ return false;
1664
1988
  }
1665
1989
  }
1666
1990
  spawnWorker() {
@@ -1678,7 +2002,7 @@ export class StdioSupervisor {
1678
2002
  }
1679
2003
  catch (error) {
1680
2004
  log("error", "supervisor.worker_spawn_throw", {
1681
- message: error instanceof Error ? error.message : String(error)
2005
+ message: describeThrown(error)
1682
2006
  });
1683
2007
  this.handleStartupFailure(token, { code: null, signal: null });
1684
2008
  return;
@@ -1723,7 +2047,15 @@ export class StdioSupervisor {
1723
2047
  return;
1724
2048
  reader.processChunk(chunk, {
1725
2049
  onFrame: ({ message }) => {
1726
- this.handleWorkerMessage(child, message);
2050
+ try {
2051
+ this.handleWorkerMessage(child, message);
2052
+ }
2053
+ catch (error) {
2054
+ // Mirror of the client side's admission safety net (see
2055
+ // handleClientMessage): an unguarded throw here escapes into the
2056
+ // reader, which reports it as a parse error and drops the frame.
2057
+ this.recoverFaultedWorkerMessage(message, error);
2058
+ }
1727
2059
  },
1728
2060
  onError: (error) => {
1729
2061
  if (isJsonRpcFramingFatalError(error)) {
@@ -1741,6 +2073,207 @@ export class StdioSupervisor {
1741
2073
  }
1742
2074
  });
1743
2075
  }
2076
+ /**
2077
+ * Rescues the request a faulted worker frame was answering.
2078
+ *
2079
+ * `handleWorkerMessage` runs inside the worker reader's `onFrame`, where a
2080
+ * throw becomes a parse error and the frame is simply dropped. For a
2081
+ * RESPONSE frame that left the pending entry live with no reply and nothing
2082
+ * left to settle it — deadlines are armed for validate-project only, so any
2083
+ * other tool had no rescue at all and the client waited on that id for the
2084
+ * life of the session. The id is released and answered here instead.
2085
+ *
2086
+ * The queue drain is the OTHER half, and it is why the answering half lives
2087
+ * in its own method: `handleWorkerMessage` deletes a response's pending
2088
+ * entry before the `writeToClient` that can throw, and its `drainQueue()` is
2089
+ * the last statement of all. So the commonest fault arrives here with the
2090
+ * entry already gone AND the queue undrained, and every early return in the
2091
+ * answering path is a case where the drain is the only rescue left.
2092
+ *
2093
+ * What this recovery is worth, stated exactly. It answers the id when this
2094
+ * supervisor is the one entitled to (see `answerFaultedWorkerResponse`), and
2095
+ * every id it answers is tombstoned, so the worker's own answer for that id
2096
+ * cannot become a second reply. It does NOT make one-response-per-id a
2097
+ * property of the whole file: an id whose entry could not be released is
2098
+ * deliberately left unanswered here rather than answered twice, the
2099
+ * `initialize` carve-out is settled by the handshake lifecycle instead, and
2100
+ * tombstone retention is capped at {@link MAX_SYNTHETIC_TOMBSTONES} — past
2101
+ * 1024 live tombstones in one worker generation the oldest is evicted, and a
2102
+ * very old worker's late answer for an evicted id would pass through.
2103
+ *
2104
+ * What this deliberately does NOT do: tear the session down or touch framing
2105
+ * state. One frame the supervisor could not handle is not evidence that the
2106
+ * worker's stream desynchronized (that is `supervisor.worker_framing_fatal`,
2107
+ * reported by the reader itself), and the reader's own state already
2108
+ * describes the stream correctly. The one exception is an in-flight
2109
+ * `initialize`, whose generation cannot finish its handshake once its answer
2110
+ * has been lost — see `answerFaultedWorkerResponse`.
2111
+ */
2112
+ recoverFaultedWorkerMessage(message, error) {
2113
+ // `describeThrown`, not an inline conversion: this line runs BEFORE every
2114
+ // guard below, so a thrown value whose `toString` or `.message` throws
2115
+ // would escape into the reader and drop the frame this method exists to
2116
+ // rescue.
2117
+ const detail = describeThrown(error);
2118
+ this.runRecoveryStep("worker_message.event", () => {
2119
+ this.eventWriter("error", "supervisor.worker_message_failed", {
2120
+ id: "id" in message ? message.id : undefined,
2121
+ method: "method" in message ? message.method : undefined,
2122
+ message: detail
2123
+ });
2124
+ });
2125
+ this.answerFaultedWorkerResponse(message, detail);
2126
+ // Unconditional, and outside every early return above. Running it when the
2127
+ // ordinary tail-drain would not have is bounded rather than free-handed:
2128
+ // drainQueue returns at once unless the current child is ready with a live
2129
+ // stdin, and dispatches nothing at all while a validate-project is running.
2130
+ //
2131
+ // The guard here contains a drain fault; it does not recover one. What
2132
+ // recovers the request a faulting dispatch stranded is
2133
+ // `dispatchQueuedRequest`, one level down — a request shifted out of the
2134
+ // queue and installed at its id has left every path that could re-reach
2135
+ // it, so containment alone would strand it exactly as badly as the throw
2136
+ // this method exists to answer for.
2137
+ this.runRecoveryStep("worker_message.drain", () => {
2138
+ this.drainQueue();
2139
+ });
2140
+ }
2141
+ /**
2142
+ * Terminally answers the client request a faulted worker RESPONSE frame was
2143
+ * carrying the answer for, if this supervisor is still the one entitled to
2144
+ * answer it. Returns without replying otherwise; the caller's drain runs
2145
+ * either way.
2146
+ */
2147
+ answerFaultedWorkerResponse(message, detail) {
2148
+ if (!isResponse(message))
2149
+ return;
2150
+ const id = getTrackedRequestId(message);
2151
+ if (id === undefined)
2152
+ return;
2153
+ const key = requestKey(id);
2154
+ const pending = this.pendingRequests.get(key);
2155
+ if (!pending) {
2156
+ // No entry at the id — which is NOT proof the id was answered.
2157
+ // handleWorkerMessage deletes a response's pending entry BEFORE it writes
2158
+ // the reply, so a write that faults arrives here with the entry already
2159
+ // gone and the client still owed a response. That window is the only
2160
+ // thing `settlingWorkerResponse` records, and it is the only case in
2161
+ // which an id with no entry may be answered here; anything else is an id
2162
+ // this supervisor already settled, or one it never tracked.
2163
+ this.answerUndeliveredWorkerResponse(key, detail);
2164
+ return;
2165
+ }
2166
+ let released = false;
2167
+ this.runRecoveryStep("worker_message.release", () => {
2168
+ // releaseForwardedRequest declines an in-flight `initialize` by design:
2169
+ // that entry belongs to the handshake lifecycle.
2170
+ released = this.releaseForwardedRequest(key, pending);
2171
+ });
2172
+ if (!released) {
2173
+ // `released` is false for three different reasons: the `initialize`
2174
+ // carve-out declined, the release threw before it removed anything, or
2175
+ // the release threw AFTER removing the entry. Only the first two leave
2176
+ // the entry owning its id, and the flag cannot tell them apart — the
2177
+ // step is guarded, so any throw leaves it false, and
2178
+ // releaseForwardedRequest deletes the entry and records its tombstone
2179
+ // before it returns. An entry that no longer owns its id has nothing
2180
+ // left that could ever answer it, so the id is read back rather than
2181
+ // assumed.
2182
+ this.runRecoveryStep("worker_message.release_verify", () => {
2183
+ if (pending.method === "initialize" || this.pendingRequests.has(key))
2184
+ return;
2185
+ // Re-recording is idempotent (see recordFinalityTombstone), and covers
2186
+ // a release that threw before it got this far.
2187
+ this.recordFinalityTombstone(key, pending.mode);
2188
+ released = true;
2189
+ });
2190
+ }
2191
+ if (!released) {
2192
+ // Still live and still owning its id. The `initialize` carve-out leaves
2193
+ // the entry to the handshake lifecycle — but that lifecycle's recovery
2194
+ // paths are not all armed here. The startup watchdog in particular is
2195
+ // cleared by adoptActiveChild, so a client whose `initialize` reached an
2196
+ // ALREADY-READY worker has no watchdog behind it, and a worker that stays
2197
+ // alive triggers neither the exit nor the replay path. What is certain in
2198
+ // every one of those lifecycles is that THIS generation can no longer
2199
+ // finish the handshake: the frame that was lost was its answer. So the
2200
+ // generation is replaced: the current child is invalidated and
2201
+ // terminated, and a successor is spawned — now, or once the live-child
2202
+ // cap allows. That successor arms a fresh startup watchdog and, on
2203
+ // ready, re-forwards the retained initialize (handleWorkerReady), so the
2204
+ // handshake gets a second chance instead of stalling.
2205
+ //
2206
+ // Replacing the generation does not by itself terminalize any OTHER
2207
+ // request forwarded to the old child: when its `exit` eventually fires,
2208
+ // `handleWorkerExit` finds `this.child` already pointing at the
2209
+ // successor and takes its early-return branch, skipping
2210
+ // failPendingRequestsOnWorkerExit entirely. So that call runs here
2211
+ // first, mirroring the precedent at handleWorkerProcessError, and BEFORE
2212
+ // recoverTimedOutWorker replaces the generation. It does not touch the
2213
+ // retained `initialize` itself: failPendingRequestsOnWorkerExit carves
2214
+ // out `this.initializeRequest`'s key, which is exactly the entry this
2215
+ // lifecycle is about to continue on the successor.
2216
+ //
2217
+ // Split into two independent recovery steps, deliberately: these are two
2218
+ // unrelated effects (terminalizing OTHER stranded requests, and replacing
2219
+ // the generation so the retained initialize gets a second chance), and
2220
+ // they must not share a fault boundary. Before failPendingRequestsOnWorkerExit
2221
+ // existed here, recoverTimedOutWorker was the only statement in this step
2222
+ // and ran unconditionally on any path that reached it. Running both in one
2223
+ // `runRecoveryStep` would let a throw inside failPendingRequestsOnWorkerExit
2224
+ // (e.g. its own timerClearer call faulting for some OTHER pending
2225
+ // request's deadline timer) silently swallow the call to
2226
+ // recoverTimedOutWorker that follows it in the same callback — silently
2227
+ // skipping the one guarantee this whole branch exists to provide.
2228
+ if (pending.method === "initialize") {
2229
+ this.runRecoveryStep("worker_message.initialize_recovery_fail_pending", () => {
2230
+ this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
2231
+ });
2232
+ this.runRecoveryStep("worker_message.initialize_recovery", () => {
2233
+ this.recoverTimedOutWorker();
2234
+ });
2235
+ }
2236
+ return;
2237
+ }
2238
+ this.runRecoveryStep("worker_message.reply", () => {
2239
+ this.writeSyntheticReply(pending, {
2240
+ jsonrpc: "2.0",
2241
+ id,
2242
+ error: {
2243
+ code: -32603,
2244
+ message: `MCP supervisor failed to process the worker response: ${detail}`
2245
+ }
2246
+ });
2247
+ });
2248
+ }
2249
+ /**
2250
+ * Answers a request whose pending entry `handleWorkerMessage` had already
2251
+ * removed when it faulted, and which therefore never reached the client.
2252
+ *
2253
+ * Consumed once: the marker is cleared before the reply is attempted, so a
2254
+ * fault in the reply cannot leave a stale claim on the id for a later,
2255
+ * unrelated fault to act on.
2256
+ */
2257
+ answerUndeliveredWorkerResponse(key, detail) {
2258
+ const settling = this.settlingWorkerResponse;
2259
+ this.settlingWorkerResponse = undefined;
2260
+ if (!settling || settling.key !== key)
2261
+ return;
2262
+ this.runRecoveryStep("worker_message.undelivered_reply", () => {
2263
+ // The entry is already gone, so writeSyntheticReply will not settle or
2264
+ // tombstone anything; the tombstone is recorded here so the worker
2265
+ // repeating its answer cannot become a second reply.
2266
+ this.recordFinalityTombstone(key, settling.snapshot.mode);
2267
+ this.writeSyntheticReply(settling.snapshot, {
2268
+ jsonrpc: "2.0",
2269
+ id: settling.snapshot.id,
2270
+ error: {
2271
+ code: -32603,
2272
+ message: `MCP supervisor failed to deliver the worker response: ${detail}`
2273
+ }
2274
+ });
2275
+ });
2276
+ }
1744
2277
  handleWorkerStdinError(child, error) {
1745
2278
  if (child !== this.child)
1746
2279
  return;
@@ -1748,6 +2281,37 @@ export class StdioSupervisor {
1748
2281
  return;
1749
2282
  }
1750
2283
  log("warn", "supervisor.worker_stdin_error", { message: error.message });
2284
+ // This event fires only on child.stdin, which exists only once the child
2285
+ // has actually been spawned, but that does NOT collapse to a single
2286
+ // "always post-ready" case: handleWorkerReady's legacy-era replay of a
2287
+ // retained `initialize` (era === "legacy" with `this.initializeRequest`
2288
+ // set) forwards it to a freshly spawned successor WITHOUT calling
2289
+ // adoptActiveChild first — adoptActiveChild only runs once that
2290
+ // initialize's response actually comes back, or on the no-replay early
2291
+ // return. So a live child can have `child.stdin` while `this.childReady`
2292
+ // is still false, mirroring the fork handleWorkerProcessError already
2293
+ // makes on `wasReady`. Treating that window as post-ready would run
2294
+ // failPendingRequestsOnWorkerExit, which deliberately carves the retained
2295
+ // initialize's pending entry OUT of what it fails (so a later id reuse of
2296
+ // the completed initialize is not wrongly caught) — leaving the client's
2297
+ // `initialize` answered by nothing and re-replayed against every
2298
+ // successor forever if the stdin fault persists. Before this recovery
2299
+ // existed at all, a broken stdin left `this.child` pointing at a worker
2300
+ // nothing could ever write to again: scheduleRestart's own `this.child`
2301
+ // guard made every later restart attempt a permanent no-op, and any
2302
+ // request already forwarded to this child had nothing left that would
2303
+ // ever answer it.
2304
+ const wasReady = this.childReady;
2305
+ this.invalidateCurrentChild(child);
2306
+ this.beginTreeTermination(child);
2307
+ if (!wasReady) {
2308
+ this.handleStartupFailure(this.attemptToken, { code: null, signal: null });
2309
+ }
2310
+ else {
2311
+ this.consecutiveImmediateStandDowns = 0;
2312
+ this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
2313
+ this.scheduleRestart(true);
2314
+ }
1751
2315
  }
1752
2316
  /**
1753
2317
  * Reassembles the worker's stderr into lines (the ready marker may be split
@@ -1908,13 +2472,17 @@ export class StdioSupervisor {
1908
2472
  if (this.isInitializationResponse(message)) {
1909
2473
  const id = getTrackedRequestId(message);
1910
2474
  let initializeMode;
2475
+ let initializeKey;
2476
+ let initializePending;
1911
2477
  if (id !== undefined) {
1912
- const key = requestKey(id);
1913
- initializeMode = this.pendingRequests.get(key)?.mode;
1914
- this.pendingRequests.delete(key);
2478
+ initializeKey = requestKey(id);
2479
+ initializePending = this.pendingRequests.get(initializeKey);
2480
+ initializeMode = initializePending?.mode;
1915
2481
  }
1916
2482
  initializeMode ??= this.modeForMessage(this.initializeRequest);
1917
2483
  if ("error" in message) {
2484
+ if (initializeKey !== undefined)
2485
+ this.pendingRequests.delete(initializeKey);
1918
2486
  if (!this.replayingInitialization && id !== undefined) {
1919
2487
  this.writeSyntheticReply({ id, era: this.era, mode: initializeMode }, buildLegacyJsonRpcError(id));
1920
2488
  const retainedIndex = this.queuedNotifications.findIndex((entry) => isRequest(entry) && requestKey(entry.id) === requestKey(id));
@@ -1930,6 +2498,8 @@ export class StdioSupervisor {
1930
2498
  return;
1931
2499
  }
1932
2500
  if (this.replayingInitialization) {
2501
+ if (initializeKey !== undefined)
2502
+ this.pendingRequests.delete(initializeKey);
1933
2503
  this.replayingInitialization = false;
1934
2504
  if (this.initializedNotification) {
1935
2505
  this.writeToWorker(child, this.initializedNotification);
@@ -1938,9 +2508,22 @@ export class StdioSupervisor {
1938
2508
  this.flushQueue();
1939
2509
  return;
1940
2510
  }
2511
+ // Mark the entry as settling and remove it BEFORE writeToClient, so a
2512
+ // throw there (or in the diagnostic write reporting that throw) still
2513
+ // leaves `settlingWorkerResponse` for `answerUndeliveredWorkerResponse`
2514
+ // to answer this id from later — mirrors the non-initialize response
2515
+ // path below, which does the same for every other successful reply.
2516
+ // Without this, the entry was gone and no marker recorded it, so a
2517
+ // faulted client write here permanently lost the reply to `initialize`.
2518
+ if (initializeKey !== undefined && initializePending) {
2519
+ this.settlingWorkerResponse = { key: initializeKey, snapshot: initializePending };
2520
+ }
2521
+ if (initializeKey !== undefined)
2522
+ this.pendingRequests.delete(initializeKey);
1941
2523
  this.clientInitialized = true;
1942
2524
  this.adoptActiveChild();
1943
2525
  this.writeToClient(message, initializeMode);
2526
+ this.settlingWorkerResponse = undefined;
1944
2527
  this.flushQueue();
1945
2528
  return;
1946
2529
  }
@@ -1967,16 +2550,34 @@ export class StdioSupervisor {
1967
2550
  const pending = this.pendingRequests.get(key);
1968
2551
  responseMode = pending?.mode;
1969
2552
  this.pendingRequests.delete(key);
1970
- if (pending?.deadlineTimer)
1971
- this.timerClearer(pending.deadlineTimer);
2553
+ if (pending && pending.method !== "initialize") {
2554
+ // The entry is gone and the client has not been answered yet. From
2555
+ // here until writeToClient returns, this marker is the only record
2556
+ // that the id is still owed a response (see settlingWorkerResponse).
2557
+ // An in-flight initialize is excluded: it is settled by the handshake
2558
+ // branch above, never here.
2559
+ this.settlingWorkerResponse = { key, snapshot: pending };
2560
+ }
2561
+ // These two clears run BEFORE timerClearer: a validate-project id must
2562
+ // not stay "running" if the timer clear below throws. answerUndelivered-
2563
+ // WorkerResponse (which later answers this id via settlingWorkerResponse
2564
+ // on a writeToClient/drainQueue fault) never touches these two fields,
2565
+ // so leaving them ordered after a throwing call would strand the
2566
+ // barrier and every validate-project admitted behind it, permanently.
1972
2567
  if (pending?.toolName === "validate-project") {
1973
2568
  this.runningValidateKey = undefined;
1974
2569
  if (this.validateBarrierKey === key)
1975
2570
  this.validateBarrierKey = undefined;
1976
2571
  }
2572
+ if (pending?.deadlineTimer)
2573
+ this.timerClearer(pending.deadlineTimer);
1977
2574
  }
1978
2575
  }
1979
2576
  this.writeToClient(message, responseMode);
2577
+ // Past this point the reply has been handed to the client channel, so the
2578
+ // id is no longer owed one by the recovery. A fault in the drain below is
2579
+ // the drain's own problem, not an undelivered response.
2580
+ this.settlingWorkerResponse = undefined;
1980
2581
  this.drainQueue();
1981
2582
  }
1982
2583
  applyStageUpdate(params) {
@@ -2075,7 +2676,12 @@ export class StdioSupervisor {
2075
2676
  // count to N and falsely escalate retryRecommendation to "report-bug".
2076
2677
  const pendingToolNames = [];
2077
2678
  for (const [key, pending] of this.pendingRequests.entries()) {
2078
- if (key === preservedInitializeKey)
2679
+ // Identity, not just id: a client may legally reuse `initialize`'s id
2680
+ // for a later request once initialize has completed. `initializeRequest`
2681
+ // (and so `preservedInitializeKey`) is retained past that point, so a
2682
+ // bare key match would treat the REUSED entry as the still-pending
2683
+ // initialize and skip it here — leaving it answered by nothing.
2684
+ if (key === preservedInitializeKey && pending.method === "initialize")
2079
2685
  continue;
2080
2686
  pendingToolNames.push(pending.toolName);
2081
2687
  }
@@ -2083,23 +2689,59 @@ export class StdioSupervisor {
2083
2689
  for (const [toolName, updated] of updatedByTool) {
2084
2690
  this.recentRestarts.set(toolName, updated);
2085
2691
  }
2692
+ // Each entry's cleanup and its reply run as two SEPARATE recovery steps,
2693
+ // not one bundled try around the whole loop body. Fault containment must
2694
+ // be per-entry, not just per-function: a throw from `this.timerClearer`
2695
+ // (or anything else in the cleanup half) for entry N must not abort the
2696
+ // `for` loop, or every entry after N in Map iteration order is left
2697
+ // completely unprocessed — not answered, and not cleared from
2698
+ // runningValidateKey/validateBarrierKey either. Since the old worker's
2699
+ // own `exit`/`error` handling short-circuits once `this.child` already
2700
+ // points at a successor generation, a request stranded that way here is
2701
+ // stranded permanently, and a stranded validate-project holding the
2702
+ // barrier keys would jam every later validate-project request behind it
2703
+ // forever. Splitting into two steps also means a fault in ONE entry's
2704
+ // cleanup cannot suppress that SAME entry's own reply.
2086
2705
  for (const [key, pending] of [...this.pendingRequests.entries()]) {
2087
- if (key === preservedInitializeKey)
2706
+ // See the identical guard above: a bare key match would also wrongly
2707
+ // skip a request that legally reused the completed initialize's id.
2708
+ if (key === preservedInitializeKey && pending.method === "initialize")
2088
2709
  continue;
2089
- if (pending.deadlineTimer)
2090
- this.timerClearer(pending.deadlineTimer);
2091
- if (this.runningValidateKey === key)
2092
- this.runningValidateKey = undefined;
2093
- if (this.validateBarrierKey === key)
2094
- this.validateBarrierKey = undefined;
2095
- // Forwarded entries stay in pendingRequests until writeSyntheticReply
2096
- // settles them (the FORWARDED pending is what entitles the id to a
2097
- // finality tombstone). Cancelled entries are already gone — the
2098
- // cancellation settled them terminally at admission.
2099
- const toolName = pending.toolName ?? "unknown";
2100
- const pruned = prunedByTool.get(toolName) ?? [];
2101
- const { reply } = buildWorkerRestartReply(pending, exit, now, pruned, { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
2102
- this.writeSyntheticReply(pending, reply);
2710
+ this.runRecoveryStep("worker_exit.fail_pending_cleanup", () => {
2711
+ if (this.runningValidateKey === key)
2712
+ this.runningValidateKey = undefined;
2713
+ if (this.validateBarrierKey === key)
2714
+ this.validateBarrierKey = undefined;
2715
+ // Nil the field immediately after clearing, matching the convention
2716
+ // releaseForwardedRequest already uses. writeSyntheticReply (below,
2717
+ // in the SEPARATE fail_pending_reply step) also does
2718
+ // `if (pending.deadlineTimer) this.timerClearer(...)` before it
2719
+ // deletes the entry from pendingRequests. If this step cleared the
2720
+ // timer but left the field set, that second check would still be
2721
+ // true and writeSyntheticReply would attempt a REDUNDANT second
2722
+ // clear of the SAME already-cleared timer. A timerClearer that
2723
+ // faults on a repeat invocation for the same timer would then throw
2724
+ // BEFORE writeSyntheticReply's delete/tombstone — unlike a fault
2725
+ // strictly after deletion (which only loses that one reply), this
2726
+ // would leave the entry live in pendingRequests forever, with
2727
+ // nothing left to remove or answer it. Nilling here makes
2728
+ // writeSyntheticReply's own check false, so it never attempts that
2729
+ // second clear at all.
2730
+ if (pending.deadlineTimer) {
2731
+ this.timerClearer(pending.deadlineTimer);
2732
+ pending.deadlineTimer = undefined;
2733
+ }
2734
+ });
2735
+ this.runRecoveryStep("worker_exit.fail_pending_reply", () => {
2736
+ // Forwarded entries stay in pendingRequests until writeSyntheticReply
2737
+ // settles them (the FORWARDED pending is what entitles the id to a
2738
+ // finality tombstone). Cancelled entries are already gone — the
2739
+ // cancellation settled them terminally at admission.
2740
+ const toolName = pending.toolName ?? "unknown";
2741
+ const pruned = prunedByTool.get(toolName) ?? [];
2742
+ const { reply } = buildWorkerRestartReply(pending, exit, now, pruned, { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
2743
+ this.writeSyntheticReply(pending, reply);
2744
+ });
2103
2745
  }
2104
2746
  }
2105
2747
  /**
@@ -2205,7 +2847,7 @@ export class StdioSupervisor {
2205
2847
  }
2206
2848
  catch (error) {
2207
2849
  this.eventWriter("warn", "supervisor.client_write_error", {
2208
- message: error instanceof Error ? error.message : String(error)
2850
+ message: describeThrown(error)
2209
2851
  });
2210
2852
  }
2211
2853
  }