@cortexkit/aft-bridge 0.62.0 → 0.63.5

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 (90) hide show
  1. package/dist/bash-env.d.ts +28 -0
  2. package/dist/bash-env.d.ts.map +1 -0
  3. package/dist/bash-env.js +161 -0
  4. package/dist/bash-env.js.map +1 -0
  5. package/dist/bash-hints.js +1 -1
  6. package/dist/bash-hints.js.map +1 -1
  7. package/dist/bash-host-fallback.d.ts +7 -2
  8. package/dist/bash-host-fallback.d.ts.map +1 -1
  9. package/dist/bash-host-fallback.js +7 -2
  10. package/dist/bash-host-fallback.js.map +1 -1
  11. package/dist/bash-output-page.d.ts +35 -0
  12. package/dist/bash-output-page.d.ts.map +1 -0
  13. package/dist/bash-output-page.js +78 -0
  14. package/dist/bash-output-page.js.map +1 -0
  15. package/dist/bash-pattern-ack.d.ts +21 -0
  16. package/dist/bash-pattern-ack.d.ts.map +1 -0
  17. package/dist/bash-pattern-ack.js +60 -0
  18. package/dist/bash-pattern-ack.js.map +1 -0
  19. package/dist/bash-permissions.d.ts +23 -0
  20. package/dist/bash-permissions.d.ts.map +1 -0
  21. package/dist/bash-permissions.js +84 -0
  22. package/dist/bash-permissions.js.map +1 -0
  23. package/dist/bash-watch-lines.d.ts +11 -0
  24. package/dist/bash-watch-lines.d.ts.map +1 -0
  25. package/dist/bash-watch-lines.js +91 -0
  26. package/dist/bash-watch-lines.js.map +1 -0
  27. package/dist/bash-watch-progress.d.ts +34 -0
  28. package/dist/bash-watch-progress.d.ts.map +1 -0
  29. package/dist/bash-watch-progress.js +154 -0
  30. package/dist/bash-watch-progress.js.map +1 -0
  31. package/dist/bridge.d.ts +207 -24
  32. package/dist/bridge.d.ts.map +1 -1
  33. package/dist/bridge.js +393 -81
  34. package/dist/bridge.js.map +1 -1
  35. package/dist/callgraph-format.d.ts.map +1 -1
  36. package/dist/callgraph-format.js +36 -12
  37. package/dist/callgraph-format.js.map +1 -1
  38. package/dist/command-timeouts.d.ts +13 -0
  39. package/dist/command-timeouts.d.ts.map +1 -1
  40. package/dist/command-timeouts.js +34 -0
  41. package/dist/command-timeouts.js.map +1 -1
  42. package/dist/config-watch.d.ts +2 -0
  43. package/dist/config-watch.d.ts.map +1 -1
  44. package/dist/config-watch.js +2 -0
  45. package/dist/config-watch.js.map +1 -1
  46. package/dist/edit-summary.d.ts +2 -0
  47. package/dist/edit-summary.d.ts.map +1 -1
  48. package/dist/edit-summary.js +9 -1
  49. package/dist/edit-summary.js.map +1 -1
  50. package/dist/feature-config.d.ts +32 -4
  51. package/dist/feature-config.d.ts.map +1 -1
  52. package/dist/feature-config.js +127 -8
  53. package/dist/feature-config.js.map +1 -1
  54. package/dist/index.d.ts +14 -5
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +13 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/logger.d.ts +4 -0
  59. package/dist/logger.d.ts.map +1 -1
  60. package/dist/lsp-github-install.d.ts +20 -0
  61. package/dist/lsp-github-install.d.ts.map +1 -1
  62. package/dist/lsp-github-install.js +39 -69
  63. package/dist/lsp-github-install.js.map +1 -1
  64. package/dist/lsp-github-probe.d.ts +11 -1
  65. package/dist/lsp-github-probe.d.ts.map +1 -1
  66. package/dist/lsp-github-probe.js +85 -5
  67. package/dist/lsp-github-probe.js.map +1 -1
  68. package/dist/lsp-github-table.d.ts.map +1 -1
  69. package/dist/lsp-github-table.js +5 -1
  70. package/dist/lsp-github-table.js.map +1 -1
  71. package/dist/lsp-npm-policy.d.ts +7 -0
  72. package/dist/lsp-npm-policy.d.ts.map +1 -0
  73. package/dist/lsp-npm-policy.js +125 -0
  74. package/dist/lsp-npm-policy.js.map +1 -0
  75. package/dist/pool.d.ts.map +1 -1
  76. package/dist/pool.js +6 -1
  77. package/dist/pool.js.map +1 -1
  78. package/dist/protocol.d.ts +48 -10
  79. package/dist/protocol.d.ts.map +1 -1
  80. package/dist/protocol.js +11 -0
  81. package/dist/protocol.js.map +1 -1
  82. package/dist/subc-transport.d.ts.map +1 -1
  83. package/dist/subc-transport.js +16 -0
  84. package/dist/subc-transport.js.map +1 -1
  85. package/dist/tool-format.js +2 -2
  86. package/dist/view-token.d.ts +9 -0
  87. package/dist/view-token.d.ts.map +1 -0
  88. package/dist/view-token.js +22 -0
  89. package/dist/view-token.js.map +1 -0
  90. package/package.json +1 -1
package/dist/bridge.js CHANGED
@@ -4,12 +4,37 @@ import { StringDecoder } from "node:string_decoder";
4
4
  import { error, getActiveLogger, getLogFilePath, log, warn } from "./active-logger.js";
