@debugg-ai/debugg-ai-mcp 3.7.0 → 3.7.2

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.
@@ -14,7 +14,7 @@ import { TunnelProvisionError } from '../services/tunnels.js';
14
14
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
15
15
  import { detectRepoName } from '../utils/gitContext.js';
16
16
  import { tunnelManager } from '../services/ngrok/tunnelManager.js';
17
- import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
17
+ import { probeLocalPort, probeTunnelHealth, extractNgrokErrorCode } from '../utils/localReachability.js';
18
18
  import { extractLocalhostPort } from '../utils/urlParser.js';
19
19
  import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
20
20
  import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
@@ -33,6 +33,28 @@ function getMaxTransientRetries() {
33
33
  return 1;
34
34
  return Math.min(n, 3);
35
35
  }
36
+ // Bug z15n: scan run evidence for ngrok's interstitial marker. The marker is a
37
+ // stable, recognizable string the REMOTE BROWSER saw — positive evidence that it
38
+ // hit our tunnel's error page rather than the user's app. Non-string parts are
39
+ // serialized (the action trace is the usual carrier); a part we can't serialize
40
+ // is simply skipped — it is not evidence either way.
41
+ function findNgrokErrorMarker(parts) {
42
+ for (const part of parts) {
43
+ if (part === undefined || part === null || part === '')
44
+ continue;
45
+ let text;
46
+ try {
47
+ text = typeof part === 'string' ? part : JSON.stringify(part) ?? '';
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ const code = extractNgrokErrorCode(text);
53
+ if (code)
54
+ return code;
55
+ }
56
+ return undefined;
57
+ }
36
58
  // Concurrency control — max 2 simultaneous browser checks.
37
59
  // Additional requests queue and run when a slot opens.
38
60
  const MAX_CONCURRENT = 2;
@@ -99,10 +121,45 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
99
121
  // dropped client kept polling for up to ~10 min, holding one of just
100
122
  // MAX_CONCURRENT=2 slots. Wiring to context.signal cancels the poll and frees
101
123
  // the slot immediately.
124
+ //
125
+ // Bead 5er7: aborting the poll frees our slot but does NOT stop the BACKEND
126
+ // execution — it runs on to its own contextData.timeoutSeconds (720), driving
127
+ // a real browser session and burning quota with nobody reading the result.
128
+ // cancelExecution() existed for exactly this and had zero callers. Cancel the
129
+ // in-flight execution best-effort on abort.
130
+ //
131
+ // Contract for the cancel path: NEVER throw and NEVER await. The client is
132
+ // already gone, so a failed cancel is not worth surfacing, and the abort path
133
+ // must not delay slot release. Strictly fire-and-forget, rejection swallowed.
134
+ let clientAborted = false;
135
+ let currentExecutionUuid = '';
136
+ const cancelCurrentExecution = () => {
137
+ // HELD OFF BY DEFAULT (bead 5er7 / sentinal-wmzdf). Cancelling the backend
138
+ // execution skips its browser teardown, so the BrowserSession row leaks
139
+ // ACTIVE and permanently burns one of the company's 50 concurrency slots —
140
+ // a net-negative trade vs. the pre-fix behaviour (poll aborts, backend runs
141
+ // to its own 720s budget, then teardown runs and the slot is returned:
142
+ // bounded and self-healing). Read at call time so the flag can be flipped
143
+ // to 'true' the moment the backend runs teardown on cancel (sentinal-wmzdf).
144
+ if (process.env.DEBUGGAI_CANCEL_ABANDONED_EXECUTIONS !== 'true')
145
+ return;
146
+ const uuid = currentExecutionUuid;
147
+ if (!uuid)
148
+ return; // nothing queued yet — never POST cancel/<empty>/
149
+ currentExecutionUuid = ''; // cancel any given execution at most once
150
+ try {
151
+ client.workflows?.cancelExecution(uuid).then(() => logger.info(`Cancelled abandoned execution ${uuid}`), (err) => logger.warn(`Best-effort cancel of abandoned execution ${uuid} failed: ${err}`));
152
+ }
153
+ catch (err) {
154
+ logger.warn(`Best-effort cancel of abandoned execution ${uuid} threw synchronously: ${err}`);
155
+ }
156
+ };
102
157
  const abortController = new AbortController();
103
158
  const onAbort = () => {
159
+ clientAborted = true;
104
160
  abortController.abort();
105
161
  progressDisabled = true; // client is gone — stop emitting
162
+ cancelCurrentExecution();
106
163
  };
107
164
  const requestSignal = context.signal;
108
165
  if (requestSignal) {
@@ -336,7 +393,17 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
336
393
  }
337
394
  executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData, Object.keys(env).length > 0 ? env : undefined);
