@adhisang/minecraft-modding-mcp 7.0.0-rc.2 → 7.0.0-rc.3

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,77 @@ 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
+ this.runRecoveryStep("queue.dispatch_settle", () => {
1932
+ const key = requestKey(entry.pending.id);
1933
+ // Only THIS instance may be settled here. A fault before forwardRequest
1934
+ // installed anything leaves the request re-queued by its own no-child
1935
+ // fallback (a later drain owns it) or already answered by the
1936
+ // queue-limit rejection; either way the id may meanwhile belong to a
1937
+ // different live request, which keeps its own guarantee.
1938
+ if (this.pendingRequests.get(key) !== entry.pending)
1939
+ return;
1940
+ if (!this.releaseForwardedRequest(key, entry.pending))
1941
+ return;
1942
+ this.writeSyntheticReply(entry.pending, {
1943
+ jsonrpc: "2.0",
1944
+ id: entry.pending.id,
1945
+ error: {
1946
+ code: -32603,
1947
+ message: `MCP supervisor failed to dispatch the queued request: ${detail}`
1948
+ }
1949
+ });
1950
+ });
1951
+ return false;
1664
1952
  }
1665
1953
  }
1666
1954
  spawnWorker() {
@@ -1723,7 +2011,15 @@ export class StdioSupervisor {
1723
2011
  return;
1724
2012
  reader.processChunk(chunk, {
1725
2013
  onFrame: ({ message }) => {
1726
- this.handleWorkerMessage(child, message);
2014
+ try {
2015
+ this.handleWorkerMessage(child, message);
2016
+ }
2017
+ catch (error) {
2018
+ // Mirror of the client side's admission safety net (see
2019
+ // handleClientMessage): an unguarded throw here escapes into the
2020
+ // reader, which reports it as a parse error and drops the frame.
2021
+ this.recoverFaultedWorkerMessage(message, error);
2022
+ }
1727
2023
  },
1728
2024
  onError: (error) => {
1729
2025
  if (isJsonRpcFramingFatalError(error)) {
@@ -1741,6 +2037,181 @@ export class StdioSupervisor {
1741
2037
  }
1742
2038
  });
1743
2039
  }
2040
+ /**
2041
+ * Rescues the request a faulted worker frame was answering.
2042
+ *
2043
+ * `handleWorkerMessage` runs inside the worker reader's `onFrame`, where a
2044
+ * throw becomes a parse error and the frame is simply dropped. For a
2045
+ * RESPONSE frame that left the pending entry live with no reply and nothing
2046
+ * left to settle it — deadlines are armed for validate-project only, so any
2047
+ * other tool had no rescue at all and the client waited on that id for the
2048
+ * life of the session. The id is released and answered here instead.
2049
+ *
2050
+ * The queue drain is the OTHER half, and it is why the answering half lives
2051
+ * in its own method: `handleWorkerMessage` deletes a response's pending
2052
+ * entry before the `writeToClient` that can throw, and its `drainQueue()` is
2053
+ * the last statement of all. So the commonest fault arrives here with the
2054
+ * entry already gone AND the queue undrained, and every early return in the
2055
+ * answering path is a case where the drain is the only rescue left.
2056
+ *
2057
+ * What this recovery is worth, stated exactly. It answers the id when this
2058
+ * supervisor is the one entitled to (see `answerFaultedWorkerResponse`), and
2059
+ * every id it answers is tombstoned, so the worker's own answer for that id
2060
+ * cannot become a second reply. It does NOT make one-response-per-id a
2061
+ * property of the whole file: an id whose entry could not be released is
2062
+ * deliberately left unanswered here rather than answered twice, the
2063
+ * `initialize` carve-out is settled by the handshake lifecycle instead, and
2064
+ * tombstone retention is capped at {@link MAX_SYNTHETIC_TOMBSTONES} — past
2065
+ * 1024 live tombstones in one worker generation the oldest is evicted, and a
2066
+ * very old worker's late answer for an evicted id would pass through.
2067
+ *
2068
+ * What this deliberately does NOT do: tear the session down or touch framing
2069
+ * state. One frame the supervisor could not handle is not evidence that the
2070
+ * worker's stream desynchronized (that is `supervisor.worker_framing_fatal`,
2071
+ * reported by the reader itself), and the reader's own state already
2072
+ * describes the stream correctly. The one exception is an in-flight
2073
+ * `initialize`, whose generation cannot finish its handshake once its answer
2074
+ * has been lost — see `answerFaultedWorkerResponse`.
2075
+ */
2076
+ recoverFaultedWorkerMessage(message, error) {
2077
+ // `describeThrown`, not an inline conversion: this line runs BEFORE every
2078
+ // guard below, so a thrown value whose `toString` or `.message` throws
2079
+ // would escape into the reader and drop the frame this method exists to
2080
+ // rescue.
2081
+ const detail = describeThrown(error);
2082
+ this.runRecoveryStep("worker_message.event", () => {
2083
+ this.eventWriter("error", "supervisor.worker_message_failed", {
2084
+ id: "id" in message ? message.id : undefined,
2085
+ method: "method" in message ? message.method : undefined,
2086
+ message: detail
2087
+ });
2088
+ });
2089
+ this.answerFaultedWorkerResponse(message, detail);
2090
+ // Unconditional, and outside every early return above. Running it when the
2091
+ // ordinary tail-drain would not have is bounded rather than free-handed:
2092
+ // drainQueue returns at once unless the current child is ready with a live
2093
+ // stdin, and dispatches nothing at all while a validate-project is running.
2094
+ //
2095
+ // The guard here contains a drain fault; it does not recover one. What
2096
+ // recovers the request a faulting dispatch stranded is
2097
+ // `dispatchQueuedRequest`, one level down — a request shifted out of the
2098
+ // queue and installed at its id has left every path that could re-reach
2099
+ // it, so containment alone would strand it exactly as badly as the throw
2100
+ // this method exists to answer for.
2101
+ this.runRecoveryStep("worker_message.drain", () => {
2102
+ this.drainQueue();
2103
+ });
2104
+ }
2105
+ /**
2106
+ * Terminally answers the client request a faulted worker RESPONSE frame was
2107
+ * carrying the answer for, if this supervisor is still the one entitled to
2108
+ * answer it. Returns without replying otherwise; the caller's drain runs
2109
+ * either way.
2110
+ */
2111
+ answerFaultedWorkerResponse(message, detail) {
2112
+ if (!isResponse(message))
2113
+ return;
2114
+ const id = getTrackedRequestId(message);
2115
+ if (id === undefined)
2116
+ return;
2117
+ const key = requestKey(id);
2118
+ const pending = this.pendingRequests.get(key);
2119
+ if (!pending) {
2120
+ // No entry at the id — which is NOT proof the id was answered.
2121
+ // handleWorkerMessage deletes a response's pending entry BEFORE it writes
2122
+ // the reply, so a write that faults arrives here with the entry already
2123
+ // gone and the client still owed a response. That window is the only
2124
+ // thing `settlingWorkerResponse` records, and it is the only case in
2125
+ // which an id with no entry may be answered here; anything else is an id
2126
+ // this supervisor already settled, or one it never tracked.
2127
+ this.answerUndeliveredWorkerResponse(key, detail);
2128
+ return;
2129
+ }
2130
+ let released = false;
2131
+ this.runRecoveryStep("worker_message.release", () => {
2132
+ // releaseForwardedRequest declines an in-flight `initialize` by design:
2133
+ // that entry belongs to the handshake lifecycle.
2134
+ released = this.releaseForwardedRequest(key, pending);
2135
+ });
2136
+ if (!released) {
2137
+ // `released` is false for three different reasons: the `initialize`
2138
+ // carve-out declined, the release threw before it removed anything, or
2139
+ // the release threw AFTER removing the entry. Only the first two leave
2140
+ // the entry owning its id, and the flag cannot tell them apart — the
2141
+ // step is guarded, so any throw leaves it false, and
2142
+ // releaseForwardedRequest deletes the entry and records its tombstone
2143
+ // before it returns. An entry that no longer owns its id has nothing
2144
+ // left that could ever answer it, so the id is read back rather than
2145
+ // assumed.
2146
+ this.runRecoveryStep("worker_message.release_verify", () => {
2147
+ if (pending.method === "initialize" || this.pendingRequests.has(key))
2148
+ return;
2149
+ // Re-recording is idempotent (see recordFinalityTombstone), and covers
2150
+ // a release that threw before it got this far.
2151
+ this.recordFinalityTombstone(key, pending.mode);
2152
+ released = true;
2153
+ });
2154
+ }
2155
+ if (!released) {
2156
+ // Still live and still owning its id. The `initialize` carve-out leaves
2157
+ // the entry to the handshake lifecycle — but that lifecycle's recovery
2158
+ // paths are not all armed here. The startup watchdog in particular is
2159
+ // cleared by adoptActiveChild, so a client whose `initialize` reached an
2160
+ // ALREADY-READY worker has no watchdog behind it, and a worker that stays
2161
+ // alive triggers neither the exit nor the replay path. What is certain in
2162
+ // every one of those lifecycles is that THIS generation can no longer
2163
+ // finish the handshake: the frame that was lost was its answer. So the
2164
+ // generation is replaced: the current child is invalidated and
2165
+ // terminated, and a successor is spawned — now, or once the live-child
2166
+ // cap allows. That successor arms a fresh startup watchdog and, on
2167
+ // ready, re-forwards the retained initialize (handleWorkerReady), so the
2168
+ // handshake gets a second chance instead of stalling.
2169
+ if (pending.method === "initialize") {
2170
+ this.runRecoveryStep("worker_message.initialize_recovery", () => {
2171
+ this.recoverTimedOutWorker();
2172
+ });
2173
+ }
2174
+ return;
2175
+ }
2176
+ this.runRecoveryStep("worker_message.reply", () => {
2177
+ this.writeSyntheticReply(pending, {
2178
+ jsonrpc: "2.0",
2179
+ id,
2180
+ error: {
2181
+ code: -32603,
2182
+ message: `MCP supervisor failed to process the worker response: ${detail}`
2183
+ }
2184
+ });
2185
+ });
2186
+ }
2187
+ /**
2188
+ * Answers a request whose pending entry `handleWorkerMessage` had already
2189
+ * removed when it faulted, and which therefore never reached the client.
2190
+ *
2191
+ * Consumed once: the marker is cleared before the reply is attempted, so a
2192
+ * fault in the reply cannot leave a stale claim on the id for a later,
2193
+ * unrelated fault to act on.
2194
+ */
2195
+ answerUndeliveredWorkerResponse(key, detail) {
2196
+ const settling = this.settlingWorkerResponse;
2197
+ this.settlingWorkerResponse = undefined;
2198
+ if (!settling || settling.key !== key)
2199
+ return;
2200
+ this.runRecoveryStep("worker_message.undelivered_reply", () => {
2201
+ // The entry is already gone, so writeSyntheticReply will not settle or
2202
+ // tombstone anything; the tombstone is recorded here so the worker
2203
+ // repeating its answer cannot become a second reply.
2204
+ this.recordFinalityTombstone(key, settling.snapshot.mode);
2205
+ this.writeSyntheticReply(settling.snapshot, {
2206
+ jsonrpc: "2.0",
2207
+ id: settling.snapshot.id,
2208
+ error: {
2209
+ code: -32603,
2210
+ message: `MCP supervisor failed to deliver the worker response: ${detail}`
2211
+ }
2212
+ });
2213
+ });
2214
+ }
1744
2215
  handleWorkerStdinError(child, error) {
1745
2216
  if (child !== this.child)
1746
2217
  return;
@@ -1967,6 +2438,14 @@ export class StdioSupervisor {
1967
2438
  const pending = this.pendingRequests.get(key);
1968
2439
  responseMode = pending?.mode;
1969
2440
  this.pendingRequests.delete(key);
2441
+ if (pending && pending.method !== "initialize") {
2442
+ // The entry is gone and the client has not been answered yet. From
2443
+ // here until writeToClient returns, this marker is the only record
2444
+ // that the id is still owed a response (see settlingWorkerResponse).
2445
+ // An in-flight initialize is excluded: it is settled by the handshake
2446
+ // branch above, never here.
2447
+ this.settlingWorkerResponse = { key, snapshot: pending };
2448
+ }
1970
2449
  if (pending?.deadlineTimer)
1971
2450
  this.timerClearer(pending.deadlineTimer);
1972
2451
  if (pending?.toolName === "validate-project") {
@@ -1977,6 +2456,10 @@ export class StdioSupervisor {
1977
2456
  }
1978
2457
  }
1979
2458
  this.writeToClient(message, responseMode);
2459
+ // Past this point the reply has been handed to the client channel, so the
2460
+ // id is no longer owed one by the recovery. A fault in the drain below is
2461
+ // the drain's own problem, not an undelivered response.
2462
+ this.settlingWorkerResponse = undefined;
1980
2463
  this.drainQueue();
1981
2464
  }
1982
2465
  applyStageUpdate(params) {