@thehammer/danx-dashboard-mcp 0.1.99 → 0.1.101

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.
package/dist/bridge.js CHANGED
@@ -184,6 +184,27 @@ export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
184
184
  export const HEARTBEAT_INTERVAL_MS = 15_000;
185
185
  /** DX-3028 (AC2) — the server-fixable degraded reasons' own periodic retry cadence. */
186
186
  export const DEGRADED_RETRY_INTERVAL_MS = 5 * 60_000;
187
+ /**
188
+ * DX-3099 — how many consecutive 15s heartbeat POSTs may fail to LAND
189
+ * (network error, timeout, 5xx, 408/429 — never an ordinary 401/403 answer,
190
+ * which proves the pipe itself still works) while this process still
191
+ * believes it is `"streaming"`, before that counts as evidence the current
192
+ * stream connection is dead without having errored — the exact "alive but
193
+ * not reporting" signature the investigation found: the SSE connection can
194
+ * read as established at the OS level and never trip `listen.ts`'s own
195
+ * read-idle timeout, while every outbound heartbeat silently never lands
196
+ * (the send call's own result used to be discarded entirely — see the
197
+ * card). `3 * HEARTBEAT_INTERVAL_MS` = 45s, deliberately UNDER
198
+ * `SESSION_LIVENESS_WINDOW_MS` (60s, `plan-session-heartbeat.ts`) so this
199
+ * process self-heals before, or right around, the moment the dashboard
200
+ * would itself start reporting the session `unattached` — never a shorter
201
+ * freshness window (that constant is untouched) and never a blind
202
+ * "restart more eagerly" (no external watchdog, no timer-driven kill): this
203
+ * fires only on repeated, OBSERVED delivery failure, self-diagnosed by the
204
+ * SAME process that is already streaming. Card: DX-3099 (AC3 forbids both
205
+ * of the obvious wrong fixes this constant is not).
206
+ */
207
+ export const HEARTBEAT_SILENT_FAILURE_THRESHOLD = 3;
187
208
  const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
188
209
  const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
189
210
  `[--resume-ids <event-id>[,<event-id>...]]`;
@@ -616,13 +637,18 @@ async function sendHeartbeat(options, deps, connection) {
616
637
  body.reason = connection.reason;
617
638
  if (connection.inventory !== undefined)
618
639
  body.inventory = connection.inventory;
619
- await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
640
+ const outcome = await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
620
641
  method: "POST",
621
642
  body: JSON.stringify({ connection: body }),
622
643
  });
623
- // Deliberately no error handling beyond this: `request()` never throws, and
624
- // this function does not even inspect the answer — a heartbeat is
625
- // fire-and-forget by design (see the docblock above).
644
+ // DX-3099 — this is now the ONE place a heartbeat's own outcome is
645
+ // inspected. `request()` still never throws, and a 401/403 is still an
646
+ // ordinary answer this function does not retry for (the credential is
647
+ // whatever it is; there is nothing to redo about THIS request) — only a
648
+ // "transient" outcome (network error, timeout, 5xx, 408/429) means the
649
+ // request never landed at all, which is exactly the signal the silent-
650
+ // failure counter in `runBridge` below needs. Still never throws.
651
+ return outcome.kind === "answer";
626
652
  }