338
395
  executionUuid = executeResponse.executionUuid;
396
+ // Bead 5er7: this is now the execution an abort must cancel (a retry moves
397
+ // the target forward; the previous attempt already reached a terminal
398
+ // state, so there is nothing to cancel there).
399
+ currentExecutionUuid = executionUuid;
339
400
  logger.info(`Execution queued: ${executionUuid}${attempt > 1 ? ` (retry ${attempt - 1}/${MAX_RETRIES})` : ''}`);
401
+ // The abort can fire BEFORE anything is queued — while provisioning the
402
+ // tunnel or resolving the template — and there is no abort check between
403
+ // there and here. Without this, a client that dropped during setup still
404
+ // gets a ~12-minute browser run queued on its behalf and abandoned.
405
+ if (clientAborted)
406
+ cancelCurrentExecution();
340
407
  // Closure state — reset PER ATTEMPT so progress numbers don't double-count
341
408
  // across retries.
342
409
  let lastStepsTaken = 0;
@@ -410,6 +477,13 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
410
477
  }
411
478
  await progressCallback({ progress: execProgress, total: TOTAL_STEPS, message });
412
479
  }, abortController.signal);
480
+ // Bead 5er7: pollExecution returned, so the execution reached a terminal
481
+ // state — there is nothing left to cancel and a late abort (while we shape
482
+ // the response) must not POST a cancel for finished work. EXCEPTION: on a
483
+ // poll-deadline timeout the execution may still be running backend-side,
484
+ // so keep it cancellable.
485
+ if (!finalExecution.timedOut)
486
+ currentExecutionUuid = '';
413
487
  // Decide retry vs exit: only retry on documented transient signatures
414
488
  // AND while we still have budget. Otherwise break and surface whatever
415
489
  // result the agent reached.
@@ -496,6 +570,68 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
496
570
  // Evidence: prefer the backend's contract evidence.actionTrace; fall back to
497
571
  // the legacy node-extracted trace while the backend contract deploys.
498
572
  const relayActionTrace = verdict.actionTrace ?? actionTrace;
