@adhisang/minecraft-modding-mcp 6.2.0 → 7.0.0-rc.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 (98) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +21 -5
  3. package/dist/cache-policy.d.ts +71 -0
  4. package/dist/cache-policy.js +83 -0
  5. package/dist/cache-registry.js +6 -6
  6. package/dist/cli.js +74 -3
  7. package/dist/compat-stdio-transport.d.ts +1 -1
  8. package/dist/compat-stdio-transport.js +13 -1
  9. package/dist/config.d.ts +3 -0
  10. package/dist/config.js +8 -2
  11. package/dist/decompiler/vineflower.d.ts +1 -0
  12. package/dist/decompiler/vineflower.js +8 -5
  13. package/dist/entry-tools/analyze-mod-service.d.ts +70 -136
  14. package/dist/entry-tools/analyze-symbol-service.d.ts +112 -150
  15. package/dist/entry-tools/compare-minecraft-service.d.ts +59 -145
  16. package/dist/entry-tools/entry-tool-schema.d.ts +38 -4
  17. package/dist/entry-tools/entry-tool-schema.js +4 -1
  18. package/dist/entry-tools/inspect-minecraft/internal.d.ts +235 -799
  19. package/dist/entry-tools/inspect-minecraft/internal.js +65 -28
  20. package/dist/entry-tools/inspect-minecraft-service.d.ts +372 -1736
  21. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  22. package/dist/entry-tools/manage-cache-service.d.ts +81 -91
  23. package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
  24. package/dist/entry-tools/validate-project-service.d.ts +164 -592
  25. package/dist/entry-tools/verify-mixin-target-service.d.ts +3 -19
  26. package/dist/era-classifier.d.ts +161 -0
  27. package/dist/era-classifier.js +292 -0
  28. package/dist/error-mapping.js +9 -2
  29. package/dist/index.d.ts +42 -4
  30. package/dist/index.js +637 -475
  31. package/dist/java-process.d.ts +2 -0
  32. package/dist/java-process.js +22 -2
  33. package/dist/json-rpc-framing.d.ts +77 -1
  34. package/dist/json-rpc-framing.js +249 -13
  35. package/dist/mapping/loaders/tiny-loom-selection.d.ts +88 -0
  36. package/dist/mapping/loaders/tiny-loom-selection.js +223 -0
  37. package/dist/mapping/loaders/tiny-loom.js +45 -33
  38. package/dist/mapping/loaders/tiny-maven.js +6 -11
  39. package/dist/mapping/parsers/tiny.d.ts +57 -0
  40. package/dist/mapping/parsers/tiny.js +99 -22
  41. package/dist/mapping-service.d.ts +19 -0
  42. package/dist/mapping-service.js +93 -9
  43. package/dist/mcp-helpers.d.ts +19 -2
  44. package/dist/mcp-helpers.js +48 -6
  45. package/dist/minecraft-explorer-service.d.ts +1 -1
  46. package/dist/mixin/types.d.ts +8 -0
  47. package/dist/mod-analyzer.js +7 -7
  48. package/dist/mod-decompile-service.js +1 -0
  49. package/dist/nbt/java-nbt-codec.js +12 -2
  50. package/dist/nbt/json-patch.js +14 -3
  51. package/dist/nbt/pipeline.js +40 -3
  52. package/dist/nbt/typed-json.js +26 -1
  53. package/dist/registration-adapter.d.ts +32 -0
  54. package/dist/registration-adapter.js +52 -0
  55. package/dist/request-context.d.ts +7 -0
  56. package/dist/request-context.js +9 -0
  57. package/dist/resources.d.ts +1 -1
  58. package/dist/resources.js +25 -19
  59. package/dist/server-identity.d.ts +27 -0
  60. package/dist/server-identity.js +26 -0
  61. package/dist/source/access-validate.js +53 -0
  62. package/dist/source/artifact-resolver.d.ts +69 -1
  63. package/dist/source/artifact-resolver.js +215 -14
  64. package/dist/source/class-source.d.ts +22 -0
  65. package/dist/source/class-source.js +162 -29
  66. package/dist/source/did-you-mean.d.ts +12 -1
  67. package/dist/source/did-you-mean.js +6 -2
  68. package/dist/source/file-access.js +150 -46
  69. package/dist/source/indexer.js +1 -0
  70. package/dist/source/nested-jars.d.ts +19 -0
  71. package/dist/source/nested-jars.js +90 -21
  72. package/dist/source/shared-utils.d.ts +21 -0
  73. package/dist/source/shared-utils.js +23 -0
  74. package/dist/source-service.d.ts +12 -0
  75. package/dist/source-service.js +3 -0
  76. package/dist/stdio-supervisor.d.ts +357 -2
  77. package/dist/stdio-supervisor.js +1031 -80
  78. package/dist/storage/db.d.ts +2 -1
  79. package/dist/storage/db.js +15 -8
  80. package/dist/synthetic-decorator.d.ts +24 -0
  81. package/dist/synthetic-decorator.js +48 -0
  82. package/dist/tool-contract-manifest.js +1 -1
  83. package/dist/tool-guidance.d.ts +17 -1
  84. package/dist/tool-guidance.js +417 -13
  85. package/dist/tool-schema-registry.d.ts +2 -0
  86. package/dist/tool-schema-registry.js +4 -0
  87. package/dist/tool-schemas.d.ts +2214 -3915
  88. package/dist/tool-schemas.js +34 -7
  89. package/dist/types.d.ts +35 -0
  90. package/dist/v1-parity-schemas.d.ts +7 -0
  91. package/dist/v1-parity-schemas.js +5584 -0
  92. package/dist/version-diff-service.d.ts +33 -0
  93. package/dist/version-diff-service.js +148 -3
  94. package/dist/version-service.js +36 -14
  95. package/dist/warning-details.js +18 -1
  96. package/docs/README-ja.md +9 -3
  97. package/docs/tool-reference.md +267 -22
  98. package/package.json +12 -9
@@ -1,9 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import process from "node:process";
3
- import { encodeJsonRpcMessage, JsonRpcFrameReader } from "./json-rpc-framing.js";
3
+ import { encodeJsonRpcMessage, isJsonRpcFramingFatalError, JsonRpcFrameReader } from "./json-rpc-framing.js";
4
4
  import { log } from "./logger.js";
5
5
  import { buildSuggestedCall } from "./build-suggested-call.js";