627
653
  /**
628
654
  * Mint, stream, re-mint — until a terminal outcome, which it writes as the one
@@ -642,6 +668,16 @@ export async function runBridge(options, deps) {
642
668
  let stoppedReason = null;
643
669
  let heartbeatSuppressed = false;
644
670
  let lastInventoryOk = true;
671
+ // DX-3099 — consecutive heartbeat POSTs that failed to LAND while this
672
+ // process still believed `connectionState === "streaming"`. Reset to 0 the
673
+ // moment a heartbeat lands, or the moment state leaves "streaming" for any
674
+ // other reason (a fresh connect cycle earns a clean slate — see the `for
675
+ // (;;)` loop below). `forceReconnect` is the CURRENT connectOnce attempt's
676
+ // own abort, supplied fresh by `listen.ts` on every attempt via
677
+ // `onConnectionAttempt` below; calling it after that attempt has already
678
+ // ended is a harmless no-op.
679
+ let heartbeatFailureStreak = 0;
680
+ let forceReconnect = null;
645
681
  const heartbeatTick = () => {
646
682
  if (heartbeatSuppressed)
647
683
  return;
@@ -649,7 +685,48 @@ export async function runBridge(options, deps) {
649
685
  ? { state: "streaming", inventory: lastInventoryOk ? "ok" : "unavailable" }
650
686
  : connectionState === "stopped"
651
687
  ? { state: "stopped", reason: stoppedReason ?? undefined }
652
- : { state: "reconnecting" });
688
+ : { state: "reconnecting" }).then((landed) => {
689
+ // DX-3099 — only counts while THIS process still believes it is
690
+ // streaming: that is the exact desync the card found (the client
691
+ // believes it is fine; the dashboard has nothing recent for it). A
692
+ // failure while reconnecting/degraded is not new information — the
693
+ // existing mint-backoff / degrade-retry machinery already owns
694
+ // recovery there.
695
+ if (connectionState !== "streaming" || landed) {
696
+ heartbeatFailureStreak = 0;
697
+ return;
698
+ }
699
+ heartbeatFailureStreak += 1;
700
+ if (heartbeatFailureStreak < HEARTBEAT_SILENT_FAILURE_THRESHOLD)
701
+ return;
702
+ heartbeatFailureStreak = 0;
703
+ // AC4 — the session finds out, rather than continuing to believe it is
704
+ // connected: an ordinary event line, delivered immediately (`origin:
705
+ // null` skips D4's agent/machine hold — see `routeEvent`'s docblock),
706
+ // which the plugin already relays into the session's inbox unchanged
707
+ // (see this module's own docblock).
708
+ routeEvent({
709
+ type: "event",
710
+ id: null,
711
+ text: oneLine(`${DIGEST_PREFIX} this session's listener stopped reaching the dashboard for ` +
712
+ `${Math.round((HEARTBEAT_SILENT_FAILURE_THRESHOLD * HEARTBEAT_INTERVAL_MS) / 1000)}s while it still ` +
713
+ `believed it was streaming — forcing a fresh connection now.`),
714
+ origin: null,
715
+ });
716
+ // AC2 — force the CURRENT connectOnce attempt to end now, rather than
717
+ // wait on listen.ts's own read-idle timeout (which this exact failure
718
+ // mode can outlive indefinitely — see the card: the connection reads
719
+ // as established and never stops producing keep-alive bytes on the
720
+ // read side, only the outbound heartbeat silently never lands). The
721
+ // new attempt reconnects with the SAME ticket; the dashboard's own
722
+ // "ONE OPEN STREAM PER TICKET" rule (`attachListener`,
723
+ // `plan-session-listeners.ts`) then tells the OLD, now-superseded
724
+ // registry entry to end (`"replaced"`) — which is what actually
725
+ // releases the exclusive claim, as a natural side effect of THIS SAME
726
+ // PROCESS reconnecting, never an external watchdog and never a second
727
+ // process racing the first.
728
+ forceReconnect?.();
729
+ });
653
730
  };
654
731
  const heartbeat = deps.scheduleInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
655
732
  async function settle(reason, detail, code, extra) {
@@ -781,6 +858,11 @@ export async function runBridge(options, deps) {
781
858
  }
782
859
  for (;;) {
783
860
  connectionState = "reconnecting";
861
+ // DX-3099 — a fresh cycle earns a clean slate: any abort captured from a
862
+ // now-abandoned prior attempt is dropped (calling it would be a harmless
863
+ // no-op anyway) and the failure streak starts over at 0.
864
+ forceReconnect = null;
865
+ heartbeatFailureStreak = 0;
784
866
  const minted = await mintTicket(options, deps);
785
867
  if (minted.kind === "terminal") {
786
868
  const outcome = await settle(minted.reason, minted.detail, 1);
@@ -868,6 +950,12 @@ export async function runBridge(options, deps) {
868
950
  onState: (state) => {
869
951
  connectionState = state;
870
952
  },
953
+ // DX-3099 — captures the CURRENT connectOnce attempt's own abort, so
954
+ // the heartbeat-failure self-heal above always targets exactly the
955
+ // in-flight connection, never a stale one from an earlier attempt.
956
+ onConnectionAttempt: (abort) => {
957
+ forceReconnect = abort;
958
+ },
871
959
  });
872
960
  const stopped = run.stopped;
873
961
  if (stopped === null)
package/dist/listen.js CHANGED
@@ -499,6 +499,11 @@ export async function runListener(options, deps) {
499
499
  };
500
500
  const connectOnce = async () => {
501
501
  const controller = new AbortController();
502
+ // DX-3099 — hand THIS attempt's own abort to the caller before doing
503
+ // anything else, so a force-reconnect requested at any point during the
504
+ // attempt (including before the fetch even starts) targets exactly this
505
+ // controller, never a stale one from a previous attempt.
506
+ deps.onConnectionAttempt?.(() => controller.abort());
502
507
  let idle;
503
508
  const armIdle = () => {
504
509
  clearTimeout(idle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.99",
3
+ "version": "0.1.101",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",