573
+ // --- Post-hoc tunnel reclassification (bugs z15n, 4bui) ---
574
+ // The pre-flight probe above proves the tunnel was alive when we handed it to
575
+ // the remote browser — it can still die mid-run. When it does, the browser
576
+ // lands on ngrok's ERR_NGROK_* interstitial and the backend, which only sees
577
+ // "the page didn't contain what I was asked about", returns a normal 'fail'
578
+ // whose reason blames the USER'S page for OUR dead tunnel (execution
579
+ // a8f07747: 217s, 7 steps, failureCategory 'fail').
580
+ //
581
+ // THE MARKER IS REQUIRED (bug 4bui). Reclassifying overrides the backend's
582
+ // verdict, so it demands positive evidence of the claim we are actually
583
+ // making: that the remote browser hit OUR error page DURING THE RUN. Only
584
+ // the ERR_NGROK_* marker recorded BY the run is evidence of that.
585
+ //
586
+ // The re-probe is NOT such evidence and can no longer trigger this on its
587
+ // own. It answers a different question — "is the tunnel healthy NOW?" —
588
+ // and we were using its answer to assert something about the run window.
589
+ // That laundered a genuine UI failure into an infrastructure excuse:
590
+ // execution 2aa14b0b completed 2.78s BEFORE its upstream was killed (tunnel
591
+ // alive for 100% of the run, no marker, an honest evidence-strictness
592
+ // verdict) and we still stamped it TunnelOfflineDuringRun. Worse, the probe
593
+ // independently returned a FALSE NETWORK_ERROR on healthy servers ~1 in 5
594
+ // runs (bug k6yq), so the false positive was reachable with nothing
595
+ // whatsoever wrong. (k6yq is now fixed: the cause was not the suspected DNS
596
+ // race but an HTTP/2 GOAWAY from the ngrok edge on a freshly created
597
+ // tunnel, which probeTunnelHealth now retries. The marker requirement
598
+ // stands on its own regardless — the probe never decides this.)
599
+ //
600
+ // Requiring the marker loses no coverage: a real mid-run death fires BOTH
601
+ // arms (live-confirmed — re-probe NETWORK_ERROR *and* marker ERR_NGROK_3200),
602
+ // so the marker alone still catches it. The probe is kept purely as
603
+ // CORROBORATION in `detail` — it tells the caller whether the tunnel is
604
+ // still down now or has since recovered. Per epic 56kd ("relay honestly,
605
+ // invent nothing"), asserting an infrastructure fault we did not observe
606
+ // during the run is the relay inventing a cause.
607
+ let tunnelFault;
608
+ if (verdict.outcome === 'fail' && ctx.isLocalhost && ctx.tunnelId && ctx.targetUrl) {
609
+ const marker = findNgrokErrorMarker([
610
+ verdict.reason,
611
+ finalExecution.state?.error,
612
+ finalExecution.errorMessage,
613
+ relayActionTrace,
614
+ ]);
615
+ // probeTunnelHealth never throws by contract; guard anyway — a probe we
616
+ // couldn't run is NOT evidence of a fault. Corroboration only: its result
617
+ // never decides whether we reclassify, only what we report alongside it.
618
+ const probe = await probeTunnelHealth(ctx.targetUrl).catch(() => undefined);
619
+ if (marker) {
620
+ tunnelFault = { probe, ngrokErrorCode: marker };
621
+ logger.warn(`Reclassifying backend 'fail' as an infrastructure fault for ${executionUuid}: ` +
622
+ `the run recorded ${marker} (re-probe now: ` +
623
+ `${probe ? (probe.healthy ? 'healthy — tunnel has since recovered' : probe.code) : 'unavailable'})`);
624
+ }
625
+ else if (probe && !probe.healthy) {
626
+ // Deliberately NOT reclassifying. The tunnel looks unhealthy now, but
627
+ // the run recorded no ngrok interstitial, so we have no evidence the
628
+ // browser ever saw one — the tunnel most likely died after the run (or
629
+ // the probe flaked). Relay the backend's verdict and log the tension.
630
+ logger.info(`Post-run tunnel re-probe for ${executionUuid} was unhealthy (${probe.code}) but the run ` +
631
+ 'recorded no ERR_NGROK_* marker, so the browser reached the app during the run. Relaying ' +
632
+ "the backend's verdict verbatim rather than blaming the tunnel.");
633
+ }
634
+ }
499
635
  const responsePayload = {
500
636
  outcome: verdict.outcome,
501
637
  success: verdict.success,
@@ -513,6 +649,44 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
513
649
  responsePayload.failureCategory = verdict.failureCategory;
514
650
  if (verdict.reason)
515
651
  responsePayload.reason = verdict.reason;
652
+ // Bug z15n: OUR tunnel died mid-run, so this 'fail' describes our error page,
653
+ // not the user's app. Relay it as a distinct, retryable infrastructure class
654
+ // so an automated caller doesn't record a UI regression that never happened.
655
+ // The backend's original verdict is preserved verbatim, never swallowed.
656
+ //
657
+ // Bug 4bui: the stated cause leads with the MARKER, because the marker is the
658
+ // evidence that justified getting here — the run itself recorded ngrok's
659
+ // interstitial. The probe only corroborates (and may legitimately say the
660
+ // tunnel has recovered by now), so it rides in `detail`, never as the cause.
661
+ if (tunnelFault) {
662
+ const { probe, ngrokErrorCode } = tunnelFault;
663
+ responsePayload.backendVerdict = { outcome: verdict.outcome, reason: verdict.reason };
664
+ responsePayload.outcome = 'error';
665
+ responsePayload.success = false;
666
+ responsePayload.failureCategory = 'infrastructure';
667
+ responsePayload.error = 'TunnelOfflineDuringRun';
668
+ responsePayload.message =
669
+ 'During this run the remote browser landed on our tunnel\'s ngrok error page instead of your ' +
670
+ 'app, so the check evaluated our error page rather than your UI. This is an infrastructure ' +
671
+ 'fault on our side, not a failed check — retry it. The backend\'s original verdict is ' +
672
+ 'preserved under `backendVerdict`.';
673
+ responsePayload.reason =
674
+ `The run recorded ngrok's ${ngrokErrorCode} interstitial, so the remote browser reached ` +
675
+ 'our tunnel error page instead of your app.' +
676
+ (probe
677
+ ? probe.healthy
678
+ ? ' (Our re-probe after the run found the tunnel reachable again, so it has since recovered.)'
679
+ : ` (Our re-probe after the run also failed: ${probe.code}` +
680
+ `${probe.status ? ` (HTTP ${probe.status})` : ''}.)`
681
+ : '');
682
+ responsePayload.detail = {
683
+ ngrokErrorCode,
684
+ probeCode: probe?.code,
685
+ probeStatus: probe?.status,
686
+ probeHealthy: probe?.healthy,
687
+ probeElapsedMs: probe?.elapsedMs,
688
+ };
689
+ }
516
690
  if (Array.isArray(relayActionTrace) && relayActionTrace.length > 0)
517
691
  responsePayload.actionTrace = relayActionTrace;
518
692
  if (evaluation)
@@ -626,7 +800,10 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
626
800
  content.push(link);
627
801
  }