6
- import { getToolSchema } from "./tool-schema-registry.js";
6
+ import { getToolSchema, registeredToolCount } from "./tool-schema-registry.js";
7
+ import { buildEraConflictRejection, buildInvalidInitializeRejection, buildMethodNotFoundRejection, buildMissingMetaRejection, buildUnsupportedProtocolVersionRejection, classifyEraSignal, extractModernRequestContext, isCompleteInitializeRequest, stripModernEraClaimInPlace, MODERN_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from "./era-classifier.js";
8
+ import { decorateSyntheticReply } from "./synthetic-decorator.js";
7
9
  const DEFAULT_CLIENT_MODE = "line";
8
10
  const WORKER_MODE_ENV = "MCP_STDIO_WORKER_MODE";
9
11
  const WORKER_READY_MARKER = "__MCP_STDIO_WORKER_READY__";
@@ -21,7 +23,38 @@ const MIN_VALIDATE_PROJECT_TIMEOUT_MS = 10_000;
21
23
  const MAX_VALIDATE_PROJECT_TIMEOUT_MS = 600_000;
22
24
  const MAX_WORKER_STARTUP_WATCHDOG_MS = 30_000;
23
25
  const MAX_SUPERVISOR_QUEUE = 2;
26
+ /**
27
+ * Hard ceiling on retained response-finality tombstones (see
28
+ * syntheticTombstones). Retention is normally bounded per worker generation,
29
+ * but a client can synthesize terminal outcomes (queue overflow, cancellation)
30
+ * for unlimited fresh ids INSIDE one healthy generation, so the map also
31
+ * evicts in insertion order once it reaches this size. Losing the oldest
32
+ * tombstone can at worst let a very old late worker answer through, which is
33
+ * strictly preferable to unbounded supervisor memory.
34
+ */
35
+ const MAX_SYNTHETIC_TOMBSTONES = 1024;
36
+ /**
37
+ * Methods the supervisor answers `-32601 Method not found` at admission in
38
+ * every NON-legacy state. `subscriptions/listen` is auto-provided by the SDK
39
+ * stdio entry with zero registration, so the worker would otherwise ACCEPT it
40
+ * and answer with an id-less acknowledgment notification, never settling the
41
+ * request id (see docs/tool-reference.md, "Absent modern surfaces"). The
42
+ * legacy era needs no entry: nothing registers the method there, so the
43
+ * worker's own registry answers -32601.
44
+ */
45
+ const MODERN_UNSUPPORTED_METHODS = new Set(["subscriptions/listen"]);
24
46
  const DEFAULT_TREE_CLEANUP_TIMEOUT_MS = 5_000;
47
+ /**
48
+ * How long a fatal-error teardown may run before the process is ended anyway.
49
+ *
50
+ * Registering `uncaughtException`/`unhandledRejection` handlers suppresses
51
+ * node's default abort, so the non-zero exit has to be performed explicitly.
52
+ * Cleanup gets this long to reap the worker's process group first; a teardown
53
+ * that stalls past it must not be allowed to keep a crashed supervisor alive.
54
+ */
55
+ const FATAL_SHUTDOWN_WATCHDOG_MS = 10_000;
56
+ const DEFAULT_CLEANUP_TOKEN_RETRY_BASE_MS = 1_000;
57
+ const DEFAULT_CLEANUP_TOKEN_RETRY_CAP_MS = 30_000;
25
58
  export function loadValidateProjectTimeoutMs(value = process.env.MCP_VALIDATE_PROJECT_TIMEOUT_MS) {
26
59
  if (!/^[0-9]+$/.test(value ?? "")) {
27
60
  return DEFAULT_VALIDATE_PROJECT_TIMEOUT_MS;
@@ -43,6 +76,22 @@ export function computeRestartBackoffMs(retryIndex) {
43
76
  }
44
77
  return Math.min(100 * (2 ** Math.floor(retryIndex)), MAX_WORKER_STARTUP_WATCHDOG_MS);
45
78
  }
79
+ /**
80
+ * How long an adopted worker must stay ready before a CLEAN voluntary
81
+ * stand-down counts as the end of a generation that actually served.
82
+ *
83
+ * A worker stands down on stdin EOF. On a host that hands every spawned child
84
+ * a stdin which closes immediately, that EOF arrives while the worker is still
85
+ * starting: it writes its ready marker, is adopted (which clears the restart
86
+ * backoff), and exits 0 in the same breath. Answering that with the 100 ms
87
+ * restart floor respawns a ~125 MB process ten times a second forever, so a
88
+ * stand-down inside this window escalates the backoff instead.
89
+ *
90
+ * A CRASH is deliberately not covered: it may be transient and keeps the
91
+ * prompt replacement the restart suites pin, whereas a clean stand-down means
92
+ * the host closed the pipe and will close the replacement's stdin the same way.
93
+ */
94
+ export const MIN_HEALTHY_WORKER_READY_MS = 1_000;
46
95
  export function terminatePosixProcessGroup(pid, kill = process.kill) {
47
96
  try {
48
97
  kill(-pid, "SIGKILL");
@@ -76,6 +125,20 @@ export class RestartBackoffState {
76
125
  reset() {
77
126
  this.retryIndex = 0;
78
127
  }
128
+ /**
129
+ * Raise the escalation level without reserving a restart.
130
+ *
131
+ * Adoption resets the index, so a generation that was adopted and then died
132
+ * without ever serving arrives here with the level already cleared. This
133
+ * restores the level that the repeated failure has earned, which is what
134
+ * stops a self-cancelling adopt/reset cycle from pinning every restart to
135
+ * the 100 ms floor.
136
+ */
137
+ escalateTo(level) {
138
+ if (!Number.isFinite(level) || level <= 0)
139
+ return;
140
+ this.retryIndex = Math.max(this.retryIndex, Math.floor(level));
141
+ }
79
142
  }
80
143
  export function retryPosixTreeToken(pid, kill = process.kill) {
81
144
  return terminatePosixProcessGroup(pid, kill);
@@ -109,6 +172,9 @@ function isNotification(message) {
109
172
  function isResponse(message) {
110
173
  return !("method" in message) && "id" in message;
111
174
  }
175
+ function isToolArgumentsRecord(value) {
176
+ return typeof value === "object" && value !== null && !Array.isArray(value);
177
+ }
112
178
  function getTrackedRequestId(message) {
113
179
  return typeof message.id === "string" || typeof message.id === "number"
114
180
  ? message.id
@@ -117,6 +183,49 @@ function getTrackedRequestId(message) {
117
183
  function requestKey(id) {
118
184
  return `${typeof id}:${String(id)}`;
119
185
  }
186
+ /**
187
+ * Frozen premigration reply for a tools/call whose name is not registered
188
+ * (typo OR flag-disabled tool — indistinguishable, exactly as the v1 SDK
189
+ * answered): a SUCCESSFUL CallToolResult with isError:true and one text
190
+ * content entry embedding the v1 error string. The v2 SDK throws a raw
191
+ * -32602 instead, so the supervisor synthesizes this envelope for
192
+ * LEGACY-era registry misses (see handleClientMessage) to keep the legacy
193
+ * wire byte-identical to the premigration baseline
194
+ * (tests/fixtures/premigration/error-code-inventory.json, disabled-tool
195
+ * row). The modern era keeps the raw -32602 (sanctioned: tool absence is a
196
+ * params problem on 2026-07-28, mirroring the SDK's own not-found mapping).
197
+ */
198
+ export function buildUnknownToolNotFoundReply(id, toolName) {
199
+ return {
200
+ jsonrpc: "2.0",
201
+ id,
202
+ result: {
203
+ content: [{ type: "text", text: `MCP error -32602: Tool ${toolName} not found` }],
204
+ isError: true
205
+ }
206
+ };
207
+ }
208
+ function buildLegacyMalformedToolArgumentsReply(id, argumentsValue) {
209
+ const received = argumentsValue === null
210
+ ? "null"
211
+ : Array.isArray(argumentsValue)
212
+ ? "array"
213
+ : typeof argumentsValue;
214
+ const message = JSON.stringify([
215
+ {
216
+ code: "invalid_type",
217
+ expected: "object",
218
+ received,
219
+ path: ["params", "arguments"],
220
+ message: `Expected object, received ${received}`
221
+ }
222
+ ], null, 2);
223
+ return {
224
+ jsonrpc: "2.0",
225
+ id,
226
+ error: { code: -32603, message }
227
+ };
228
+ }
120
229
  export function buildLegacyJsonRpcError(id) {
121
230
  return {
122
231
  jsonrpc: "2.0",
@@ -489,6 +598,8 @@ export class StdioSupervisor {
489
598
  clientWriter;
490
599
  treeTokenRetrier;
491
600
  treeCleanupTimeoutMs;
601
+ cleanupTokenRetryBaseMs;
602
+ cleanupTokenRetryCapMs;
492
603
  eventWriter;
493
604
  monotonicNow;
494
605
  timerScheduler;
@@ -507,10 +618,59 @@ export class StdioSupervisor {
507
618
  unresolvedTreeTokens = new Set();
508
619
  restartBackoff = new RestartBackoffState();
509
620
  terminalChildren = new WeakSet();
621
+ /**
622
+ * Monotonic worker-generation counter (incremented per successful spawn).
623
+ * Recorded into finality tombstones so retention can be bounded per
624
+ * generation; hand-injected children in tests that bypass spawnWorker run
625
+ * as generation 0.
626
+ */
627
+ workerGeneration = 0;
628
+ /**
629
+ * Response-finality tombstones: request key → {originating worker
630
+ * generation, originating framing mode}, recorded by writeSyntheticReply
631
+ * the moment a synthetic response is written. While a tombstone exists, any
632
+ * worker RESPONSE for that id is discarded (with a logged
633
+ * `supervisor.late_response_discarded` event) instead of being forwarded —
634
+ * the synthesized response was terminal, and exactly one response per id
635
+ * must reach the client. A tombstone is cleared when its id is re-forwarded
636
+ * to a worker (forwardRequest), because a client may legally retry with the
637
+ * same id; finality is per request INSTANCE, not per id forever.
638
+ *
639
+ * `mode` is retained deliberately even though discard writes nothing today:
640
+ * a tombstoned id keeps its originating framing mode until discard, so any
641
+ * future terminal write for that id — should the discard policy ever gain
642
+ * one — answers in the originating framing.
643
+ *
644
+ * Retention bound: entries with generation ≤ G are dropped when the
645
+ * generation-G child emits "close" (process ended AND its stdio streams
646
+ * closed — Node's guarantee for that event). This is safe: worker frames
647
+ * only enter handleWorkerMessage from the CURRENT child, the supervisor
648
+ * only forwards ids with live pending entries, and forwarding clears the
649
+ * id's tombstone — so once every generation that could have seen a
650
+ * tombstoned id is gone with streams closed, no current child can ever
651
+ * deliver a frame for it. Also cleared wholesale on shutdown.
652
+ */
653
+ syntheticTombstones = new Map();
510
654
  child;
511
655
  childReady = false;
656
+ /**
657
+ * Monotonic timestamp of the current generation's adoption, or undefined
658
+ * while no generation is adopted. Read once at exit to tell a generation
659
+ * that served from one that stood down the moment it came up.
660
+ */
661
+ childReadyAt;
662
+ /**
663
+ * Consecutive adopted generations that stood down CLEANLY before they had
664
+ * been ready for MIN_HEALTHY_WORKER_READY_MS. Any other outcome — a crash, a
665
+ * signal, a generation that served — clears it.
666
+ */
667
+ consecutiveImmediateStandDowns = 0;
512
668
  shuttingDown = false;
513
669
  restartTimer;
670
+ cleanupRetryTimer;
671
+ cleanupRetryIndex = 0;
672
+ cleanupRetryAttempts = new Map();
673
+ cleanupRetriesInFlight = new Map();
514
674
  startupWatchdog;
515
675
  validateBarrierKey;
516
676
  runningValidateKey;
@@ -519,12 +679,58 @@ export class StdioSupervisor {
519
679
  currentRetryReservation;
520
680
  retryPaused = false;
521
681
  workerStderrBuffer = "";
522
- clientMode = DEFAULT_CLIENT_MODE;
682
+ /**
683
+ * Framing mode of the most recently detected inbound client frame. NOT used
684
+ * for request-correlated writes (those use the originating request's
685
+ * captured mode); this is only the documented fallback for client-bound
686
+ * writes that correlate to no request id (worker-originated notifications
687
+ * and server->client requests forwarded at handleWorkerMessage, and
688
+ * untracked-id responses with no finality tombstone — tombstoned ids are
689
+ * discarded, never forwarded).
690
+ */
691
+ lastInboundClientMode = DEFAULT_CLIENT_MODE;
692
+ /**
693
+ * Per-frame framing mode of every inbound client message, keyed by message
694
+ * object identity. Lets admission (createPendingRequest), queued-message
695
+ * flushes, and initialize replay recover the ORIGINATING frame's mode long
696
+ * after the frame was parsed.
697
+ */
698
+ inboundFrameModes = new WeakMap();
523
699
  initializeRequest;
524
700
  initializedNotification;
701
+ /**
702
+ * Number of queued requests that arrived before an initial initialize while
703
+ * the worker was unavailable. Only era-neutral modern discovers can occupy
704
+ * this prefix; readiness forwards it before initialize, then keeps the queue
705
+ * suffix gated until the initialization response.
706
+ */
707
+ initializePredecessorCount = 0;
525
708
  clientInitialized = false;
526
709
  replayingInitialization = false;
527
710
  initializeSentToWorker = false;
711
+ /**
712
+ * Process-lifetime era state. The supervisor is the SOLE era gatekeeper:
713
+ * classification happens at admission (handleClientMessage), in stdin
714
+ * order. The first valid NON-discover era signal locks the era (initialize
715
+ * → legacy; shallow-valid modern `_meta` request → modern); the lock is
716
+ * one-way and survives worker restarts.
717
+ */
718
+ era = "unselected";
719
+ /**
720
+ * Whether `start()` attached this instance to the real process stdio. Only
721
+ * then may a teardown touch process-wide state (the framing-fatal exit
722
+ * code); white-box suites construct supervisors without starting them and
723
+ * must never have their own runner's exit status rewritten.
724
+ */
725
+ ownsProcessStdio = false;
726
+ /**
727
+ * Whether a fatal fault has already armed the explicit process exit. A
728
+ * second fault must not stack another watchdog or cut the first teardown
729
+ * short.
730
+ */
731
+ fatalExitScheduled = false;
732
+ /** Memoized teardown; see shutdown(). */
733
+ shutdownRun;
528
734
  constructor(options) {
529
735
  this.entryFile = options.entryFile;
530
736
  this.validateProjectTimeoutMs = options.validateProjectTimeoutMs ?? loadValidateProjectTimeoutMs();
@@ -532,6 +738,8 @@ export class StdioSupervisor {
532
738
  this.clientWriter = options.clientWriter;
533
739
  this.treeTokenRetrier = options.treeTokenRetrier;
534
740
  this.treeCleanupTimeoutMs = options.treeCleanupTimeoutMs ?? DEFAULT_TREE_CLEANUP_TIMEOUT_MS;
741
+ this.cleanupTokenRetryBaseMs = Math.max(1, Math.floor(options.cleanupTokenRetryBaseMs ?? DEFAULT_CLEANUP_TOKEN_RETRY_BASE_MS));
742
+ this.cleanupTokenRetryCapMs = Math.max(this.cleanupTokenRetryBaseMs, Math.floor(options.cleanupTokenRetryCapMs ?? DEFAULT_CLEANUP_TOKEN_RETRY_CAP_MS));
535
743
  this.eventWriter = options.eventWriter ?? log;
536
744
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
537
745
  this.timerScheduler = options.timerScheduler ?? ((callback, delayMs) => setTimeout(callback, delayMs));
@@ -547,6 +755,7 @@ export class StdioSupervisor {
547
755
  this.treeTerminator = options.treeTerminator;
548
756
  }
549
757
  async start() {
758
+ this.ownsProcessStdio = true;
550
759
  process.stdin.on("data", this.handleClientData);
551
760
  process.stdin.on("error", this.handleClientError);
552
761
  process.stdin.on("end", this.handleClientClosed);
@@ -554,19 +763,51 @@ export class StdioSupervisor {
554
763
  process.stdin.resume();
555
764
  process.on("SIGINT", this.handleTerminateSignal);
556
765
  process.on("SIGTERM", this.handleTerminateSignal);
766
+ // SIGHUP is what a terminating launcher or a vanishing session sends.
767
+ // Left to the OS default it kills this process where it stands, and the
768
+ // detached worker process group survives with nobody left to reap it.
769
+ process.on("SIGHUP", this.handleTerminateSignal);
770
+ // The supervisor OWNS this process's fatal handlers: it is the only party
771
+ // that can reach shutdown() and terminate the worker's process group.
772
+ // Node's default handler prints a stack and leaves the worker orphaned.
773
+ process.on("uncaughtException", this.handleFatalError);
774
+ process.on("unhandledRejection", this.handleFatalError);
557
775
  this.spawnWorker();
558
776
  }
559
777
  handleClientData = (chunk) => {
560
778
  this.clientReader.processChunk(chunk, {
561
779
  onFrame: ({ message, mode }) => {
562
- this.clientMode = mode;
780
+ this.inboundFrameModes.set(message, mode);
781
+ this.lastInboundClientMode = mode;
563
782
  this.handleClientMessage(message);
564
783
  },
565
784
  onError: (error) => {
785
+ if (isJsonRpcFramingFatalError(error)) {
786
+ // The client stream can no longer be framed. Every later byte would
787
+ // be guesswork, so the session ends with this diagnostic rather than
788
+ // leaving a live process that silently answers nothing (the wedge
789
+ // this repair removes).
790
+ this.eventWriter("error", "supervisor.client_framing_fatal", {
791
+ message: error.message
792
+ });
793
+ if (this.ownsProcessStdio) {
794
+ // A client protocol violation ended the session — distinguishable
795
+ // by a launcher from the code 0 of an ordinary stdin close. Set the
796
+ // code rather than calling process.exit(), so the shutdown finishes
797
+ // and the diagnostic is flushed.
798
+ process.exitCode = 1;
799
+ }
800
+ void this.shutdown();
801
+ return;
802
+ }
566
803
  log("warn", "supervisor.client_parse_error", { message: error.message });
567
804
  }
568
805
  });
569
806
  };
807
+ /** The captured per-frame mode of an inbound client message, if known. */
808
+ modeForMessage(message) {
809
+ return message === undefined ? undefined : this.inboundFrameModes.get(message);
810
+ }
570
811
  handleClientError = (error) => {
571
812
  log("warn", "supervisor.client_stream_error", { message: error.message });
572
813
  };
@@ -576,6 +817,44 @@ export class StdioSupervisor {
576
817
  handleTerminateSignal = () => {
577
818
  void this.shutdown();
578
819
  };
820
+ /**
821
+ * Terminal fault in the supervisor process. Reported through the injected
822
+ * event writer, then routed into the ordinary shutdown so the detached
823
+ * worker group is reaped rather than orphaned by a crash.
824
+ */
825
+ handleFatalError = (reason) => {
826
+ const error = reason instanceof Error ? reason : new Error(String(reason));
827
+ this.eventWriter("error", "supervisor.fatal", {
828
+ message: error.message,
829
+ stack: error.stack
830
+ });
831
+ // Always the SAME teardown promise, so a second fault arriving mid-shutdown
832
+ // waits for the worker group to be reaped instead of racing it to the exit.
833
+ const teardown = this.shutdown();
834
+ if (!this.ownsProcessStdio) {
835
+ // Embedded or white-box supervisor: the host owns this process's fate.
836
+ void teardown.catch(() => undefined);
837
+ return;
838
+ }
839
+ process.exitCode = 1;
840
+ if (this.fatalExitScheduled)
841
+ return;
842
+ this.fatalExitScheduled = true;
843
+ // Registering fatal handlers suppressed node's default abort, so the exit
844
+ // is no longer implied by the crash — it has to be performed. Cleanup
845
+ // first, bounded, then leave with the non-zero status regardless of what
846
+ // is still holding the event loop.
847
+ const watchdog = setTimeout(() => {
848
+ this.eventWriter("error", "supervisor.fatal_exit_forced", {
849
+ timeoutMs: FATAL_SHUTDOWN_WATCHDOG_MS
850
+ });
851
+ process.exit(1);
852
+ }, FATAL_SHUTDOWN_WATCHDOG_MS);
853
+ void teardown.catch(() => undefined).then(() => {
854
+ clearTimeout(watchdog);
855
+ process.exit(1);
856
+ });
857
+ };
579
858
  handleClientMessage(message) {
580
859
  debugSupervisor("client_message", {
581
860
  hasMethod: "method" in message,
@@ -583,45 +862,62 @@ export class StdioSupervisor {
583
862
  id: "id" in message ? message.id : undefined,
584
863
  childReady: this.childReady
585
864
  });
586
- if (isRequest(message) && message.method === "initialize") {
587
- this.initializeRequest = message;
588
- this.clientInitialized = false;
589
- }
590
- else if (isNotification(message) && message.method === "notifications/initialized") {
591
- this.initializedNotification = message;
592
- }
593
865
  if (isNotification(message)) {
594
- if (message.method === "notifications/cancelled") {
595
- this.handleCancellation(message);
596
- return;
597
- }
598
- if (!this.childReady) {
599
- if (!shouldRetainUnavailableNotification(message.method)) {
600
- this.eventWriter("warn", "supervisor.notification_dropped", {
601
- method: message.method,
602
- reason: this.liveCapOccupancy() >= 2 ? "live-cap-blocked" : "worker-unavailable"
603
- });
604
- }
605
- return;
606
- }
607
- if (this.child && !this.child.stdin.destroyed) {
608
- this.writeToWorker(this.child, message);
609
- }
866
+ this.handleClientNotification(message);
610
867
  return;
611
868
  }
612
869
  if (!isRequest(message)) {
613
870
  return;
614
871
  }
615
872
  if (message.method === "initialize") {
873
+ // initialize is the legacy era signal regardless of any _meta envelope
874
+ // it carries: the envelope is ignored for era classification here.
875
+ if (this.era === "modern") {
876
+ // One-way era lock: a rejected initialize is NEVER captured into the
877
+ // replay cache.
878
+ this.writeToClient(buildEraConflictRejection(message.id, "modern"), this.modeForMessage(message));
879
+ return;
880
+ }
881
+ if (!isCompleteInitializeRequest(message)) {
882
+ // The legacy lock is ONE-WAY and lasts the process lifetime, so it may
883
+ // only be committed on a frame that is actually a valid era opening.
884
+ // Generic JSON-RPC parsing is not that check: `params: {}` reaches
885
+ // here as a well-formed request and used to burn the lock, after which
886
+ // the worker rejected the handshake, the supervisor replaced that with
887
+ // a -32603 telling the client to RETRY, and the retry — or any modern
888
+ // request — could never succeed in this process again. Rejecting here
889
+ // leaves the era UNSELECTED, which makes that advice true.
890
+ this.eventWriter("warn", "supervisor.invalid_initialize_rejected", {
891
+ id: message.id
892
+ });
893
+ this.writeToClient(buildInvalidInitializeRejection(message.id), this.modeForMessage(message));
894
+ return;
895
+ }
896
+ this.era = "legacy";
897
+ // The SDK's opening classifier treats an initialize that carries a
898
+ // valid modern era claim as MODERN, diverging from this admission rule
899
+ // ("initialize is the legacy era signal; the envelope is ignored") and
900
+ // failing the handshake on the worker. Strip the era-claim keys before
901
+ // the frame is cached, queued, or forwarded (in place: the framing-mode
902
+ // registry is keyed by this exact frame object).
903
+ stripModernEraClaimInPlace(message.params);
616
904
  if (!this.child && this.liveCapOccupancy() >= 2) {
905
+ // Capture-ordering fix: the cap-blocked rejection happens BEFORE
906
+ // capture, so a rejected initialize can never enter (or corrupt) the
907
+ // replay cache. clearInitialInitializationState() still discards any
908
+ // NOT-yet-completed earlier handshake, exactly as before.
617
909
  this.clearInitialInitializationState();
618
- this.writeToClient(buildLegacyJsonRpcError(message.id));
910
+ this.writeSyntheticReply({ id: message.id, era: this.era, mode: this.modeForMessage(message) }, buildLegacyJsonRpcError(message.id));
619
911
  return;
620
912
  }
913
+ this.initializeRequest = message;
914
+ this.clientInitialized = false;
621
915
  if (this.childReady) {
916
+ this.initializePredecessorCount = 0;
622
917
  this.forwardRequest(message, this.createPendingRequest(message));
623
918
  }
624
919
  else {
920
+ this.initializePredecessorCount = this.queuedRequests.length;
625
921
  const existing = this.queuedNotifications.findIndex((entry) => isRequest(entry) && entry.method === "initialize");
626
922
  if (existing >= 0)
627
923
  this.queuedNotifications.splice(existing, 1);
@@ -629,19 +925,78 @@ export class StdioSupervisor {
629
925
  }
630
926
  return;
631
927
  }
632
- const pending = this.createPendingRequest(message);
928
+ const eraSignal = this.admitEraRequest(message);
929
+ if (!eraSignal) {
930
+ return;
931
+ }
932
+ if (this.era !== "legacy" && MODERN_UNSUPPORTED_METHODS.has(message.method)) {
933
+ // Admission-time method rejection, AFTER the envelope check and the era
934
+ // lock admitEraRequest already applied, so the documented ordering holds:
935
+ // a claim-less listen in a non-legacy state fails -32602 first, a
936
+ // modern-signal listen still locks modern, and only then is the method
937
+ // itself rejected. Rejected pre-queue: no pendingRequests entry is
938
+ // created, so an unanswerable request can never occupy a dispatch
939
+ // barrier or a queue slot.
940
+ this.eventWriter("info", "supervisor.unsupported_method_rejected", {
941
+ id: message.id,
942
+ method: message.method,
943
+ era: this.era
944
+ });
945
+ this.writeToClient(buildMethodNotFoundRejection(message.id), this.modeForMessage(message));
946
+ return;
947
+ }
948
+ const pending = this.createPendingRequest(message, eraSignal);
633
949
  if (!this.child && this.liveCapOccupancy() >= 2) {
634
950
  const { reply } = buildWorkerRestartReply(pending, { code: null, signal: null }, this.monotonicNow(), [], { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
635
- this.writeToClient(reply);
951
+ this.writeSyntheticReply(pending, reply);
636
952
  return;
637
953
  }
638
954
  const isValidate = pending.toolName === "validate-project";
639
955
  const dispatchImmediately = this.canDispatchImmediately(pending);
640
956
  if (!dispatchImmediately && this.queuedRequests.length >= MAX_SUPERVISOR_QUEUE) {
641
- this.writeToClient(buildSupervisorQueueLimitReply(pending.id, pending.method ?? message.method));
957
+ this.writeSyntheticReply(pending, buildSupervisorQueueLimitReply(pending.id, pending.method ?? message.method));
642
958
  this.drainQueue();
643
959
  return;
644
960
  }
961
+ if (this.era === "legacy" &&
962
+ message.method === "tools/call" &&
963
+ isToolArgumentsRecord(message.params) &&
964
+ Object.prototype.hasOwnProperty.call(message.params, "arguments")) {
965
+ const argumentsValue = message.params.arguments;
966
+ if (argumentsValue !== undefined && !isToolArgumentsRecord(argumentsValue)) {
967
+ this.writeSyntheticReply(pending, buildLegacyMalformedToolArgumentsReply(pending.id, argumentsValue));
968
+ return;
969
+ }
970
+ }
971
+ if (this.era === "legacy" &&
972
+ message.method === "tools/call" &&
973
+ typeof pending.toolName === "string" &&
974
+ registeredToolCount() > 0 &&
975
+ getToolSchema(pending.toolName) === undefined) {
976
+ // LEGACY-era registry miss (typo or flag-disabled tool): answer the
977
+ // frozen premigration isError envelope. The registry is authoritative
978
+ // in production (src/cli.ts imports ./index.js before the supervisor
979
+ // starts, under the same env flags the worker inherits); the
980
+ // populated-registry gate keeps white-box suites that construct the
981
+ // supervisor without the app import on today's forwarding behavior.
982
+ // Semantics: DEGRADED states answer first — the cap-blocked restart
983
+ // and queue-overflow branches above take precedence exactly as before
984
+ // the intercept existed — while in the normal state the miss is
985
+ // answered immediately, pre-queue: it never enters pendingRequests or
986
+ // queuedRequests, so writeSyntheticReply's forwarded-only entitlement
987
+ // records no finality tombstone, and — unlike v1, which forwarded the
988
+ // call — it consumes no queue slot and skips the worker round-trip
989
+ // (identical per-request reply bytes, different queue occupancy and
990
+ // latency). Modern-era misses pass through to the worker's raw -32602
991
+ // (sanctioned modern contract); the unselected state never reaches
992
+ // here (rule-5 rejection in admitEraRequest).
993
+ this.eventWriter("info", "supervisor.unknown_tool_intercepted", {
994
+ id: pending.id,
995
+ toolName: pending.toolName
996
+ });
997
+ this.writeSyntheticReply(pending, buildUnknownToolNotFoundReply(pending.id, pending.toolName));
998
+ return;
999
+ }
645
1000
  if (isValidate) {
646
1001
  pending.timeoutPhase = "queue";
647
1002
  const elapsedAtAdmission = Math.max(0, this.monotonicNow() - pending.startedAt);
@@ -657,12 +1012,219 @@ export class StdioSupervisor {
657
1012
  }
658
1013
  this.queuedRequests.push({ message, pending });
659
1014
  }
660
- createPendingRequest(message) {
1015
+ /**
1016
+ * Era-aware notification dispatch. notifications/cancelled stays
1017
+ * supervisor-side in ALL eras/states; notifications/initialized is captured
1018
+ * and forwarded only within a legacy flow; every other notification is
1019
+ * gated by the era rules before reaching the original forwarding path.
1020
+ * Dropped variants emit NO response and never affect subsequent traffic.
1021
+ */
1022
+ handleClientNotification(message) {
1023
+ if (message.method === "notifications/cancelled") {
1024
+ this.handleCancellation(message);
1025
+ return;
1026
+ }
1027
+ if (message.method === "notifications/initialized") {
1028
+ if (this.era !== "legacy") {
1029
+ // A stray initialized outside a legacy flow is dropped and NEVER
1030
+ // captured: forwarding (or later replaying) it would pin a worker
1031
+ // connection legacy.
1032
+ this.eventWriter("warn", "supervisor.notification_dropped", {
1033
+ method: message.method,
1034
+ reason: this.era === "modern" ? "era-conflict" : "era-unselected"
1035
+ });
1036
+ return;
1037
+ }
1038
+ if (this.initializeRequest === undefined) {
1039
+ // Legacy era but NO handshake in progress (e.g. after a cap-rejected
1040
+ // initialize): capturing here could later replay the stray frame
1041
+ // around an uninitialized worker. Drop, never capture.
1042
+ this.eventWriter("warn", "supervisor.notification_dropped", {
1043
+ method: message.method,
1044
+ reason: "no-active-handshake"
1045
+ });
1046
+ return;
1047
+ }
1048
+ this.initializedNotification = message;
1049
+ }
1050
+ else if (this.era === "unselected") {
1051
+ if (this.childReady) {
1052
+ // Consumed at the supervisor: forwarding any notification before an
1053
+ // era is selected would pin the worker connection legacy. The
1054
+ // worker-unavailable branch below keeps its original drop reasons.
1055
+ this.eventWriter("warn", "supervisor.notification_dropped", {
1056
+ method: message.method,
1057
+ reason: "era-unselected"
1058
+ });
1059
+ return;
1060
+ }
1061
+ }
1062
+ else {
1063
+ const signal = classifyEraSignal(message.params);
1064
+ if (this.era === "legacy" && signal.classification === "modern-signal") {
1065
+ // Only shallow-VALID modern signals conflict with the legacy lock;
1066
+ // claim-less and claim-shaped-invalid notifications forward as today
1067
+ // (the legacy era stays maximally permissive).
1068
+ this.eventWriter("warn", "supervisor.notification_dropped", {
1069
+ method: message.method,
1070
+ reason: "era-conflict"
1071
+ });
1072
+ return;
1073
+ }
1074
+ if (this.era === "modern" && signal.classification !== "modern-signal") {
1075
+ this.eventWriter("warn", "supervisor.notification_dropped", {
1076
+ method: message.method,
1077
+ reason: "missing-meta"
1078
+ });
1079
+ return;
1080
+ }
1081
+ }
1082
+ if (!this.childReady) {
1083
+ if (!shouldRetainUnavailableNotification(message.method)) {
1084
+ this.eventWriter("warn", "supervisor.notification_dropped", {
1085
+ method: message.method,
1086
+ reason: this.liveCapOccupancy() >= 2 ? "live-cap-blocked" : "worker-unavailable"
1087
+ });
1088
+ }
1089
+ return;
1090
+ }
1091
+ if (this.child && !this.child.stdin.destroyed) {
1092
+ this.writeToWorker(this.child, message);
1093
+ }
1094
+ }
1095
+ /**
1096
+ * Era rules for non-initialize requests, applied at admission in stdin
1097
+ * order. Returns undefined when the request was terminally rejected here (a
1098
+ * supervisor-produced response was written; nothing may be forwarded or
1099
+ * queued). On admission it returns the admission-time era signal, which the
1100
+ * caller hands to createPendingRequest so snapshot capture uses the same
1101
+ * result as the gating decision (extractModernRequestContext re-checks the
1102
+ * same immutable params graph defensively — a pure re-evaluation, not a
1103
+ * second authority); the caller then proceeds through the original era-less
1104
+ * admission path unchanged.
1105
+ */
1106
+ admitEraRequest(message) {
1107
+ const signal = classifyEraSignal(message.params);
1108
+ const mode = this.modeForMessage(message);
1109
+ const id = message.id;
1110
+ if (message.method === "server/discover") {
1111
+ // server/discover is ERA-NEUTRAL: it forwards under the legacy lock
1112
+ // (the legacy-pinned worker answers -32601) and a modern-signal
1113
+ // discover forwards WITHOUT locking; only signal-less discovers in
1114
+ // non-legacy states are rejected.
1115
+ if (this.era === "legacy") {
1116
+ return signal;
1117
+ }
1118
+ if (signal.classification === "modern-signal") {
1119
+ if (this.rejectUnsupportedProtocolVersion(message, signal, mode)) {
1120
+ return undefined;
1121
+ }
1122
+ return signal;
1123
+ }
1124
+ this.writeToClient(buildMissingMetaRejection(id, signal, this.era === "modern" ? "modern" : "unselected"), mode);
1125
+ return undefined;
1126
+ }
1127
+ if (signal.classification === "modern-signal") {
1128
+ if (this.era === "legacy") {
1129
+ this.writeToClient(buildEraConflictRejection(id, "legacy"), mode);
1130
+ return undefined;
1131
+ }
1132
+ if (this.era === "unselected") {
1133
+ this.lockModernEra();
1134
+ }
1135
+ if (this.rejectUnsupportedProtocolVersion(message, signal, mode)) {
1136
+ return undefined;
1137
+ }
1138
+ return signal;
1139
+ }
1140
+ if (this.era === "legacy") {
1141
+ // Legacy-locked behavior is otherwise UNCHANGED: claim-less AND
1142
+ // claim-shaped-invalid traffic forwards exactly as before the era gate.
1143
+ return signal;
1144
+ }
1145
+ this.writeToClient(buildMissingMetaRejection(id, signal, this.era === "modern" ? "modern" : "unselected"), mode);
1146
+ return undefined;
1147
+ }
1148
+ /**
1149
+ * Deep validation of a shallow-valid modern claim's protocolVersion VALUE,
1150
+ * applied to EVERY modern request at admission. Returns whether the request
1151
+ * was terminally answered here.
1152
+ *
1153
+ * The version check used to be delegated entirely to the worker, and the SDK
1154
+ * only performs it while the connection is still OPENING: once an instance
1155
+ * is pinned, `processMessage` delivers straight to it without re-reading the
1156
+ * envelope. So the contract ("any unsupported version string answers -32022
1157
+ * with data.supported/data.requested") held for exactly one request per
1158
+ * process — and whether it held at all depended on which method the client
1159
+ * happened to send first, since the era-neutral `server/discover` does not
1160
+ * pin. Every later request with `"1999-12-31"` was SERVED, tool handlers
1161
+ * included.
1162
+ *
1163
+ * Ordering is deliberate and matches the SDK's own listen path: the envelope
1164
+ * shape is checked first (era gating above), the era lock is committed
1165
+ * BEFORE this check — an unsupported version still locks modern, exactly as
1166
+ * documented — and only then is the value judged, ahead of any method-level
1167
+ * rejection. The reply is built from the SDK's own
1168
+ * UnsupportedProtocolVersionError, so it stays byte-identical to the answer
1169
+ * the worker gives on the still-opening path.
1170
+ */
1171
+ rejectUnsupportedProtocolVersion(message, signal, mode) {
1172
+ if (signal.classification !== "modern-signal") {
1173
+ return false;
1174
+ }
1175
+ const meta = message.params._meta;
1176
+ const requested = meta[PROTOCOL_VERSION_META_KEY];
1177
+ if (requested === MODERN_PROTOCOL_VERSION) {
1178
+ return false;
1179
+ }
1180
+ this.eventWriter("warn", "supervisor.unsupported_protocol_version", {
1181
+ id: message.id,
1182
+ method: message.method,
1183
+ requested
1184
+ });
1185
+ this.writeToClient(buildUnsupportedProtocolVersionRejection(message.id, requested), mode);
1186
+ return true;
1187
+ }
1188
+ /**
1189
+ * One-way modern lock. Purges the cached legacy lifecycle state so no
1190
+ * initialize/notifications/initialized can ever reach a worker in a
1191
+ * modern-locked process — across ALL later worker generations.
1192
+ */
1193
+ lockModernEra() {
1194
+ this.era = "modern";
1195
+ this.initializeRequest = undefined;
1196
+ this.initializedNotification = undefined;
1197
+ this.initializePredecessorCount = 0;
1198
+ this.clientInitialized = false;
1199
+ this.replayingInitialization = false;
1200
+ this.initializeSentToWorker = false;
1201
+ }
1202
+ /**
1203
+ * `eraSignal` is the ADMISSION-time classification handed back by
1204
+ * admitEraRequest — the initialize/replay/flush call sites omit it (an
1205
+ * initialize snapshot is always era "legacy" with no modern context, even
1206
+ * when the initialize carries a _meta envelope). Context is captured only
1207
+ * for shallow-valid modern signals; this.era already reflects any lock
1208
+ * admitEraRequest applied, so it IS the era at admission on every path.
1209
+ */
1210
+ createPendingRequest(message, eraSignal) {
661
1211
  const pending = {
662
1212
  id: message.id,
663
1213
  method: message.method,
664
- startedAt: this.monotonicNow()
1214
+ startedAt: this.monotonicNow(),
1215
+ mode: this.modeForMessage(message),
1216
+ era: this.era
665
1217
  };
1218
+ if (eraSignal?.classification === "modern-signal") {
1219
+ const context = extractModernRequestContext(message.params);
1220
+ if (context) {
1221
+ pending.protocolVersion = context.protocolVersion;
1222
+ pending.clientCapabilities = context.clientCapabilities;
1223
+ if ("clientInfo" in context) {
1224
+ pending.clientInfo = context.clientInfo;
1225
+ }
1226
+ }
1227
+ }
666
1228
  if (message.method === "tools/call") {
667
1229
  const params = (message.params ?? {});
668
1230
  if (typeof params.name === "string")
@@ -694,11 +1256,15 @@ export class StdioSupervisor {
694
1256
  else {
695
1257
  if (pending.deadlineTimer)
696
1258
  this.timerClearer(pending.deadlineTimer);
697
- this.writeToClient(buildSupervisorQueueLimitReply(pending.id, pending.method ?? message.method));
1259
+ this.writeSyntheticReply(pending, buildSupervisorQueueLimitReply(pending.id, pending.method ?? message.method));
698
1260
  }
699
1261
  this.scheduleRestart();
700
1262
  return;
701
1263
  }
1264
+ // The id goes live on a worker: a finality tombstone from an EARLIER
1265
+ // request instance that reused this id no longer applies (a client may
1266
+ // legally retry with the same id after a synthetic terminal reply).
1267
+ this.syntheticTombstones.delete(requestKey(pending.id));
702
1268
  this.pendingRequests.set(requestKey(pending.id), pending);
703
1269
  if (pending.toolName === "validate-project") {
704
1270
  pending.timeoutPhase = "running";
@@ -725,6 +1291,9 @@ export class StdioSupervisor {
725
1291
  const key = requestKey(targetId);
726
1292
  const queuedIndex = this.queuedRequests.findIndex((entry) => requestKey(entry.pending.id) === key);
727
1293
  if (queuedIndex >= 0) {
1294
+ if (queuedIndex < this.initializePredecessorCount) {
1295
+ this.initializePredecessorCount -= 1;
1296
+ }
728
1297
  const [{ pending }] = this.queuedRequests.splice(queuedIndex, 1);
729
1298
  if (pending.deadlineTimer)
730
1299
  this.timerClearer(pending.deadlineTimer);
@@ -734,13 +1303,100 @@ export class StdioSupervisor {
734
1303
  return;
735
1304
  }
736
1305
  const pending = this.pendingRequests.get(key);
737
- if (pending?.toolName === "validate-project") {
738
- pending.clientCancelled = true;
1306
+ const released = pending !== undefined && this.releaseCancelledRequest(key, pending);
1307
+ if (this.shouldForwardCancellation(message, pending !== undefined)) {
1308
+ const child = this.child;
1309
+ if (child && !child.stdin.destroyed) {
1310
+ this.writeToWorker(child, message);
1311
+ }
739
1312
  }
740
- const child = this.child;
741
- if (child && !child.stdin.destroyed) {
742
- this.writeToWorker(child, message);
1313
+ if (released) {
1314
+ // The released entry may have been the last occupant of a dispatch
1315
+ // barrier, so queued work can move immediately.
1316
+ this.drainQueue();
1317
+ }
1318
+ }
1319
+ /**
1320
+ * Terminal release of a FORWARDED request the client cancelled.
1321
+ *
1322
+ * MCP cancellation semantics forbid a response for a cancelled id, so the
1323
+ * supervisor stops waiting on the worker the moment the cancellation is
1324
+ * admitted — and a worker is under no obligation to ever answer a cancelled
1325
+ * request. Keeping the entry in `pendingRequests` as a suppression marker
1326
+ * (the pre-repair behavior) therefore stranded EVERY dispatch barrier the
1327
+ * map gates — canDispatchImmediately's empty-map requirement, drainQueue's
1328
+ * validate release — for the life of the process whenever the worker never
1329
+ * answered, and let a client grow the map without bound by pairing fresh
1330
+ * ids with cancellations.
1331
+ *
1332
+ * Suppression is instead recorded as an ordinary response-finality
1333
+ * tombstone, which already carries exactly the needed semantics: a late
1334
+ * worker answer for the id is discarded with a logged event, and
1335
+ * re-forwarding the id (a legal retry) clears it. The result is one
1336
+ * mechanism for "this request instance is over" rather than two.
1337
+ *
1338
+ * Carve-out: an in-flight `initialize` is NOT released. Its pending entry is
1339
+ * owned by the legacy handshake lifecycle (replay correlation,
1340
+ * isInitializationResponse, the preserved-key rule in
1341
+ * failPendingRequestsOnWorkerExit), which has its own recovery paths
1342
+ * (startup watchdog, worker exit, clearInitialInitializationState) and never
1343
+ * consulted the cancellation marker anyway — so handshake behavior is
1344
+ * unchanged, byte for byte.
1345
+ *
1346
+ * Returns whether an entry was actually released.
1347
+ */
1348
+ releaseCancelledRequest(key, pending) {
1349
+ if (pending.method === "initialize") {
1350
+ return false;
1351
+ }
1352
+ if (pending.deadlineTimer)
1353
+ this.timerClearer(pending.deadlineTimer);
1354
+ pending.deadlineTimer = undefined;
1355
+ this.pendingRequests.delete(key);
1356
+ if (this.runningValidateKey === key)
1357
+ this.runningValidateKey = undefined;
1358
+ if (this.validateBarrierKey === key)
1359
+ this.validateBarrierKey = undefined;
1360
+ this.recordFinalityTombstone(key, pending.mode);
1361
+ this.eventWriter("info", "supervisor.request_cancelled", {
1362
+ id: pending.id,
1363
+ method: pending.method,
1364
+ toolName: pending.toolName
1365
+ });
1366
+ return true;
1367
+ }
1368
+ /**
1369
+ * Era gating for a cancellation notification. Extracted from
1370
+ * handleCancellation so supervisor-side bookkeeping (releasing the cancelled
1371
+ * request) stays independent of whether the frame may reach the worker — a
1372
+ * DROPPED cancellation still cancels, exactly as documented.
1373
+ */
1374
+ shouldForwardCancellation(message, hasPendingTarget) {
1375
+ const cancellationSignal = classifyEraSignal(message.params).classification;
1376
+ if (this.era === "unselected") {
1377
+ if (!hasPendingTarget || cancellationSignal === "claim-shaped-invalid") {
1378
+ // Unknown cancellations could be the worker connection's first frame;
1379
+ // claim-shaped-invalid notifications are not protocol-valid. Drop both.
1380
+ // A known active claim-less target is different: that pending request
1381
+ // proves this current worker already received a modern discover.
1382
+ return false;
1383
+ }
1384
+ }
1385
+ if (this.era === "modern") {
1386
+ const activeClaimlessCancellation = cancellationSignal === "claim-less" && hasPendingTarget;
1387
+ if (cancellationSignal !== "modern-signal" && !activeClaimlessCancellation) {
1388
+ // A claim-less cancellation is safe only while its target is pending:
1389
+ // that proves this current worker already received a modern request.
1390
+ // Unknown claim-less and claim-shaped-invalid cancellations remain
1391
+ // dropped so they can never legacy-pin a fresh worker generation.
1392
+ this.eventWriter("warn", "supervisor.notification_dropped", {
1393
+ method: message.method,
1394
+ reason: "missing-meta"
1395
+ });
1396
+ return false;
1397
+ }
743
1398
  }
1399
+ return true;
744
1400
  }
745
1401
  handleValidateProjectDeadline(key) {
746
1402
  const queuedIndex = this.queuedRequests.findIndex((entry) => requestKey(entry.pending.id) === key);
@@ -751,34 +1407,35 @@ export class StdioSupervisor {
751
1407
  pending.deadlineTimer = undefined;
752
1408
  if (this.validateBarrierKey === key)
753
1409
  this.validateBarrierKey = undefined;
754
- if (!pending.clientCancelled) {
755
- this.writeToClient(buildValidateProjectTimeoutReply({
756
- request: pending,
757
- phase: "queue",
758
- deadlineMs: this.validateProjectTimeoutMs,
759
- now: this.monotonicNow(),
760
- workerRestartInitiated: false
761
- }));
762
- }
1410
+ // A cancelled queued entry was spliced out of queuedRequests (and its
1411
+ // timer cleared) at cancellation, so anything still here is live.
1412
+ this.writeSyntheticReply(pending, buildValidateProjectTimeoutReply({
1413
+ request: pending,
1414
+ phase: "queue",
1415
+ deadlineMs: this.validateProjectTimeoutMs,
1416
+ now: this.monotonicNow(),
1417
+ workerRestartInitiated: false
1418
+ }));
763
1419
  this.drainQueue();
764
1420
  return;
765
1421
  }
766
1422
  const pending = this.pendingRequests.get(key);
767
1423
  if (!pending || pending.toolName !== "validate-project")
768
1424
  return;
769
- this.pendingRequests.delete(key);
770
1425
  pending.deadlineTimer = undefined;
771
1426
  this.runningValidateKey = undefined;
772
1427
  this.validateBarrierKey = undefined;
773
- if (!pending.clientCancelled) {
774
- this.writeToClient(buildValidateProjectTimeoutReply({
775
- request: pending,
776
- phase: "running",
777
- deadlineMs: this.validateProjectTimeoutMs,
778
- now: this.monotonicNow(),
779
- workerRestartInitiated: true
780
- }));
781
- }
1428
+ // The entry stays in pendingRequests until writeSyntheticReply settles it:
1429
+ // the FORWARDED pending is what entitles the id to a finality tombstone.
1430
+ // A cancelled request never reaches here: releaseCancelledRequest already
1431
+ // settled it and cleared this timer.
1432
+ this.writeSyntheticReply(pending, buildValidateProjectTimeoutReply({
1433
+ request: pending,
1434
+ phase: "running",
1435
+ deadlineMs: this.validateProjectTimeoutMs,
1436
+ now: this.monotonicNow(),
1437
+ workerRestartInitiated: true
1438
+ }));
782
1439
  this.recoverTimedOutWorker();
783
1440
  }
784
1441
  drainQueue() {
@@ -832,7 +1489,12 @@ export class StdioSupervisor {
832
1489
  this.child = child;
833
1490
  this.liveChildren.add(child);
834
1491
  this.workerReaders.set(child, new JsonRpcFrameReader());
1492
+ const generation = ++this.workerGeneration;
1493
+ // "close" fires only once the process has ended AND its stdio streams are
1494
+ // closed — the tombstone retention bound (see syntheticTombstones).
1495
+ child.once("close", () => this.purgeTombstonesThroughGeneration(generation));
835
1496
  this.childReady = false;
1497
+ this.childReadyAt = undefined;
836
1498
  this.initializeSentToWorker = false;
837
1499
  this.workerStderrBuffer = "";
838
1500
  this.clearStartupWatchdog();
@@ -866,6 +1528,17 @@ export class StdioSupervisor {
866
1528
  this.handleWorkerMessage(child, message);
867
1529
  },
868
1530
  onError: (error) => {
1531
+ if (isJsonRpcFramingFatalError(error)) {
1532
+ // The worker's own output stream desynchronized: its reader is dead,
1533
+ // so this generation can never be read again. Recover the way any
1534
+ // other unusable worker is recovered — terminate it and restart.
1535
+ this.eventWriter("error", "supervisor.worker_framing_fatal", {
1536
+ pid: child.pid,
1537
+ message: error.message
1538
+ });
1539
+ this.handleWorkerProcessError(child, error);
1540
+ return;
1541
+ }
869
1542
  log("warn", "supervisor.worker_parse_error", { message: error.message });
870
1543
  }
871
1544
  });
@@ -904,6 +1577,9 @@ export class StdioSupervisor {
904
1577
  this.handleStartupFailure(this.attemptToken, { code: null, signal: null });
905
1578
  }
906
1579
  else {
1580
+ // A stream/process fault is never a clean stand-down, so it breaks any
1581
+ // run of them.
1582
+ this.consecutiveImmediateStandDowns = 0;
907
1583
  this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
908
1584
  this.scheduleRestart(true);
909
1585
  }
@@ -920,6 +1596,7 @@ export class StdioSupervisor {
920
1596
  this.staleChildren.delete(child);
921
1597
  if (cleanup.status === "pending" || cleanup.status === "unresolved") {
922
1598
  this.unresolvedTreeTokens.add(cleanup.pid);
1599
+ this.scheduleCleanupTokenRetry();
923
1600
  }
924
1601
  else {
925
1602
  this.cleanupStates.delete(child);
@@ -938,6 +1615,7 @@ export class StdioSupervisor {
938
1615
  }
939
1616
  const childPid = this.child?.pid;
940
1617
  const wasReady = this.childReady;
1618
+ const readyAt = this.childReadyAt;
941
1619
  this.detachCurrentChild();
942
1620
  if (this.shuttingDown) {
943
1621
  return;
@@ -952,9 +1630,40 @@ export class StdioSupervisor {
952
1630
  this.handleStartupFailure(this.attemptToken, { code, signal });
953
1631
  return;
954
1632
  }
1633
+ this.accountForReadyGenerationExit(childPid, readyAt, code, signal);
955
1634
  this.failPendingRequestsOnWorkerExit({ code, signal });
956
1635
  this.scheduleRestart(true);
957
1636
  }
1637
+ /**
1638
+ * Decide whether the generation that just ended counts as a start that
1639
+ * succeeded.
1640
+ *
1641
+ * A worker stands down on stdin EOF, and it replays an EOF that arrived
1642
+ * while it was still starting. On a host that closes a spawned child's stdin
1643
+ * immediately, every generation therefore signals ready, is adopted — which
1644
+ * clears the restart backoff — and exits 0 in the same breath. Left at the
1645
+ * 100 ms floor that is an unbounded hot respawn loop, so a clean stand-down
1646
+ * inside the healthy window re-escalates the backoff the adoption cleared.
1647
+ *
1648
+ * Only a CLEAN exit qualifies: a non-zero code or a signal is a crash, which
1649
+ * may be transient and keeps its prompt replacement.
1650
+ */
1651
+ accountForReadyGenerationExit(pid, readyAt, code, signal) {
1652
+ const readyMs = readyAt === undefined ? 0 : this.monotonicNow() - readyAt;
1653
+ const stoodDownImmediately = code === 0 && signal === null && readyMs < MIN_HEALTHY_WORKER_READY_MS;
1654
+ if (!stoodDownImmediately) {
1655
+ this.consecutiveImmediateStandDowns = 0;
1656
+ return;
1657
+ }
1658
+ this.consecutiveImmediateStandDowns += 1;
1659
+ this.restartBackoff.escalateTo(this.consecutiveImmediateStandDowns);
1660
+ this.eventWriter("warn", "supervisor.worker_stood_down_immediately", {
1661
+ pid,
1662
+ readyMs,
1663
+ consecutive: this.consecutiveImmediateStandDowns,
1664
+ healthyReadyMs: MIN_HEALTHY_WORKER_READY_MS
1665
+ });
1666
+ }
958
1667
  handleWorkerMessage(child, message) {
959
1668
  if (child !== this.child)
960
1669
  return;
@@ -972,12 +1681,16 @@ export class StdioSupervisor {
972
1681
  }
973
1682
  if (this.isInitializationResponse(message)) {
974
1683
  const id = getTrackedRequestId(message);
1684
+ let initializeMode;
975
1685
  if (id !== undefined) {
976
- this.pendingRequests.delete(requestKey(id));
1686
+ const key = requestKey(id);
1687
+ initializeMode = this.pendingRequests.get(key)?.mode;
1688
+ this.pendingRequests.delete(key);
977
1689
  }
1690
+ initializeMode ??= this.modeForMessage(this.initializeRequest);
978
1691
  if ("error" in message) {
979
1692
  if (!this.replayingInitialization && id !== undefined) {
980
- this.writeToClient(buildLegacyJsonRpcError(id));
1693
+ this.writeSyntheticReply({ id, era: this.era, mode: initializeMode }, buildLegacyJsonRpcError(id));
981
1694
  const retainedIndex = this.queuedNotifications.findIndex((entry) => isRequest(entry) && requestKey(entry.id) === requestKey(id));
982
1695
  if (retainedIndex >= 0)
983
1696
  this.queuedNotifications.splice(retainedIndex, 1);
@@ -1001,15 +1714,32 @@ export class StdioSupervisor {
1001
1714
  }
1002
1715
  this.clientInitialized = true;
1003
1716
  this.adoptActiveChild();
1004
- this.writeToClient(message);
1717
+ this.writeToClient(message, initializeMode);
1005
1718
  this.flushQueue();
1006
1719
  return;
1007
1720
  }
1721
+ // Worker messages that correlate to no tracked request (server-originated
1722
+ // notifications/requests, untracked-id responses with no tombstone) fall
1723
+ // back to the last-detected inbound mode in writeToClient. A response for
1724
+ // a TOMBSTONED id is different: the supervisor already synthesized the
1725
+ // terminal response for that request instance, so the late answer is
1726
+ // discarded — exactly one response per id.
1727
+ let responseMode;
1008
1728
  if (isResponse(message)) {
1009
1729
  const id = getTrackedRequestId(message);
1010
1730
  if (id !== undefined) {
1011
1731
  const key = requestKey(id);
1732
+ const tombstone = this.syntheticTombstones.get(key);
1733
+ if (tombstone) {
1734
+ this.eventWriter("warn", "supervisor.late_response_discarded", {
1735
+ id,
1736
+ tombstoneGeneration: tombstone.generation,
1737
+ currentGeneration: this.workerGeneration
1738
+ });
1739
+ return;
1740
+ }
1012
1741
  const pending = this.pendingRequests.get(key);
1742
+ responseMode = pending?.mode;
1013
1743
  this.pendingRequests.delete(key);
1014
1744
  if (pending?.deadlineTimer)
1015
1745
  this.timerClearer(pending.deadlineTimer);
@@ -1017,14 +1747,10 @@ export class StdioSupervisor {
1017
1747
  this.runningValidateKey = undefined;
1018
1748
  if (this.validateBarrierKey === key)
1019
1749
  this.validateBarrierKey = undefined;
1020
- if (pending.clientCancelled) {
1021
- this.drainQueue();
1022
- return;
1023
- }
1024
1750
  }
1025
1751
  }
1026
1752
  }
1027
- this.writeToClient(message);
1753
+ this.writeToClient(message, responseMode);
1028
1754
  this.drainQueue();
1029
1755
  }
1030
1756
  applyStageUpdate(params) {
@@ -1061,11 +1787,20 @@ export class StdioSupervisor {
1061
1787
  hasInitializeRequest: this.initializeRequest !== undefined,
1062
1788
  clientInitialized: this.clientInitialized
1063
1789
  });
1064
- if (!this.initializeRequest) {
1790
+ // Era-gated replay: initialize replay fires ONLY in the legacy era. The
1791
+ // modern lock purges the cached lifecycle, so the era check is
1792
+ // belt-and-braces — no initialize may ever reach a modern-locked worker.
1793
+ if (this.era !== "legacy" || !this.initializeRequest) {
1065
1794
  this.adoptActiveChild();
1066
1795
  this.flushQueue();
1067
1796
  return;
1068
1797
  }
1798
+ while (this.initializePredecessorCount > 0 && this.queuedRequests.length > 0) {
1799
+ const predecessor = this.queuedRequests.shift();
1800
+ this.initializePredecessorCount -= 1;
1801
+ this.forwardRequest(predecessor.message, predecessor.pending);
1802
+ }
1803
+ this.initializePredecessorCount = 0;
1069
1804
  this.replayingInitialization = this.clientInitialized;
1070
1805
  this.forwardRequest(this.initializeRequest, this.createPendingRequest(this.initializeRequest));
1071
1806
  }
@@ -1125,34 +1860,121 @@ export class StdioSupervisor {
1125
1860
  for (const [key, pending] of [...this.pendingRequests.entries()]) {
1126
1861
  if (key === preservedInitializeKey)
1127
1862
  continue;
1128
- this.pendingRequests.delete(key);
1129
1863
  if (pending.deadlineTimer)
1130
1864
  this.timerClearer(pending.deadlineTimer);
1131
1865
  if (this.runningValidateKey === key)
1132
1866
  this.runningValidateKey = undefined;
1133
1867
  if (this.validateBarrierKey === key)
1134
1868
  this.validateBarrierKey = undefined;
1135
- if (pending.clientCancelled)
1136
- continue;
1869
+ // Forwarded entries stay in pendingRequests until writeSyntheticReply
1870
+ // settles them (the FORWARDED pending is what entitles the id to a
1871
+ // finality tombstone). Cancelled entries are already gone — the
1872
+ // cancellation settled them terminally at admission.
1137
1873
  const toolName = pending.toolName ?? "unknown";
1138
1874
  const pruned = prunedByTool.get(toolName) ?? [];
1139
1875
  const { reply } = buildWorkerRestartReply(pending, exit, now, pruned, { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
1140
- this.writeToClient(reply);
1876
+ this.writeSyntheticReply(pending, reply);
1877
+ }
1878
+ }
1879
+ /**
1880
+ * The single terminal-synthesis writer: EVERY supervisor-synthesized
1881
+ * response (queue-limit, validate-project timeout, worker-restart in all
1882
+ * variants incl. the structured-restart toggle, startup-failure
1883
+ * terminalization, initialize-failure -32603) routes through here.
1884
+ * Responsibilities, in order:
1885
+ * 1. settle the pending entry,
1886
+ * 2. record the finality tombstone (id + originating worker generation +
1887
+ * framing mode) so a late worker response for the id is discarded —
1888
+ * ONLY when the id was actually FORWARDED to a worker (i.e. the entry
1889
+ * in pendingRequests at the id IS the snapshot instance being
1890
+ * answered; an id-colliding DIFFERENT live entry is left untouched).
1891
+ * Never-forwarded
1892
+ * rejections (queue-limit at admission and at the forward fallback,
1893
+ * queue-phase validate timeout, cap-blocked replies, startup-failure
1894
+ * terminalization of queued work, the retained-initialize error) record
1895
+ * NO tombstone: no worker ever saw those ids, so no worker can answer
1896
+ * them, and tombstoning them would let a client grow the map without
1897
+ * bound inside a healthy generation,
1898
+ * 3. apply modern-era decoration — `snapshot.era` is the ONLY
1899
+ * captured-context field read; raw error envelopes and non-modern eras
1900
+ * pass through byte-identical — and
1901
+ * 4. write in the ORIGINATING request's framing.
1902
+ * Admission-time era rejections (era_conflict/missing_meta/-32601) do NOT
1903
+ * route through here: they reject before any forwarding could happen, so
1904
+ * no worker can ever answer those ids. The initialize-error synthesis also
1905
+ * records no tombstone: isInitializationResponse settles its pending entry
1906
+ * before synthesizing, the answering generation is terminated, and the
1907
+ * replay cache is cleared — no late-response channel remains.
1908
+ */
1909
+ writeSyntheticReply(snapshot, reply) {
1910
+ const key = requestKey(snapshot.id);
1911
+ const pending = this.pendingRequests.get(key);
1912
+ // Settle ONLY the exact forwarded instance this synthesis answers. A
1913
+ // never-forwarded snapshot (queue-limit, cap-blocked, intercept, queued
1914
+ // terminalization, ad-hoc literals) whose id collides with a DIFFERENT
1915
+ // live forwarded request must not delete or tombstone that live entry:
1916
+ // the tombstone would discard the worker's real answer and strand the
1917
+ // validate barrier (id reuse is a client-side JSON-RPC violation, but
1918
+ // the live request keeps its exactly-one-response guarantee).
1919
+ if (pending && pending === snapshot) {
1920
+ if (pending.deadlineTimer)
1921
+ this.timerClearer(pending.deadlineTimer);
1922
+ this.pendingRequests.delete(key);
1923
+ this.recordFinalityTombstone(key, snapshot.mode);
1924
+ }
1925
+ this.writeToClient(decorateSyntheticReply(reply, snapshot.era), snapshot.mode);
1926
+ }
1927
+ /**
1928
+ * Records a response-finality tombstone for a request instance the
1929
+ * supervisor has terminally settled (synthesized reply or client
1930
+ * cancellation). Insertion-order eviction at MAX_SYNTHETIC_TOMBSTONES keeps
1931
+ * the map bounded even inside one healthy worker generation, where the
1932
+ * per-generation purge cannot fire.
1933
+ */
1934
+ recordFinalityTombstone(key, mode) {
1935
+ // Re-set moves an existing key to the end of the insertion order, so the
1936
+ // eviction victim is always the least recently recorded tombstone.
1937
+ this.syntheticTombstones.delete(key);
1938
+ this.syntheticTombstones.set(key, { generation: this.workerGeneration, mode });
1939
+ while (this.syntheticTombstones.size > MAX_SYNTHETIC_TOMBSTONES) {
1940
+ const oldest = this.syntheticTombstones.keys().next();
1941
+ if (oldest.done)
1942
+ break;
1943
+ this.syntheticTombstones.delete(oldest.value);
1944
+ debugSupervisor("tombstone_evicted", { key: oldest.value, cap: MAX_SYNTHETIC_TOMBSTONES });
1945
+ }
1946
+ }
1947
+ /**
1948
+ * Retention bound for finality tombstones: generation `generation` is gone
1949
+ * with streams closed, so every tombstone recorded at or before it is
1950
+ * unreachable (see syntheticTombstones doc) and may be dropped.
1951
+ */
1952
+ purgeTombstonesThroughGeneration(generation) {
1953
+ for (const [key, tombstone] of this.syntheticTombstones) {
1954
+ if (tombstone.generation <= generation) {
1955
+ this.syntheticTombstones.delete(key);
1956
+ }
1141
1957
  }
1142
1958
  }
1143
- writeToClient(message) {
1959
+ /**
1960
+ * Writes one client-bound message. `mode` is the ORIGINATING REQUEST's
1961
+ * captured framing; callers omit it only for writes that correlate to no
1962
+ * request id, which fall back to the last-detected inbound mode.
1963
+ */
1964
+ writeToClient(message, mode) {
1965
+ const effectiveMode = mode ?? this.lastInboundClientMode;
1144
1966
  debugSupervisor("write_to_client", {
1145
1967
  hasMethod: "method" in message,
1146
1968
  method: "method" in message ? message.method : undefined,
1147
1969
  id: "id" in message ? message.id : undefined,
1148
- clientMode: this.clientMode
1970
+ clientMode: effectiveMode
1149
1971
  });
1150
1972
  try {
1151
1973
  if (this.clientWriter) {
1152
1974
  this.clientWriter(message);
1153
1975
  return;
1154
1976
  }
1155
- const frame = encodeJsonRpcMessage(message, this.clientMode);
1977
+ const frame = encodeJsonRpcMessage(message, effectiveMode);
1156
1978
  process.stdout.write(frame);
1157
1979
  }
1158
1980
  catch (error) {
@@ -1164,14 +1986,33 @@ export class StdioSupervisor {
1164
1986
  clearInitialInitializationState() {
1165
1987
  if (this.clientInitialized)
1166
1988
  return;
1989
+ if (this.initializeRequest) {
1990
+ // A preserved not-yet-answered initialize map entry (kept across worker
1991
+ // exit for replay correlation) is orphaned once the cached handshake is
1992
+ // discarded: no replay can ever answer it, and leaving it would block
1993
+ // validate-project dispatch forever and resurface at the next worker
1994
+ // exit as a duplicate reply. Drop it without a tombstone — its
1995
+ // generation is gone, so no late answer can arrive.
1996
+ const staleKey = requestKey(this.initializeRequest.id);
1997
+ const stale = this.pendingRequests.get(staleKey);
1998
+ if (stale?.method === "initialize") {
1999
+ if (stale.deadlineTimer)
2000
+ this.timerClearer(stale.deadlineTimer);
2001
+ this.pendingRequests.delete(staleKey);
2002
+ }
2003
+ }
1167
2004
  this.initializeRequest = undefined;
1168
2005
  this.initializedNotification = undefined;
2006
+ this.initializePredecessorCount = 0;
1169
2007
  this.replayingInitialization = false;
1170
2008
  this.initializeSentToWorker = false;
1171
2009
  }
1172
2010
  liveCapOccupancy() {
1173
2011
  return this.liveChildren.size + this.unresolvedTreeTokens.size;
1174
2012
  }
2013
+ get unresolvedTreeTokenCount() {
2014
+ return this.unresolvedTreeTokens.size;
2015
+ }
1175
2016
  clearStartupWatchdog() {
1176
2017
  if (this.startupWatchdog) {
1177
2018
  this.timerClearer(this.startupWatchdog);
@@ -1181,6 +2022,7 @@ export class StdioSupervisor {
1181
2022
  adoptActiveChild() {
1182
2023
  this.clearStartupWatchdog();
1183
2024
  this.childReady = true;
2025
+ this.childReadyAt ??= this.monotonicNow();
1184
2026
  this.restartBackoff.reset();
1185
2027
  this.currentRetryEpoch = undefined;
1186
2028
  this.currentRetryReservation = undefined;
@@ -1192,6 +2034,7 @@ export class StdioSupervisor {
1192
2034
  this.clearStartupWatchdog();
1193
2035
  this.child = undefined;
1194
2036
  this.childReady = false;
2037
+ this.childReadyAt = undefined;
1195
2038
  this.replayingInitialization = false;
1196
2039
  this.initializeSentToWorker = false;
1197
2040
  this.staleChildren.add(child);
@@ -1205,6 +2048,7 @@ export class StdioSupervisor {
1205
2048
  this.clearStartupWatchdog();
1206
2049
  this.child = undefined;
1207
2050
  this.childReady = false;
2051
+ this.childReadyAt = undefined;
1208
2052
  this.replayingInitialization = false;
1209
2053
  this.initializeSentToWorker = false;
1210
2054
  if (child) {
@@ -1328,15 +2172,64 @@ export class StdioSupervisor {
1328
2172
  return;
1329
2173
  if (success) {
1330
2174
  this.unresolvedTreeTokens.delete(cleanup.pid);
2175
+ this.cleanupRetryAttempts.delete(cleanup.pid);
1331
2176
  this.cleanupStates.delete(child);
2177
+ this.stopCleanupTokenRetryIfIdle();
1332
2178
  }
1333
2179
  else {
1334
2180
  this.unresolvedTreeTokens.add(cleanup.pid);
2181
+ this.scheduleCleanupTokenRetry();
1335
2182
  }
1336
2183
  this.resumePausedRestart();
1337
2184
  this.drainQueue();
1338
2185
  }
1339
- async retryUnresolvedTreeToken(pid) {
2186
+ scheduleCleanupTokenRetry() {
2187
+ if (this.shuttingDown || this.cleanupRetryTimer || this.unresolvedTreeTokens.size === 0)
2188
+ return;
2189
+ const delayMs = Math.min(this.cleanupTokenRetryBaseMs * (2 ** this.cleanupRetryIndex), this.cleanupTokenRetryCapMs);
2190
+ this.cleanupRetryIndex += 1;
2191
+ this.cleanupRetryTimer = this.timerScheduler(() => {
2192
+ this.cleanupRetryTimer = undefined;
2193
+ void this.runCleanupTokenRetries();
2194
+ }, delayMs);
2195
+ this.cleanupRetryTimer.unref();
2196
+ }
2197
+ async runCleanupTokenRetries() {
2198
+ await Promise.all([...this.unresolvedTreeTokens].map((pid) => this.retryUnresolvedTreeToken(pid)));
2199
+ if (this.unresolvedTreeTokens.size === 0) {
2200
+ this.cleanupRetryIndex = 0;
2201
+ return;
2202
+ }
2203
+ this.scheduleCleanupTokenRetry();
2204
+ }
2205
+ stopCleanupTokenRetryIfIdle() {
2206
+ if (this.unresolvedTreeTokens.size > 0)
2207
+ return;
2208
+ if (this.cleanupRetryTimer) {
2209
+ this.timerClearer(this.cleanupRetryTimer);
2210
+ this.cleanupRetryTimer = undefined;
2211
+ }
2212
+ this.cleanupRetryIndex = 0;
2213
+ this.cleanupRetryAttempts.clear();
2214
+ }
2215
+ retryUnresolvedTreeToken(pid) {
2216
+ const existing = this.cleanupRetriesInFlight.get(pid);
2217
+ if (existing)
2218
+ return existing;
2219
+ const retry = this.performUnresolvedTreeTokenRetry(pid);
2220
+ this.cleanupRetriesInFlight.set(pid, retry);
2221
+ return retry.finally(() => {
2222
+ if (this.cleanupRetriesInFlight.get(pid) === retry) {
2223
+ this.cleanupRetriesInFlight.delete(pid);
2224
+ }
2225
+ });
2226
+ }
2227
+ async performUnresolvedTreeTokenRetry(pid) {
2228
+ if (!this.unresolvedTreeTokens.has(pid))
2229
+ return;
2230
+ const attempt = (this.cleanupRetryAttempts.get(pid) ?? 0) + 1;
2231
+ this.cleanupRetryAttempts.set(pid, attempt);
2232
+ this.eventWriter("warn", "supervisor.cleanup_token.retry", { pid, attempt });
1340
2233
  let success = false;
1341
2234
  if (this.treeTokenRetrier) {
1342
2235
  success = await settleTreeCleanupWithin(Promise.resolve().then(() => this.treeTokenRetrier(pid)), this.treeCleanupTimeoutMs);
@@ -1374,14 +2267,23 @@ export class StdioSupervisor {
1374
2267
  catch { /* best effort */ }
1375
2268
  });
1376
2269
  }
1377
- if (!success)
2270
+ if (!success || !this.unresolvedTreeTokens.has(pid))
1378
2271
  return;
1379
2272
  this.unresolvedTreeTokens.delete(pid);
2273
+ this.cleanupRetryAttempts.delete(pid);
1380
2274
  for (const [child, cleanup] of this.cleanupStates) {
1381
2275
  if (cleanup.pid === pid && cleanup.parentExited) {
1382
2276
  this.cleanupStates.delete(child);
1383
2277
  }
1384
2278
  }
2279
+ this.eventWriter("info", "supervisor.cleanup_token.recovered", {
2280
+ pid,
2281
+ attempt,
2282
+ unresolvedTreeTokens: this.unresolvedTreeTokens.size
2283
+ });
2284
+ this.stopCleanupTokenRetryIfIdle();
2285
+ this.resumePausedRestart();
2286
+ this.drainQueue();
1385
2287
  }
1386
2288
  handleStartupFailure(token, exit) {
1387
2289
  if (token !== this.attemptToken && this.child !== undefined)
@@ -1399,12 +2301,16 @@ export class StdioSupervisor {
1399
2301
  const { reply } = buildWorkerRestartReply(pending, exit, now, [], {
1400
2302
  structuredRestartDisabled: STRUCTURED_RESTART_DISABLED
1401
2303
  });
1402
- this.writeToClient(reply);
2304
+ this.writeSyntheticReply(pending, reply);
1403
2305
  }
1404
2306
  const retainedInitialize = this.queuedNotifications.find((message) => isRequest(message) && message.method === "initialize");
1405
2307
  this.queuedNotifications.splice(0, this.queuedNotifications.length);
1406
2308
  if (retainedInitialize && isRequest(retainedInitialize)) {
1407
- this.writeToClient(buildLegacyJsonRpcError(retainedInitialize.id));
2309
+ this.writeSyntheticReply({
2310
+ id: retainedInitialize.id,
2311
+ era: this.era,
2312
+ mode: this.modeForMessage(retainedInitialize)
2313
+ }, buildLegacyJsonRpcError(retainedInitialize.id));
1408
2314
  }
1409
2315
  this.clearInitialInitializationState();
1410
2316
  this.validateBarrierKey = undefined;
@@ -1419,6 +2325,13 @@ export class StdioSupervisor {
1419
2325
  this.currentRetryEpoch = reservation.epoch;
1420
2326
  if (this.liveCapOccupancy() >= 2) {
1421
2327
  this.retryPaused = true;
2328
+ this.eventWriter("warn", "supervisor.live_cap.saturated", {
2329
+ occupancy: this.liveCapOccupancy(),
2330
+ cap: 2,
2331
+ liveChildren: this.liveChildren.size,
2332
+ unresolvedTreeTokens: this.unresolvedTreeTokens.size,
2333
+ reason: "restart-blocked"
2334
+ });
1422
2335
  return;
1423
2336
  }
1424
2337
  this.armRestartReservation(reservation);
@@ -1458,11 +2371,25 @@ export class StdioSupervisor {
1458
2371
  return;
1459
2372
  this.armRestartReservation(reservation);
1460
2373
  }
1461
- async shutdown() {
2374
+ /**
2375
+ * Idempotent teardown. Every caller receives the SAME promise, so a caller
2376
+ * that needs to know when cleanup is really finished — the fatal handler,
2377
+ * which then ends the process — cannot be handed an already-resolved promise
2378
+ * while another teardown is still collecting the worker's process group.
2379
+ */
2380
+ shutdown() {
2381
+ this.shutdownRun ??= this.runShutdown();
2382
+ return this.shutdownRun;
2383
+ }
2384
+ async runShutdown() {
1462
2385
  if (this.shuttingDown) {
1463
2386
  return;
1464
2387
  }
1465
2388
  this.shuttingDown = true;
2389
+ if (this.cleanupRetryTimer) {
2390
+ this.timerClearer(this.cleanupRetryTimer);
2391
+ this.cleanupRetryTimer = undefined;
2392
+ }
1466
2393
  if (this.restartTimer) {
1467
2394
  this.timerClearer(this.restartTimer);
1468
2395
  this.restartTimer = undefined;
@@ -1481,17 +2408,41 @@ export class StdioSupervisor {
1481
2408
  }
1482
2409
  this.pendingRequests.clear();
1483
2410
  this.queuedRequests.splice(0, this.queuedRequests.length);
2411
+ this.syntheticTombstones.clear();
1484
2412
  process.stdin.off("data", this.handleClientData);
1485
2413
  process.stdin.off("error", this.handleClientError);
1486
2414
  process.stdin.off("end", this.handleClientClosed);
1487
2415
  process.stdin.off("close", this.handleClientClosed);
2416
+ // Symmetric with start()'s resume(). Without this a shutdown triggered
2417
+ // while stdin is still OPEN (a framing-fatal teardown, a signal) leaves the
2418
+ // stdin handle referenced and the process alive with nothing left to serve;
2419
+ // the ordinary shutdown path never noticed because stdin had already ended.
2420
+ // pause() stops the flow, unref() releases the event-loop reference (absent
2421
+ // on a file-backed stdin, hence the guard).
2422
+ process.stdin.pause();
2423
+ const unrefStdin = process.stdin.unref;
2424
+ if (typeof unrefStdin === "function") {
2425
+ unrefStdin.call(process.stdin);
2426
+ }
1488
2427
  process.off("SIGINT", this.handleTerminateSignal);
1489
2428
  process.off("SIGTERM", this.handleTerminateSignal);
2429
+ process.off("SIGHUP", this.handleTerminateSignal);
1490
2430
  this.detachCurrentChild();
1491
2431
  for (const child of this.liveChildren) {
1492
2432
  this.beginTreeTermination(child);
1493
2433
  }
1494
- await Promise.all([...this.unresolvedTreeTokens].map((pid) => this.retryUnresolvedTreeToken(pid)));
2434
+ try {
2435
+ await Promise.all([...this.unresolvedTreeTokens].map((pid) => this.retryUnresolvedTreeToken(pid)));
2436
+ }
2437
+ finally {
2438
+ // Deregistered only once the teardown TAIL is done. Dropping them before
2439
+ // the await left a rejection raised while the worker group was still
2440
+ // being collected with nowhere to go: node's default handler is
2441
+ // suppressed for the lifetime of a registration, and this one had
2442
+ // already been withdrawn.
2443
+ process.off("uncaughtException", this.handleFatalError);
2444
+ process.off("unhandledRejection", this.handleFatalError);
2445
+ }
1495
2446
  }
1496
2447
  }
1497
2448
  export const STDIO_WORKER_MODE_ENV = WORKER_MODE_ENV;