5
5
  import { binaryContentHash, binaryStampKey, peekBinaryContentHash } from "./binary-identity.js";
6
6
  import { spawn } from "./child-process.js";
7
- import { isPassiveCommand, PASSIVE_COMMAND_TIMEOUT_MS } from "./command-timeouts.js";
7
+ import { isPassiveCommand, isReadOnlyRetryCommand, PASSIVE_COMMAND_TIMEOUT_MS, } from "./command-timeouts.js";
8
8
  import { withPathPrepended } from "./path-env.js";
9
9
  import { isTerminalBashStatus } from "./protocol.js";
10
10
  import { HOST_TOOL_NAMES_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
11
11
  const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
12
- const BRIDGE_HANG_TIMEOUT_THRESHOLD = 2;
12
+ /** Default floor of the hang watchdog's silence window (`hangWindowMinMs`). */
13
+ const DEFAULT_MIN_HANG_WINDOW_MS = 60_000;
14
+ /** Default wait for the answer to the hang watchdog's `ping` (`hangProbeTimeoutMs`). */
15
+ const DEFAULT_HANG_PROBE_TIMEOUT_MS = 5_000;
16
+ /**
17
+ * How long the child may write nothing at all (no reply, progress frame or
18
+ * push frame) while it owes a reply, before the hang watchdog probes it with a
19
+ * `ping`. The window starts at the child's last output, or at the oldest
20
+ * unanswered request when that request was sent after the last output.
21
+ *
22
+ * The window is twice the longest transport budget among those unanswered
23
+ * requests and the calls still waiting, and never less than `minWindowMs`. A
24
+ * request that is slow but healthy finishes within its own budget, and a
25
+ * request queued behind it within roughly one more, so a child that stays
26
+ * silent for twice the longest budget is suspected hung and gets the probe.
27
+ * (A deeper silent backlog can still reach the window; any parsed stdout frame
28
+ * resets it.) The floor keeps short budgets (5 s status polls, 30 s defaults)
29
+ * from turning a slow disk or a cold index into a kill. This is the only place
30
+ * the window is computed.
31
+ */
32
+ export function hangWindowMs(budgetsMs, minWindowMs = DEFAULT_MIN_HANG_WINDOW_MS) {
33
+ let longest = 0;
34
+ for (const budget of budgetsMs)
35
+ longest = Math.max(longest, budget);
36
+ return Math.max(minWindowMs, 2 * longest);
37
+ }
13
38
  const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
14
39
  /** Characters of an unparseable stdout line quoted in the warning log. */
15
40
  const MALFORMED_STDOUT_LOG_CHARS = 512;
@@ -149,6 +174,13 @@ export function compareSemver(a, b) {
149
174
  }
150
175
  return 0;
151
176
  }
177
+ // Looks up the global timer functions at call time, so code that replaces
178
+ // globalThis.setTimeout (as some tests do) still sees bridge timers.
179
+ const SYSTEM_CLOCK = {
180
+ now: () => Date.now(),
181
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
182
+ clearTimeout: (timer) => clearTimeout(timer),
183
+ };
152
184
  function coerceConfigureDroppedKeys(value) {
153
185
  if (!Array.isArray(value))
154
186
  return [];
@@ -239,6 +271,35 @@ export class BridgeTransportUnknownOutcomeError extends BridgeTransportUnavailab
239
271
  this.name = "BridgeTransportUnknownOutcomeError";
240
272
  }
241
273
  }
274
+ /**
275
+ * The hang watchdog killed the child while this request was waiting: the child
276
+ * had written nothing for longer than its silence window (see `hangWindowMs`).
277
+ * The bridge respawns on the next call, so the request can be sent again.
278
+ *
279
+ * `outcomeUnknown` is false for read-only tools (`READ_ONLY_RETRY_COMMANDS`):
280
+ * they cannot have changed anything, and the bridge retries them once on the
281
+ * respawned child (unless the call was aborted or the bridge is shutting
282
+ * down), so a caller sees this only if that retry was cut off too. For every
283
+ * other command it is true: the child may
284
+ * have applied the request before it went silent, so a mutation must be
285
+ * inspected before it is repeated.
286
+ */
287
+ export class BridgeRestartedError extends BridgeTransportUnknownOutcomeError {
288
+ tool;
289
+ outcomeUnknown;
290
+ retryable = true;
291
+ restartReason = "hang_watchdog";
292
+ constructor(tool, outcomeUnknown, message) {
293
+ super(message);
294
+ this.tool = tool;
295
+ this.outcomeUnknown = outcomeUnknown;
296
+ this.name = "BridgeRestartedError";
297
+ }
298
+ }
299
+ /** Type guard for a rejection caused by a hang-watchdog restart. */
300
+ export function isBridgeRestarted(err) {
301
+ return err instanceof BridgeRestartedError;
302
+ }
242
303
  /** Type guard for a transport-timeout rejection (bridge busy, retryable). */