628
802
  }
629
- return { content };
803
+ // Bug z15n: an infrastructure fault is an error, not a check result — same
804
+ // posture as the LocalServerUnreachable / TunnelTrafficBlocked pre-checks.
805
+ // The evidence (screenshot, trace, artifacts) still rides along in `content`.
806
+ return tunnelFault ? { content, isError: true } : { content };
630
807
  }
631
808
  catch (error) {
632
809
  const duration = Date.now() - startTime;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ngrok agent session bootstrap (bead pqgj).
3
+ *
4
+ * ngrok's connect() calls its internal getProcess() and then IMMEDIATELY asks
5
+ * the agent to open a tunnel. But getProcess() resolves as soon as the agent
6
+ * prints its local API address, which — measured live against agent v3.38.0 —
7
+ * is ~293ms BEFORE the client session is established:
8
+ *
9
+ * [ 110ms] msg="starting web service" addr=127.0.0.1:4043 <- getProcess resolves
10
+ * [ 403ms] msg="client session established" <- ~293ms later
11
+ *
12
+ * Tunnelling inside that window returns 503 "a successful ngrok tunnel session
13
+ * has not yet been established". That 503 is retriable and ngrok retries it
14
+ * internally — but connectRetry() pins opts.name on the first call and reuses
15
+ * it, so its own retry collides with the tunnel record the 503 left behind:
16
+ * 400 "invalid tunnel configuration — tunnel already exists". 400 is not
17
+ * retriable, so it surfaces to us. That is bead pqgj's 100% attempt-1 failure.
18
+ *
19
+ * Fix: start the agent and wait for its session BEFORE anyone tunnels.
20
+ * getProcess() memoizes on a module-level promise, so ngrok's own connect()
21
+ * then reuses the very agent we started and never re-spawns it.
22
+ *
23
+ * getProcess is not in ngrok's package export map ({".": "./index.js"}), hence
24
+ * the resolved deep require. It is isolated in this module so the reach into
25
+ * another package's internals is explicit, greppable, and mockable — and so a
26
+ * version bump that moves it fails HERE, loudly, instead of silently spawning
27
+ * something unexpected. Callers treat a throw as "no pre-warm available" and
28
+ * fall back to their connect retry ladder.
29
+ */
30
+ import { createRequire } from 'node:module';
31
+ import { dirname, join } from 'node:path';
32
+ /**
33
+ * Spawn (or reuse) the ngrok agent, wiring the session callbacks.
34
+ *
35
+ * Resolves once the agent's local API is up — NOT once the session is live.
36
+ * Callers await the session via `onStatusChange('connected')`.
37
+ *
38
+ * @throws if ngrok's internals cannot be reached (version bump, stubbed module).
39
+ */
40
+ export const startAgentSession = async (opts) => {
41
+ const require = createRequire(import.meta.url);
42
+ const ngrokEntry = require.resolve('ngrok');
43
+ const processModulePath = join(dirname(ngrokEntry), 'src', 'process.js');
44
+ const { getProcess } = require(processModulePath);
45
+ if (typeof getProcess !== 'function') {
46
+ throw new Error(`ngrok getProcess() not found at ${processModulePath}`);
47
+ }
48
+ await getProcess(opts);
49
+ };
@@ -22,6 +22,7 @@ import { isLocalhostUrl, extractLocalhostPort, generateTunnelUrl } from '../../u
22
22
  import { v4 as uuidv4 } from 'uuid';
23
23
  import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
24
24
  import { getDefaultRegistry, } from './tunnelRegistry.js';
25
+ import { startAgentSession } from './ngrokAgentSession.js';
25
26
  let ngrokModule = null;
26
27
  async function getNgrok() {
27
28
  if (!ngrokModule) {
@@ -66,6 +67,23 @@ class TunnelManager {
66
67
  * changing the public API or depending on jest fake timers.
67
68
  */
68
69
  connectBackoffMs = [500, 1500];
70
+ /**
71
+ * Bead pqgj: how the ngrok agent gets started + how we learn its client
72
+ * session is live. Overridable so tests can drive a fake agent instead of
73
+ * spawning a real ngrok process.
74
+ */
75
+ agentSessionStarter = startAgentSession;
76
+ /**
77
+ * Cap on waiting for "client session established". Measured live at ~293ms;
78
+ * this is a generous ceiling, not an expected wait. On expiry we tunnel
79
+ * anyway and let the retry ladder handle it — a slow session must not become
80
+ * a hang.
81
+ */
82
+ agentSessionTimeoutMs = 5000;
83
+ /** Whether the ngrok agent's client session is established (bead pqgj). */
84
+ agentSessionReady = false;
85
+ /** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
86
+ agentSessionPromise = null;
69
87
  constructor(reg = getDefaultRegistry()) {
70
88
  this.reg = reg;
71
89
  // Bead `mdp`: sweep stale entries on startup so the registry doesn't grow
@@ -349,6 +367,11 @@ class TunnelManager {
349
367
  hostname: tunnelDomain,
350
368
  authtoken: authToken,
351
369
  };
370
+ // Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the
371
+ // agent's ~293ms not-ready window (which poisons the tunnel name via
372
+ // ngrok's own name-reusing internal retry and surfaces as
373
+ // "invalid tunnel configuration").
374
+ await this.ensureAgentSession(authToken, trace);
352
375
  let lastError;
353
376
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
354
377
  trace.emit('connect.attempt.start', { attempt });
@@ -481,6 +504,61 @@ class TunnelManager {
481
504
  }
482
505
  }
483
506
  // ── Helpers ─────────────────────────────────────────────────────────────────
507
+ /**
508
+ * Bead pqgj: make sure the ngrok agent's client session is established before
509
+ * we ask it for a tunnel, so attempt 1 lands on a ready agent instead of the
510
+ * ~293ms not-ready window that made every single run fail its first connect.
511
+ *
512
+ * Never throws: if the agent can't be pre-warmed (ngrok internals moved, slow
513
+ * session, dead token) we fall through and let connectWithRetry's ladder do
514
+ * what it did before this fix. The ladder stays a genuine safety net.
515
+ */
516
+ async ensureAgentSession(authtoken, trace) {
517
+ if (this.agentSessionReady)
518
+ return;
519
+ if (!this.agentSessionPromise) {
520
+ this.agentSessionPromise = (async () => {
521
+ let markEstablished;
522
+ const established = new Promise((resolve) => { markEstablished = resolve; });
523
+ await this.agentSessionStarter({
524
+ authtoken,
525
+ onStatusChange: (status) => {
526
+ if (status === 'connected') {
527
+ this.agentSessionReady = true;
528
+ markEstablished();
529
+ }
530
+ else if (status === 'closed') {
531
+ this.agentSessionReady = false;
532
+ }
533
+ },
534
+ onTerminated: () => {
535
+ // Agent process died — next tunnel must re-warm.
536
+ this.agentSessionReady = false;
537
+ this.agentSessionPromise = null;
538
+ },
539
+ });
540
+ let capTimer;
541
+ const cap = new Promise((resolve) => {
542
+ capTimer = setTimeout(resolve, this.agentSessionTimeoutMs);
543
+ });
544
+ try {
545
+ await Promise.race([established, cap]);
546
+ }
547
+ finally {
548
+ if (capTimer)
549
+ clearTimeout(capTimer);
550
+ }
551
+ })().catch((err) => {
552
+ // Pre-warm unavailable — not fatal, the ladder covers it.
553
+ this.agentSessionPromise = null;
554
+ const msg = err instanceof Error ? err.message : String(err);
555
+ trace?.emit('agent.session.prewarm-failed', { message: msg.slice(0, 200) });
556
+ logger.debug(`ngrok agent pre-warm unavailable, relying on connect retry ladder: ${msg}`);
557
+ });
558
+ }
559
+ await this.agentSessionPromise;
560
+ trace?.emit('agent.session.ready', { ready: this.agentSessionReady });
561
+ }
484
562
  async ensureInitialized() {
485
563
  if (!this.initialized) {
486
564
  try {
@@ -52,10 +52,68 @@ export async function probeLocalPort(port, opts = {}) {
52
52
  });
53
53
  });
54
54
  }
55
+ /**
56
+ * Bead k6yq: connection-level failures that mean "the edge isn't ready yet",
57
+ * NOT "the tunnel is broken".
58
+ *
59
+ * Measured live on freshly created tunnels against a server serving 200: the
60
+ * ngrok edge sends an HTTP/2 GOAWAY with code 0 (NO_ERROR) — a graceful
61
+ * "reconnect on a fresh connection" signal — which undici raises as
62
+ * UND_ERR_SOCKET behind an opaque "fetch failed". DNS resolved in 1ms, and a
63
+ * retry 250ms later returned 200. Treating that as a fault reported a healthy
64
+ * dev server as unreachable roughly 1 run in 5-14.
65
+ *
66
+ * Deliberately EXCLUDED (these resolve and answer — they are real faults):
67
+ * ECONNREFUSED / EHOSTUNREACH / ENETUNREACH, TLS errors, any HTTP response
68
+ * (incl. 502 + ERR_NGROK_*), and timeouts.
69
+ */
70
+ const TRANSIENT_CAUSE_CODES = new Set([
71
+ 'ENOTFOUND', // DNS: name not resolvable yet
72
+ 'EAI_AGAIN', // DNS: temporary resolver failure
73
+ 'ECONNRESET', // edge dropped the connection mid-handshake
74
+ 'EPIPE',
75
+ 'UND_ERR_SOCKET', // undici socket error — this is the GOAWAY case
76
+ 'UND_ERR_CONNECT_TIMEOUT',
77
+ ]);
78
+ /** undici hides the real error behind `TypeError: fetch failed` — dig it out. */
79
+ function errorCodeOf(err) {
80
+ return err?.cause?.code ?? err?.code;
81
+ }
82
+ function isTransientConnectionError(err) {
83
+ const code = errorCodeOf(err);
84
+ return code !== undefined && TRANSIENT_CAUSE_CODES.has(code);
85
+ }
86
+ /**
87
+ * Probe that traffic actually flows through the tunnel.
88
+ *
89
+ * Bead k6yq: a freshly created ngrok tunnel can bounce the first connection
90
+ * (HTTP/2 GOAWAY, DNS not yet resolvable) while being perfectly healthy, so
91
+ * transient connection-level failures are retried with a short backoff. Every
92
+ * other outcome — any HTTP response, connection refused, timeout — is returned
93
+ * on the first attempt, so a real fault is never retried into a false pass.
94
+ */
55
95
  export async function probeTunnelHealth(tunnelUrl, opts = {}) {
96
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? 3);
97
+ const backoff = opts.retryBackoffMs ?? [150, 350];
98
+ const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
99
+ const started = Date.now();
100
+ let last;
101
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
102
+ const { result, retryable } = await probeOnce(tunnelUrl, opts, started);
103
+ if (!retryable)
104
+ return result;
105
+ last = result;
106
+ if (attempt < maxAttempts) {
107
+ await sleep(backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 150);
108
+ }
109
+ }
110
+ // Ladder exhausted on a transient error — the edge never came up. Report it:
111
+ // "not ready" that never becomes ready IS unhealthy.
112
+ return last;
113
+ }
114
+ async function probeOnce(tunnelUrl, opts, started) {
56
115
  const timeoutMs = opts.timeoutMs ?? 5000;
57
116
  const fetchImpl = opts.fetchFn ?? fetch;
58
- const started = Date.now();
59
117
  const controller = new AbortController();
60
118
  const timer = setTimeout(() => controller.abort(), timeoutMs);
61
119
  try {
@@ -71,51 +129,77 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
71
129
  // ngrok error pages are small; a full user app body is a waste.
72
130
  const bodyText = await readCapped(res, 4096);
73
131
  const ngrokErr = extractNgrokErrorCode(bodyText);
74
- // 502/504 + ngrok error marker → ngrok couldn't reach our server
132
+ // 502/504 + ngrok error marker → ngrok couldn't reach our server.
133
+ // A RESPONSE is proof the edge is up: never retried, so bead 4bui's
134
+ // marker-driven reclassification keeps seeing the real verdict.
75
135
  if (ngrokErr) {
76
136
  return {
77
- healthy: false,
78
- status: res.status,
79
- code: 'NGROK_ERROR',
80
- ngrokErrorCode: ngrokErr,
81
- detail: `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
82
- elapsedMs: Date.now() - started,
137
+ retryable: false,
138
+ result: {
139
+ healthy: false,
140
+ status: res.status,
141
+ code: 'NGROK_ERROR',
142
+ ngrokErrorCode: ngrokErr,
143
+ detail: `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
144
+ elapsedMs: Date.now() - started,
145
+ },
83
146
  };
84
147
  }
85
148
  if (res.status === 502 || res.status === 504) {
86
149
  return {
87
- healthy: false,
88
- status: res.status,
89
- code: 'BAD_GATEWAY',
90
- detail: `tunnel returned ${res.status} without an ngrok error marker — gateway is rejecting upstream`,
91
- elapsedMs: Date.now() - started,
150
+ retryable: false,
151
+ result: {
152
+ healthy: false,
153
+ status: res.status,
154
+ code: 'BAD_GATEWAY',
155
+ detail: `tunnel returned ${res.status} without an ngrok error marker — gateway is rejecting upstream`,
156
+ elapsedMs: Date.now() - started,
157
+ },
92
158
  };
93
159
  }
94
160
  // Any other response (incl. 4xx from user's app) means traffic reached
95
161
  // the dev server — that's healthy from the TUNNEL's perspective. The
96
162
  // user's 404 is a user concern, not a tunnel concern.
97
163
  return {
98
- healthy: true,
99
- status: res.status,
100
- elapsedMs: Date.now() - started,
164
+ retryable: false,
165
+ result: {
166
+ healthy: true,
167
+ status: res.status,
168
+ elapsedMs: Date.now() - started,
169
+ },
101
170
  };
102
171
  }
103
172
  catch (err) {
104
173
  clearTimeout(timer);
105
174
  const e = err;
106
175
  if (e?.name === 'AbortError' || /abort|timeout/i.test(e?.message ?? '')) {
176
+ // Not retried: a hanging tunnel must not cost 3x the timeout budget.
107
177
  return {
108
- healthy: false,
109
- code: 'TIMEOUT',
110
- detail: `tunnel health probe timed out after ${timeoutMs}ms`,
111
- elapsedMs: Date.now() - started,
178
+ retryable: false,
179
+ result: {
180
+ healthy: false,
181
+ code: 'TIMEOUT',
182
+ detail: `tunnel health probe timed out after ${timeoutMs}ms`,
183
+ elapsedMs: Date.now() - started,
184
+ },
112
185
  };
113
186
  }
187
+ // Surface the cause code: undici reports every network failure as the
188
+ // undiagnosable "fetch failed" and hides the truth in err.cause.
189
+ const causeCode = errorCodeOf(e);
190
+ const baseMsg = e?.message ?? String(err);
191
+ const causeMsg = e?.cause?.message;
192
+ const detail = causeCode
193
+ ? `${baseMsg} (${causeCode}${causeMsg && causeMsg !== baseMsg ? `: ${causeMsg}` : ''})`
194
+ : baseMsg;
114
195
  return {
115
- healthy: false,
116
- code: 'NETWORK_ERROR',
117
- detail: e?.message ?? String(err),
118
- elapsedMs: Date.now() - started,
196
+ retryable: isTransientConnectionError(e),
197
+ result: {
198
+ healthy: false,
199
+ code: 'NETWORK_ERROR',
200
+ detail,
201
+ elapsedMs: Date.now() - started,
202
+ },
119
203
  };
120
204
  }
121
205
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "bin": {