243
304
  export function isBridgeTransportTimeout(err) {
244
305
  return err instanceof Error && err.code === "transport_timeout";
@@ -300,7 +361,9 @@ export class BinaryBridge {
300
361
  _restartCount = 0;
301
362
  _shuttingDown = false;
302
363
  timeoutMs;
303
- hangThreshold;
364
+ hangWindowMinMs;
365
+ hangProbeTimeoutMs;
366
+ clock;
304
367
  lateShellReplyGraceMs;
305
368
  maxRestarts;
306
369
  configured = false;
@@ -321,13 +384,37 @@ export class BinaryBridge {
321
384
  configureWarningClients = new Map();
322
385
  restartTimer = null;
323
386
  restartResetTimer = null;
324
- /** Updated after every successfully parsed stdout frame from the child. */
325
- lastChildActivityAt = 0;
326
- /** Consecutive non-bash-style request timeouts without an id-matched response. */
327
- consecutiveRequestTimeouts = 0;
328
- // One timeout-driven replacement may recover a genuine hang. Replacing it
329
- // again before any tool succeeds only repeats the same cold startup work.
330
- // This survives spawn/configure/version and is re-armed by a tool response.
387
+ /**
388
+ * Hang evidence: requests written to the current child that it has not
389
+ * answered. An entry stays after its caller timed out, because the child
390
+ * still owes the reply. An entry is removed when the child answers that
391
+ * request (`settleHangEvidence`), when an answered liveness probe shows the
392
+ * child is alive while the entry's caller is gone (`onHangProbeAnswered`),
393
+ * or when a new child starts (`resetHangWatchdog`). Output for other
394
+ * requests does not remove it: the child may have answered one session and
395
+ * then frozen on another's request.
396
+ */
397
+ awaitingReply = new Map();
398
+ /**
399
+ * Bridge-clock time of the current child's last parsed stdout frame of any
400
+ * kind. The watchdog's silence is measured from here, so a child that keeps
401
+ * writing is never judged silent.
402
+ */
403
+ lastChildActivity = Number.NEGATIVE_INFINITY;
404
+ /**
405
+ * The watchdog's `ping` to the current child that has not been answered
406
+ * yet; at most one is in flight. It is not a caller's request: it is never
407
+ * retried, never counts as hang evidence, and its answer is handled only by
408
+ * `onHangProbeAnswered`.
409
+ */
410
+ hangProbe = null;
411
+ hangWatchdogTimer = null;
412
+ /** Set once per child when a suppressed watchdog kill has been logged. */
413
+ hangSuppressionLogged = false;
414
+ // One watchdog restart may recover a genuine hang. Restarting again before
415
+ // any tool succeeds only repeats the same cold startup work, so the
416
+ // watchdog stands down until then. This survives spawn/configure/version
417
+ // and is re-armed by a successful tool response.
331
418
  timeoutRestartAwaitingRecovery = false;
332
419
  errorPrefix;
333
420
  logger;
@@ -337,7 +424,9 @@ export class BinaryBridge {
337
424
  this.binaryPath = binaryPath;
338
425
  this.cwd = cwd;
339
426
  this.timeoutMs = options?.timeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
340
- this.hangThreshold = options?.hangThreshold ?? BRIDGE_HANG_TIMEOUT_THRESHOLD;
427
+ this.hangWindowMinMs = options?.hangWindowMinMs ?? DEFAULT_MIN_HANG_WINDOW_MS;
428
+ this.hangProbeTimeoutMs = options?.hangProbeTimeoutMs ?? DEFAULT_HANG_PROBE_TIMEOUT_MS;
429
+ this.clock = options?.clock ?? SYSTEM_CLOCK;
341
430
  this.lateShellReplyGraceMs =
342
431
  options?.lateShellReplyGraceMs ?? DEFAULT_LATE_SHELL_REPLY_GRACE_MS;
343
432
  this.maxRestarts = options?.maxRestarts ?? 3;
@@ -389,6 +478,23 @@ export class BinaryBridge {
389
478
  log(message, meta);
390
479
  }
391
480
  }
481
+ debugVia(message, meta) {
482
+ const logger = this.logger ?? getActiveLogger();
483
+ // A host logger without a debug method, or no logger at all, gets the same
484
+ // destination as an INFO record, as before DEBUG forwarding existed. Never
485
+ // fall back to the console: engine DEBUG records can be numerous.
486
+ if (!logger?.debug) {
487
+ this.logVia(message, meta);
488
+ return;
489
+ }
490
+ try {
491
+ logger.debug(message, meta);
492
+ }
493
+ catch (err) {
494
+ console.error(`[aft-bridge] ERROR: logger debug threw: ${err instanceof Error ? err.message : String(err)}`);
495
+ console.error(`[aft-bridge] ${message}`);
496
+ }
497
+ }
392
498
  warnVia(message, meta) {
393
499
  const logger = this.logger ?? getActiveLogger();
394
500
  if (logger) {
@@ -673,7 +779,7 @@ export class BinaryBridge {
673
779
  params[HOST_TOOL_NAMES_FIELD] = hostToolNames;
674
780
  return (await this.send("tool_call", params, Object.keys(sendOptions).length > 0 ? sendOptions : undefined));
675
781
  }
676
- async sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap) {
782
+ async sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap, retriedAfterHangRestart = false) {
677
783
  try {
678
784
  if (this._retiringDueToBinaryChange) {
679
785
  throw new BridgeTransportUnavailableError(`${this.errorPrefix} Bridge is retiring after the on-disk binary changed; retry to respawn on the updated binary`);
@@ -809,17 +915,22 @@ export class BinaryBridge {
809
915
  else {
810
916
  request = { id, command, ...params };
811
917
  }
918
+ delete request.handles_progress;
919
+ if (options?.onProgress)
920
+ request.handles_progress = true;
812
921
  const line = `${JSON.stringify(request)}\n`;
813
- // Passive polls NEVER count toward hang escalation regardless of caller:
814
- // a queued status poll timing out means the bridge is BUSY, not hung, and
815
- // killing it would abort the user's in-flight request waiting on the same
816
- // work (issue #117). This is enforced bridge-side so no call site can
817
- // forget it.
922
+ // Passive polls and completion drains always take the typed keep-warm
923
+ // timeout regardless of caller: a queued status poll timing out means
924
+ // the bridge is BUSY, not hung (issue #117). Enforced bridge-side so no
925
+ // call site can forget it.
818
926
  const keepBridgeOnTimeout = passive || command === "bash_drain_completions" || options?.keepBridgeOnTimeout === true;
819
- let requestSentAt = Date.now();
820
927
  const child = this.process;
821
928
  const response = await new Promise((resolve, reject) => {
822
- const timer = setTimeout(() => {
929
+ // A timeout settles only this request. It never kills the child or
930
+ // rejects other requests: one slow call says nothing about the calls
931
+ // of other sessions sharing this child, and a child that is truly
932
+ // hung is caught by the hang watchdog, which watches its silence.
933
+ const timer = this.clock.setTimeout(() => {
823
934
  const entry = this.pending.get(id);
824
935
  if (!entry)
825
936
  return;
@@ -828,51 +939,26 @@ export class BinaryBridge {
828
939
  return;
829
940
  }
830
941
  this.pending.delete(id);
831
- clearTimeout(entry.timer);
942
+ this.clock.clearTimeout(entry.timer);
832
943
  entry.onSettled?.();
833
- if (keepBridgeOnTimeout) {
834
- const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`;
835
- if (requestSessionId) {
836
- this.sessionWarnVia(requestSessionId, timeoutMsg);
837
- }
838
- else {
839
- this.warnVia(timeoutMsg);
840
- }
841
- entry.reject(new BridgeTransportTimeoutError(command, effectiveTimeoutMs, `${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
842
- return;
843
- }
844
- const childActiveSinceRequest = this.lastChildActivityAt > requestSentAt;
845
- const consecutiveTimeouts = this.consecutiveRequestTimeouts + 1;
846
- this.consecutiveRequestTimeouts = consecutiveTimeouts;
847
- const restartSuppressed = this.timeoutRestartAwaitingRecovery;
848
- const keepWarm = restartSuppressed ||
849
- childActiveSinceRequest ||
850
- consecutiveTimeouts < this.hangThreshold;
851
- const restartSuffix = keepWarm ? " — bridge kept warm" : " — restarting bridge";
852
- const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms${restartSuffix}`;
944
+ const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms — bridge kept warm`;
853
945
  if (requestSessionId) {
854
946
  this.sessionWarnVia(requestSessionId, timeoutMsg);
855
947
  }
856
948
  else {
857
949
  this.warnVia(timeoutMsg);
858
950
  }
859
- if (keepWarm) {
860
- entry.reject(new Error(restartSuppressed
861
- ? `${this.errorPrefix} request "${command}" timed out after ${effectiveTimeoutMs}ms; automatic timeout restart already attempted; bridge kept warm to finish startup. Retry later or restart the host explicitly if it remains unresponsive. A timed-out mutation may still complete; inspect before repeating it.`
862
- : `${this.errorPrefix} request "${command}" timed out after ${effectiveTimeoutMs}ms (bridge busy/under load); bridge kept warm — retry`));
951
+ if (keepBridgeOnTimeout) {
952
+ entry.reject(new BridgeTransportTimeoutError(command, effectiveTimeoutMs, `${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
863
953
  return;
864
954
  }
865
- entry.reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
866
- this.handleTimeout({
867
- requestId: id,
868
- tool: entry.tool,
869
- generation: entry.generation,
870
- }, requestSessionId);
955
+ entry.reject(new Error(`${this.errorPrefix} request "${command}" timed out after ${effectiveTimeoutMs}ms (bridge busy/under load); bridge kept warm — retry. A timed-out mutation may still complete; inspect before repeating it.`));
871
956
  }, effectiveTimeoutMs);
872
957
  const entry = {
873
958
  resolve,
874
959
  reject,
875
960
  timer,
961
+ budgetMs: effectiveTimeoutMs,
876
962
  onProgress: options?.onProgress,
877
963
  command,
878
964
  tool,
@@ -881,21 +967,21 @@ export class BinaryBridge {
881
967
  this.pending.set(id, entry);
882
968
  if (!child?.stdin?.writable) {
883
969
  this.pending.delete(id);
884
- clearTimeout(timer);
970
+ this.clock.clearTimeout(timer);
885
971
  const error = new BridgeTransportUnavailableError(`${this.errorPrefix} stdin not writable for command "${command}"`);
886
972
  reject(error);
887
973
  if (this.process === child)
888
974
  this.invalidateTransportProcess(error);
889
975
  return;
890
976
  }
891
- requestSentAt = Date.now();
977
+ this.recordAwaitingReply(id, effectiveTimeoutMs, tool, requestSessionId);
892
978
  const handleWriteFailure = (cause) => {
893
979
  const writeError = cause instanceof Error ? cause : new Error(String(cause));
894
980
  const error = new BridgeTransportUnknownOutcomeError(`${this.errorPrefix} Failed to write to stdin: ${writeError.message}`, { cause: writeError });
895
981
  const entry = this.pending.get(id);
896
982
  if (entry) {
897
983
  this.pending.delete(id);
898
- clearTimeout(entry.timer);
984
+ this.clock.clearTimeout(entry.timer);
899
985
  entry.onSettled?.();
900
986
  entry.reject(error);
901
987
  }
@@ -928,7 +1014,19 @@ export class BinaryBridge {
928
1014
  command !== "configure" &&
929
1015
  command !== "version") {
930
1016
  this.logVia(`Retrying request "${command}" once after coordinated binary replacement: ${err.newBinaryPath}`);
931
- return this.sendWithVersionMismatchRetry(command, params, options, false);
1017
+ return this.sendWithVersionMismatchRetry(command, params, options, false, retriedAfterHangRestart);
1018
+ }
1019
+ // A watchdog restart rejected this call. A read-only tool is sent once
1020
+ // more on the respawned child; anything else may already have taken
1021
+ // effect, so its caller gets the typed restart error to decide on.
1022
+ const tool = command === "tool_call" && typeof params.name === "string" ? params.name : command;
1023
+ if (err instanceof BridgeRestartedError &&
1024
+ !retriedAfterHangRestart &&
1025
+ isReadOnlyRetryCommand(tool) &&
1026
+ !this._shuttingDown &&
1027
+ options?.abortSignal?.aborted !== true) {
1028
+ this.logVia(`Retrying read-only request "${tool}" once after the hang watchdog restart`);
1029
+ return this.sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap, true);
932
1030
  }
933
1031
  throw err;
934
1032
  }
@@ -1009,6 +1107,7 @@ export class BinaryBridge {
1009
1107
  async shutdown() {
1010
1108
  this._shuttingDown = true;
1011
1109
  this.spawnedBinaryFingerprint = null;
1110
+ this.resetHangWatchdog();
1012
1111
  this.clearRestartResetTimer();
1013
1112
  this.clearRestartTimer();
1014
1113
  this.configureWarningClients.clear();
@@ -1258,8 +1357,7 @@ export class BinaryBridge {
1258
1357
  this.stdoutPending = [];
1259
1358
  this.stdoutPendingLength = 0;
1260
1359
  this.stderrBuffer = "";
1261
- this.lastChildActivityAt = 0;
1262
- this.consecutiveRequestTimeouts = 0;
1360
+ this.resetHangWatchdog();
1263
1361
  // Fresh spawn — clear the stderr ring so crash diagnostics only reflect
1264
1362
  // the current child's output, not output from prior restart cycles.
1265
1363
  this.stderrTail = [];
@@ -1307,7 +1405,17 @@ export class BinaryBridge {
1307
1405
  if (!line || !shouldSurfaceStderrLine(line))
1308
1406
  return;
1309
1407
  const tagged = tagStderrLine(line);
1310
- this.logVia(tagged);
1408
+ // Only the engine's own formatter supplies a trustworthy severity prefix.
1409
+ const level = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[aft(?:-lsp)?\] (INFO|WARN|ERROR|DEBUG) /.exec(line)?.[1];
1410
+ const meta = level ? { engineRecord: true } : undefined;
1411
+ if (level === "WARN")
1412
+ this.warnVia(tagged, meta);
1413
+ else if (level === "ERROR")
1414
+ this.errorVia(tagged, meta);
1415
+ else if (level === "DEBUG")
1416
+ this.debugVia(tagged, meta);
1417
+ else
1418
+ this.logVia(tagged, meta);
1311
1419
  this.pushStderrLine(tagged);
1312
1420
  }
1313
1421
  /**
@@ -1374,7 +1482,9 @@ export class BinaryBridge {
1374
1482
  processStdoutLine(line) {
1375
1483
  try {
1376
1484
  const response = JSON.parse(line);
1377
- this.lastChildActivityAt = Date.now();
1485
+ // Any parsed frame proves the child is alive: it restarts the silence
1486
+ // clock but forgets no unanswered request (see `awaitingReply`).
1487
+ this.lastChildActivity = this.clock.now();
1378
1488
  if (response.type === "progress") {
1379
1489
  const requestId = response.request_id;
1380
1490
  const entry = requestId ? this.pending.get(requestId) : undefined;
@@ -1388,7 +1498,7 @@ export class BinaryBridge {
1388
1498
  const entry = requestId ? this.pending.get(requestId) : undefined;
1389
1499
  if (requestId && entry) {
1390
1500
  this.pending.delete(requestId);
1391
- clearTimeout(entry.timer);
1501
+ this.clock.clearTimeout(entry.timer);
1392
1502
  entry.onSettled?.();
1393
1503
  entry.resolve({
1394
1504
  success: false,
@@ -1397,6 +1507,10 @@ export class BinaryBridge {
1397
1507
  asks: response.asks,
1398
1508
  });
1399
1509
  }
1510
+ // The ask settles the request in place of a reply. This runs after the
1511
+ // pending entry is gone, so its budget no longer widens the window.
1512
+ if (requestId)
1513
+ this.settleHangEvidence(requestId);
1400
1514
  return;
1401
1515
  }
1402
1516
  if (response.type === "bash_completed") {
@@ -1426,14 +1540,20 @@ export class BinaryBridge {
1426
1540
  return;
1427
1541
  }
1428
1542
  const id = response.id;
1543
+ if (id && this.hangProbe?.id === id) {
1544
+ this.onHangProbeAnswered();
1545
+ return;
1546
+ }
1429
1547
  if (id && this.pending.has(id)) {
1430
1548
  const entry = this.pending.get(id);
1431
1549
  if (!entry)
1432
1550
  return;
1433
1551
  this.pending.delete(id);
1434
- clearTimeout(entry.timer);
1552
+ // Answered: the child no longer owes this request. Settled after the
1553
+ // pending entry is gone, so its budget no longer widens the window.
1554
+ this.settleHangEvidence(id);
1555
+ this.clock.clearTimeout(entry.timer);
1435
1556
  entry.onSettled?.();
1436
- this.consecutiveRequestTimeouts = 0;
1437
1557
  if (response.success !== false &&
1438
1558
  ![
1439
1559
  "configure",
@@ -1451,6 +1571,8 @@ export class BinaryBridge {
1451
1571
  entry.resolve(response);
1452
1572
  }
1453
1573
  else if (id && typeof response.type !== "string") {
1574
+ // A late reply still means the child no longer owes this request.
1575
+ this.settleHangEvidence(id);
1454
1576
  // A reply for a request that already settled (its deadline and any
1455
1577
  // grace period passed). One that names a task is a warning: the task
1456
1578
  // may still be running, and this log line is the only record of its id.
@@ -1506,8 +1628,8 @@ export class BinaryBridge {
1506
1628
  // A reply now arrives after the caller's deadline, so it settles the call
1507
1629
  // as a timeout that reports what AFT answered.
1508
1630
  entry.resolve = (response) => settle(shellOutcomeFromLateReply(response));
1509
- clearTimeout(entry.timer);
1510
- entry.timer = setTimeout(() => {
1631
+ this.clock.clearTimeout(entry.timer);
1632
+ entry.timer = this.clock.setTimeout(() => {
1511
1633
  if (this.pending.get(id) !== entry)
1512
1634
  return;
1513
1635
  this.pending.delete(id);
@@ -1533,23 +1655,211 @@ export class BinaryBridge {
1533
1655
  this.outstandingBackgroundTaskIds.add(taskId);
1534
1656
  }
1535
1657
  }
1536
- handleTimeout(trigger, triggeringSessionId) {
1537
- this.consecutiveRequestTimeouts = 0;
1658
+ /** Record a request the child now owes a reply, and make sure the watchdog runs. */
1659
+ recordAwaitingReply(id, budgetMs, tool, sessionId) {
1660
+ this.awaitingReply.set(id, { sentAt: this.clock.now(), budgetMs, tool, sessionId });
1661
+ this.armHangWatchdog();
1662
+ }
1663
+ /**
1664
+ * The child answered request `id`, so it no longer owes that reply. Only
1665
+ * this request's evidence goes: other unanswered requests stay, because a
1666
+ * child can answer one session and then freeze on another's request.
1667
+ */
1668
+ settleHangEvidence(id) {
1669
+ if (!this.awaitingReply.delete(id))
1670
+ return;
1671
+ // The window may have shrunk with this request's budget, so the timer is
1672
+ // set again from the remaining evidence (or dropped when none is left).
1673
+ this.clearHangWatchdogTimer();
1674
+ this.armHangWatchdog();
1675
+ }
1676
+ /**
1677
+ * When the current child counts as silent for too long: the silence window
1678
+ * measured from its last output, or from the oldest unanswered request when
1679
+ * that was sent later (a child owes nothing before it is asked). The window
1680
+ * covers the budgets of the unanswered requests and of every call still
1681
+ * waiting, so a slow request that is still within its budget widens it.
1682
+ * Null when the child owes no reply.
1683
+ */
1684
+ hangDeadline() {
1685
+ if (this.awaitingReply.size === 0)
1686
+ return null;
1687
+ let oldestId = "";
1688
+ let oldestSentAt = Number.POSITIVE_INFINITY;
1689
+ const budgets = [];
1690
+ for (const [id, entry] of this.awaitingReply) {
1691
+ budgets.push(entry.budgetMs);
1692
+ if (entry.sentAt < oldestSentAt) {
1693
+ oldestSentAt = entry.sentAt;
1694
+ oldestId = id;
1695
+ }
1696
+ }
1697
+ for (const entry of this.pending.values())
1698
+ budgets.push(entry.budgetMs);
1699
+ const windowMs = hangWindowMs(budgets, this.hangWindowMinMs);
1700
+ const silentSince = Math.max(oldestSentAt, this.lastChildActivity);
1701
+ return { deadline: silentSince + windowMs, windowMs, silentSince, oldestId };
1702
+ }
1703
+ armHangWatchdog() {
1704
+ // While a probe is in flight, its own timer decides what happens next.
1705
+ if (this.hangWatchdogTimer || this.hangProbe)
1706
+ return;
1707
+ const hang = this.hangDeadline();
1708
+ if (!hang)
1709
+ return;
1710
+ const timer = this.clock.setTimeout(() => {
1711
+ this.hangWatchdogTimer = null;
1712
+ this.checkForHang();
1713
+ }, Math.max(0, hang.deadline - this.clock.now()));
1714
+ // Waiting calls have their own referenced timeout timers that keep the
1715
+ // host alive; an idle host must be able to exit with a hung child.
1716
+ timer.unref?.();
1717
+ this.hangWatchdogTimer = timer;
1718
+ }
1719
+ clearHangWatchdogTimer() {
1720
+ if (this.hangWatchdogTimer)
1721
+ this.clock.clearTimeout(this.hangWatchdogTimer);
1722
+ this.hangWatchdogTimer = null;
1723
+ }
1724
+ clearHangProbe() {
1725
+ if (this.hangProbe)
1726
+ this.clock.clearTimeout(this.hangProbe.timer);
1727
+ this.hangProbe = null;
1728
+ }
1729
+ resetHangWatchdog() {
1730
+ this.clearHangWatchdogTimer();
1731
+ this.clearHangProbe();
1732
+ this.awaitingReply.clear();
1733
+ this.lastChildActivity = Number.NEGATIVE_INFINITY;
1734
+ this.hangSuppressionLogged = false;
1735
+ }
1736
+ /**
1737
+ * Runs when the silence window may have elapsed. A child that owes a reply
1738
+ * and has written nothing for the whole window is either hung, or healthy
1739
+ * and idle while one request it was given never finishes (a stuck worker, a
1740
+ * lost reply). Only a hung child should be replaced: the bridge is shared by
1741
+ * every session, and a restart throws away everyone's warm state. So the
1742
+ * watchdog sends a `ping` (answered by the engine's dispatcher without
1743
+ * touching any index) and decides when it is answered or times out; see
1744
+ * `onHangProbeAnswered` and `onHangProbeTimeout`.
1745
+ *
1746
+ * The probe goes out at this tick even when no caller is waiting any more
1747
+ * (every caller may already have timed out). Waiting for the next call
1748
+ * instead would let that call run into the same silent child. So a hung
1749
+ * child is replaced one window plus one probe timeout after its silence
1750
+ * began (its last output, or the oldest unanswered request if that came
1751
+ * later), and a call made in that time waits at most until then.
1752
+ *
1753
+ * After one watchdog restart it stands down until a tool succeeds (see
1754
+ * `timeoutRestartAwaitingRecovery`), and then sends no probe either.
1755
+ */
1756
+ checkForHang() {
1757
+ if (!this.isAlive() || this.hangProbe)
1758
+ return;
1759
+ const hang = this.hangDeadline();
1760
+ if (!hang)
1761
+ return;
1762
+ const now = this.clock.now();
1763
+ if (now < hang.deadline) {
1764
+ this.armHangWatchdog();
1765
+ return;
1766
+ }
1767
+ if (this.timeoutRestartAwaitingRecovery) {
1768
+ // Pending calls are left to their own timeouts; see the field comment.
1769
+ if (!this.hangSuppressionLogged) {
1770
+ this.hangSuppressionLogged = true;
1771
+ this.warnVia(`Hang watchdog: child silent past its window, but it was already restarted once and no tool has succeeded since; not restarting again; ${this.hangAttribution(hang)}`);
1772
+ }
1773
+ return;
1774
+ }
1775
+ this.sendHangProbe(hang);
1776
+ }
1777
+ hangAttribution(hang) {
1778
+ return (`silent_ms=${this.clock.now() - hang.silentSince} ` +
1779
+ `hang_window_ms=${hang.windowMs} ` +
1780
+ `oldest_unanswered_request_id=${hang.oldestId} ` +
1781
+ `oldest_unanswered_tool=${JSON.stringify(this.awaitingReply.get(hang.oldestId)?.tool)} ` +
1782
+ `bridge_generation=${this.processGeneration}`);
1783
+ }
1784
+ /** Write the liveness `ping` straight to the child, outside the request path. */
1785
+ sendHangProbe(hang) {
1786
+ const id = `hang-probe-${this.nextId++}`;
1787
+ const timer = this.clock.setTimeout(() => this.onHangProbeTimeout(id), this.hangProbeTimeoutMs);
1788
+ // Like the watchdog timer, the probe must not keep an idle host alive.
1789
+ timer.unref?.();
1790
+ this.hangProbe = { id, sentAt: this.clock.now(), timer };
1791
+ this.logVia(`Hang watchdog: child silent past its window; sending liveness probe id=${id} probe_timeout_ms=${this.hangProbeTimeoutMs}; ${this.hangAttribution(hang)}`);
1792
+ // A probe that cannot be written goes unanswered, and its timeout decides.
1793
+ try {
1794
+ this.process?.stdin?.write(`${JSON.stringify({ id, command: "ping" })}\n`, () => { });
1795
+ }
1796
+ catch {
1797
+ // Same as above: the probe timeout handles it.
1798
+ }
1799
+ }
1800
+ /**
1801
+ * The child answered the probe, so it can still respond. Requests whose
1802
+ * callers already gave up (timed out or aborted) stop counting as evidence:
1803
+ * their missing replies alone are no reason to replace a responsive child.
1804
+ * Requests whose callers are still waiting stay, and the silence window
1805
+ * restarts from this answer.
1806
+ */
1807
+ onHangProbeAnswered() {
1808
+ const probeId = this.hangProbe?.id;
1809
+ this.clearHangProbe();
1810
+ let dropped = 0;
1811
+ for (const id of Array.from(this.awaitingReply.keys())) {
1812
+ if (this.pending.has(id))
1813
+ continue;
1814
+ this.awaitingReply.delete(id);
1815
+ dropped += 1;
1816
+ }
1817
+ this.logVia(`Hang watchdog: liveness probe id=${probeId} answered; child kept; dropped ${dropped} unanswered request(s) whose callers already settled, ${this.awaitingReply.size} still waiting`);
1818
+ this.clearHangWatchdogTimer();
1819
+ this.armHangWatchdog();
1820
+ }
1821
+ /**
1822
+ * No answer to the probe within its timeout. If the child wrote anything
1823
+ * else since the probe was sent it is not silent, and the watchdog simply
1824
+ * starts over from that output. Otherwise it is hung and is killed.
1825
+ */
1826
+ onHangProbeTimeout(probeId) {
1827
+ const probe = this.hangProbe;
1828
+ if (!probe || probe.id !== probeId)
1829
+ return;
1830
+ this.hangProbe = null;
1831
+ if (!this.isAlive())
1832
+ return;
1833
+ const hang = this.hangDeadline();
1834
+ if (!hang)
1835
+ return;
1836
+ if (this.lastChildActivity >= probe.sentAt) {
1837
+ this.armHangWatchdog();
1838
+ return;
1839
+ }
1840
+ const attribution = `${this.hangAttribution(hang)} probe_id=${probeId} probe_timeout_ms=${this.hangProbeTimeoutMs}`;
1841
+ this.killHungChild(attribution, this.awaitingReply.get(hang.oldestId)?.sessionId);
1842
+ }
1843
+ killHungChild(attribution, sessionId) {
1538
1844
  this.timeoutRestartAwaitingRecovery = true;
1539
1845
  this.spawnedBinaryFingerprint = null;
1540
- const abortedSiblings = Array.from(this.pending, ([requestId, entry]) => ({
1846
+ const aborted = Array.from(this.pending, ([requestId, entry]) => ({
1541
1847
  request_id: requestId,
1542
1848
  tool: entry.tool,
1849
+ retry: isReadOnlyRetryCommand(entry.tool),
1543
1850
  }));
1544
- const attribution = `triggering_request_id=${trigger.requestId} ` +
1545
- `triggering_tool=${JSON.stringify(trigger.tool)} ` +
1546
- `bridge_generation=${trigger.generation} ` +
1547
- `aborted_siblings=${JSON.stringify(abortedSiblings)}`;
1548
- // A timed-out request means the child is about to be SIGKILLed. Reject all
1549
- // sibling in-flight requests now instead of leaving them parked until their
1550
- // own independent timers fire. Keep the full kill attribution on every
1551
- // sibling error because the trigger's timeout is otherwise invisible there.
1552
- this.rejectAllPending(new BridgeTransportUnknownOutcomeError(`${this.errorPrefix} bridge killed during sibling timeout — request aborted; ${attribution}`));
1851
+ const fullAttribution = `${attribution} aborted_requests=${JSON.stringify(aborted)}`;
1852
+ this.configureWarningClients.clear();
1853
+ const pending = Array.from(this.pending.values());
1854
+ this.pending.clear();
1855
+ for (const entry of pending) {
1856
+ this.clock.clearTimeout(entry.timer);
1857
+ entry.onSettled?.();
1858
+ const readOnly = isReadOnlyRetryCommand(entry.tool);
1859
+ entry.reject(new BridgeRestartedError(entry.tool, !readOnly, readOnly
1860
+ ? `${this.errorPrefix} bridge restarted: the child wrote nothing for longer than its hang window and did not answer a liveness probe; "${entry.tool}" is read-only and safe to retry; ${fullAttribution}`
1861
+ : `${this.errorPrefix} bridge restarted: the child wrote nothing for longer than its hang window and did not answer a liveness probe; the outcome of "${entry.tool}" is unknown — a mutation may already have been applied, inspect before repeating it; ${fullAttribution}`));
1862
+ }
1553
1863
  // Forget outstanding background task ids: their removal hooks died with
1554
1864
  // the child (foreground polls were just rejected and won't resume, and a
1555
1865
  // completion frame can't arrive from a killed process), so keeping them
@@ -1557,6 +1867,7 @@ export class BinaryBridge {
1557
1867
  // detached tasks persist undelivered completions on disk, and the next
1558
1868
  // spawn rehydrates and delivers them.
1559
1869
  this.outstandingBackgroundTaskIds.clear();
1870
+ this.resetHangWatchdog();
1560
1871
  if (this.process) {
1561
1872
  this.process.kill("SIGKILL");
1562
1873
  this.process = null;
@@ -1569,11 +1880,12 @@ export class BinaryBridge {
1569
1880
  // rejection error. Clear the ring so the next spawn doesn't inherit it.
1570
1881
  const tail = this.formatStderrTail();
1571
1882
  this.stderrTail = [];
1883
+ // "Bridge killed after timeout" is the phrase log tooling groups on.
1572
1884
  const killedMsg = tail
1573
- ? `Bridge killed after timeout; ${attribution}.${tail}`
1574
- : `Bridge killed after timeout; ${attribution} (see ${this.getLogFilePathVia()})`;
1575
- if (triggeringSessionId) {
1576
- this.sessionErrorVia(triggeringSessionId, killedMsg);
1885
+ ? `Bridge killed after timeout of the hang watchdog; ${fullAttribution}.${tail}`
1886
+ : `Bridge killed after timeout of the hang watchdog; ${fullAttribution} (see ${this.getLogFilePathVia()})`;
1887
+ if (sessionId) {
1888
+ this.sessionErrorVia(sessionId, killedMsg);
1577
1889
  }
1578
1890
  else {
1579
1891
  this.errorVia(killedMsg);
@@ -1590,7 +1902,7 @@ export class BinaryBridge {
1590
1902
  this.clearRestartResetTimer();
1591
1903
  this.configured = false; // Force reconfigure on next command after restart
1592
1904
  // Forget outstanding background task ids — same rationale as
1593
- // handleTimeout: abandoned removal hooks would otherwise pin this bridge
1905
+ // killHungChild: abandoned removal hooks would otherwise pin this bridge
1594
1906
  // against idle eviction permanently (phantom ids). Disk-persisted
1595
1907
  // completions are re-delivered after the next spawn rehydrates.
1596
1908
  this.outstandingBackgroundTaskIds.clear();
@@ -1638,7 +1950,7 @@ export class BinaryBridge {
1638
1950
  rejectAllPending(error) {
1639
1951
  this.configureWarningClients.clear();
1640
1952
  for (const [_id, entry] of this.pending) {
1641
- clearTimeout(entry.timer);
1953
+ this.clock.clearTimeout(entry.timer);
1642
1954
  entry.onSettled?.();
1643
1955
  entry.reject(error);
1644
1956
  }