@evomap/evolver 1.89.10 → 1.89.12

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 (112) hide show
  1. package/.cursor/BUGBOT.md +182 -0
  2. package/.env.example +68 -0
  3. package/.git-commit-guard-token +1 -0
  4. package/.github/CODEOWNERS +63 -0
  5. package/.github/ISSUE_TEMPLATE/good_first_issue.md +23 -0
  6. package/.github/pull_request_template.md +45 -0
  7. package/.github/workflows/test.yml +75 -0
  8. package/CHANGELOG.md +1367 -0
  9. package/README.ja-JP.md +1 -1
  10. package/README.ko-KR.md +1 -1
  11. package/README.md +90 -536
  12. package/README.public.md +578 -0
  13. package/README.zh-CN.md +1 -1
  14. package/SECURITY.md +108 -0
  15. package/assets/gep/events.jsonl +3 -0
  16. package/examples/atp-consumer-quickstart.md +100 -0
  17. package/examples/hello-world.md +38 -0
  18. package/index.js +5 -3
  19. package/package.json +6 -18
  20. package/proxy-package.json +39 -0
  21. package/public.manifest.json +145 -0
  22. package/src/adapters/scripts/evolver-session-end.js +18 -37
  23. package/src/atp/atpExecute.js +27 -71
  24. package/src/atp/serviceHelper.js +28 -36
  25. package/src/config.js +17 -0
  26. package/src/evolve/guards.js +721 -1
  27. package/src/evolve/pipeline/collect.js +1283 -1
  28. package/src/evolve/pipeline/dispatch.js +421 -1
  29. package/src/evolve/pipeline/enrich.js +440 -1
  30. package/src/evolve/pipeline/hub.js +319 -1
  31. package/src/evolve/pipeline/select.js +274 -1
  32. package/src/evolve/pipeline/signals.js +206 -1
  33. package/src/evolve/utils.js +264 -1
  34. package/src/evolve.js +350 -1
  35. package/src/gep/a2aProtocol.js +4463 -1
  36. package/src/gep/antiAbuseTelemetry.js +233 -1
  37. package/src/gep/autoDistillConv.js +205 -1
  38. package/src/gep/autoDistillLlm.js +315 -1
  39. package/src/gep/candidateEval.js +92 -1
  40. package/src/gep/candidates.js +198 -1
  41. package/src/gep/contentHash.js +30 -1
  42. package/src/gep/conversationDistiller.js +270 -0
  43. package/src/gep/conversationSniffer.js +266 -1
  44. package/src/gep/crypto.js +89 -1
  45. package/src/gep/curriculum.js +163 -1
  46. package/src/gep/deviceId.js +218 -1
  47. package/src/gep/envFingerprint.js +118 -1
  48. package/src/gep/epigenetics.js +31 -1
  49. package/src/gep/execBridge.js +712 -1
  50. package/src/gep/explore.js +289 -1
  51. package/src/gep/hash.js +15 -1
  52. package/src/gep/hubFetch.js +608 -1
  53. package/src/gep/hubReview.js +207 -1
  54. package/src/gep/hubSearch.js +526 -1
  55. package/src/gep/hubVerify.js +306 -1
  56. package/src/gep/learningSignals.js +89 -1
  57. package/src/gep/memoryGraph.js +1449 -1
  58. package/src/gep/memoryGraphAdapter.js +203 -1
  59. package/src/gep/mutation.js +203 -1
  60. package/src/gep/narrativeMemory.js +108 -1
  61. package/src/gep/oauthLogin.js +9 -3
  62. package/src/gep/openPRRegistry.js +205 -1
  63. package/src/gep/personality.js +423 -1
  64. package/src/gep/policyCheck.js +599 -1
  65. package/src/gep/privacyClient.js +10 -9
  66. package/src/gep/prompt.js +836 -1
  67. package/src/gep/questionGenerator.js +103 -0
  68. package/src/gep/recallInject.js +409 -1
  69. package/src/gep/recallVerifier.js +318 -1
  70. package/src/gep/reflection.js +177 -1
  71. package/src/gep/savingsCore.js +112 -1
  72. package/src/gep/selector.js +602 -1
  73. package/src/gep/signals.js +85 -17
  74. package/src/gep/skillDistiller.js +1294 -1
  75. package/src/gep/solidify.js +1699 -1
  76. package/src/gep/strategy.js +136 -1
  77. package/src/gep/tokenSavings.js +95 -1
  78. package/src/gep/workspaceKeychain.js +174 -1
  79. package/src/proxy/extensions/traceControl.js +109 -1
  80. package/src/proxy/index.js +100 -3
  81. package/src/proxy/inject.js +52 -1
  82. package/src/proxy/lifecycle/manager.js +108 -7
  83. package/src/proxy/server/routes.js +41 -7
  84. package/src/proxy/server/settings.js +6 -2
  85. package/src/proxy/sync/engine.js +31 -15
  86. package/src/proxy/sync/inbound.js +87 -9
  87. package/src/proxy/sync/outbound.js +57 -4
  88. package/src/proxy/trace/extractor.js +1403 -1
  89. package/src/proxy/trace/usage.js +105 -1
  90. package/CONTRIBUTING.md +0 -19
  91. package/assets/cover.png +0 -0
  92. package/scripts/a2a_export.js +0 -63
  93. package/scripts/a2a_ingest.js +0 -79
  94. package/scripts/a2a_promote.js +0 -118
  95. package/scripts/analyze_by_skill.js +0 -121
  96. package/scripts/build_binaries.js +0 -479
  97. package/scripts/check-changelog.js +0 -166
  98. package/scripts/extract_log.js +0 -85
  99. package/scripts/generate_history.js +0 -75
  100. package/scripts/gep_append_event.js +0 -96
  101. package/scripts/gep_personality_report.js +0 -234
  102. package/scripts/human_report.js +0 -147
  103. package/scripts/recall-verify-report.js +0 -234
  104. package/scripts/recover_loop.js +0 -61
  105. package/scripts/refresh_stars_badge.js +0 -168
  106. package/scripts/seed-merchants.js +0 -91
  107. package/scripts/suggest_version.js +0 -89
  108. package/scripts/validate-modules.js +0 -38
  109. package/scripts/validate-suite.js +0 -78
  110. package/skills/index.json +0 -14
  111. /package/assets/gep/{genes.seed.json → genes.json} +0 -0
  112. /package/{skills → bundled-skills}/_meta/SKILL.md +0 -0
@@ -5,7 +5,14 @@ const path = require('path');
5
5
  const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
6
6
  const { buildEnvelope } = require('../envelope');
7
7
  const crypto = require('crypto');
8
- const { hubFetch } = require('../../gep/hubFetch');
8
+ const {
9
+ hubFetch,
10
+ hubUnreachableBackoffMs,
11
+ isHubUnreachableError,
12
+ readHubResponseJson,
13
+ readHubResponseText,
14
+ throwIfHubUnreachableResponse,
15
+ } = require('../../gep/hubFetch');
9
16
  const { getEvomapPath } = require('../../gep/paths');
10
17
  // last_update transit (PR #188): proxy heartbeat ferries a pending
11
18
  // force_update outcome to the hub, then clears the state file on 2xx.
@@ -372,6 +379,8 @@ class LifecycleManager {
372
379
  this._consecutiveReauthFailures = 0;
373
380
  this._driftInterval = null;
374
381
  this._lastDriftCheckAt = 0;
382
+ this._hubUnreachableFailures = 0;
383
+ this._hubUnreachableUntil = 0;
375
384
 
376
385
  // H4 fix: persist the legacy node_id file as soon as the in-memory
377
386
  // node_id is known, NOT only after a successful hello(). The original
@@ -508,6 +517,26 @@ class LifecycleManager {
508
517
  return headers;
509
518
  }
510
519
 
520
+ _hubUnreachableWaitMs() {
521
+ return Math.max(0, this._hubUnreachableUntil - Date.now());
522
+ }
523
+
524
+ _recordHubReachable() {
525
+ this._hubUnreachableFailures = 0;
526
+ this._hubUnreachableUntil = 0;
527
+ }
528
+
529
+ _recordHubUnreachable(err) {
530
+ this._hubUnreachableFailures += 1;
531
+ const retryAfterMs = hubUnreachableBackoffMs(this._hubUnreachableFailures);
532
+ this._hubUnreachableUntil = Date.now() + retryAfterMs;
533
+ this.logger.warn?.(
534
+ `[lifecycle] Hub unreachable; backing off for ${Math.ceil(retryAfterMs / 1000)}s: ` +
535
+ `${err && err.message || err}`
536
+ );
537
+ return retryAfterMs;
538
+ }
539
+
511
540
  async hello({ rotateSecret = false } = {}) {
512
541
  if (!this.hubUrl) return { ok: false, error: 'no_hub_url' };
513
542
 
@@ -517,6 +546,15 @@ class LifecycleManager {
517
546
  return { ok: false, error: 'hello_rate_limit_active', waitSec };
518
547
  }
519
548
 
549
+ const waitMs = this._hubUnreachableWaitMs();
550
+ if (waitMs > 0) {
551
+ return {
552
+ ok: false,
553
+ error: 'hub_unreachable_backoff',
554
+ retryAfterMs: waitMs,
555
+ };
556
+ }
557
+
520
558
  const endpoint = `${this.hubUrl}/a2a/hello`;
521
559
  const nodeId = this.store.getState('node_id')
522
560
  || _readLegacyNodeId()
@@ -539,8 +577,10 @@ class LifecycleManager {
539
577
  body: JSON.stringify(body),
540
578
  signal: AbortSignal.timeout(HELLO_TIMEOUT),
541
579
  });
580
+ await throwIfHubUnreachableResponse(res, 'lifecycle hello');
581
+ this._recordHubReachable();
542
582
  if (!res.ok) {
543
- const errData = await res.json().catch(() => ({}));
583
+ const errData = await readHubResponseJson(res).catch(() => ({}));
544
584
  const errMsg = errData?.error || `http_${res.status}`;
545
585
  if (res.status === 429) {
546
586
  const retryAfter = parseInt(res.headers.get('retry-after') || '3600', 10);
@@ -552,7 +592,7 @@ class LifecycleManager {
552
592
  return { ok: false, error: errMsg, statusCode: res.status };
553
593
  }
554
594
 
555
- const data = await res.json();
595
+ const data = await readHubResponseJson(res);
556
596
 
557
597
  if (data?.payload?.status === 'rejected') {
558
598
  this.logger.error(`[lifecycle] hello rejected: ${data.payload.reason || 'unknown'}`);
@@ -604,6 +644,15 @@ class LifecycleManager {
604
644
  this.logger.log(`[lifecycle] hello OK, node_id=${nodeId}${rotateSecret ? ' (secret rotated)' : ''}`);
605
645
  return { ok: true, nodeId, response: data };
606
646
  } catch (err) {
647
+ if (isHubUnreachableError(err)) {
648
+ const retryAfterMs = this._recordHubUnreachable(err);
649
+ return {
650
+ ok: false,
651
+ error: 'hub_unreachable',
652
+ detail: err.message,
653
+ retryAfterMs,
654
+ };
655
+ }
607
656
  this.logger.error(`[lifecycle] hello failed: ${err.message}`);
608
657
  return { ok: false, error: err.message };
609
658
  }
@@ -630,6 +679,7 @@ class LifecycleManager {
630
679
  }
631
680
  this._reauthInProgress = true;
632
681
  let manualResetRequired = false;
682
+ let hubUnreachable = false;
633
683
  try {
634
684
  for (let attempt = 1; attempt <= MAX_REAUTH_ATTEMPTS; attempt++) {
635
685
  this.logger.warn(`[lifecycle] re-auth attempt ${attempt}/${MAX_REAUTH_ATTEMPTS}: rotating secret via hello...`);
@@ -637,6 +687,17 @@ class LifecycleManager {
637
687
  if (!helloResult.ok) {
638
688
  this.logger.error(`[lifecycle] re-auth hello failed: ${helloResult.error}`);
639
689
  if (helloResult.error === 'hello_rate_limited' || helloResult.error === 'hello_rate_limit_active') break;
690
+ // Hub link is down (WAF HTML, network error, timeout) -- NOT an auth
691
+ // failure. Bail without arming the re-auth backoff: otherwise a
692
+ // transient outage that arrives mid-rotate would burn both attempts
693
+ // (attempt 2's hello short-circuits on the hub-unreachable window)
694
+ // and suppress genuine auth recovery for up to REAUTH_BACKOFF_MAX_MS,
695
+ // even though a JSON 401/403 retry would succeed once the hub is
696
+ // reachable again. The hub-unreachable window already gates re-entry.
697
+ if (helloResult.error === 'hub_unreachable' || helloResult.error === 'hub_unreachable_backoff') {
698
+ hubUnreachable = true;
699
+ break;
700
+ }
640
701
  if (typeof helloResult.error === 'string' && helloResult.error.startsWith('node_id_already_claimed')) {
641
702
  // Hub does not believe we own this nodeId. Our locally cached
642
703
  // secret(s) are useless. Drop them so attempt 2 retries WITHOUT
@@ -667,8 +728,19 @@ class LifecycleManager {
667
728
  // fire a second time anyway. Resetting the flag was misleading.
668
729
  return true;
669
730
  }
731
+ // Same as the hello path: if the verification heartbeat fails because
732
+ // the hub link dropped (not an auth rejection), defer without arming
733
+ // the re-auth backoff.
734
+ if (hbResult.error === 'hub_unreachable' || hbResult.error === 'hub_unreachable_backoff') {
735
+ hubUnreachable = true;
736
+ break;
737
+ }
670
738
  this.logger.warn(`[lifecycle] re-auth attempt ${attempt}: heartbeat still failing after rotate`);
671
739
  }
740
+ if (hubUnreachable) {
741
+ this.logger.warn('[lifecycle] re-auth deferred: hub unreachable (no re-auth backoff armed)');
742
+ return false;
743
+ }
672
744
  if (manualResetRequired) {
673
745
  this._emitManualResetNeeded();
674
746
  }
@@ -734,6 +806,15 @@ class LifecycleManager {
734
806
  // unchanged; only the boundary moved earlier.
735
807
  if (!this.hubUrl) return { ok: false, error: 'no_hub_url' };
736
808
  try {
809
+ const waitMs = this._hubUnreachableWaitMs();
810
+ if (waitMs > 0) {
811
+ return {
812
+ ok: false,
813
+ error: 'hub_unreachable_backoff',
814
+ retryAfterMs: waitMs,
815
+ };
816
+ }
817
+
737
818
  const nodeId = this.nodeId;
738
819
  if (!nodeId) {
739
820
  const helloResult = await this.hello();
@@ -798,9 +879,12 @@ class LifecycleManager {
798
879
  signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT),
799
880
  });
800
881
 
882
+ await throwIfHubUnreachableResponse(res, 'lifecycle heartbeat');
883
+ this._recordHubReachable();
884
+
801
885
  if (res.status === 403 || res.status === 401) {
802
886
  this._consecutiveFailures++;
803
- const errText = await res.text().catch(() => '');
887
+ const errText = await readHubResponseText(res).catch(() => '');
804
888
  this.logger.error(`[lifecycle] heartbeat auth failed (${res.status}): ${errText}`);
805
889
  if (!_skipReauth) {
806
890
  const recovered = await this.reAuthenticate();
@@ -813,7 +897,7 @@ class LifecycleManager {
813
897
  }
814
898
 
815
899
  if (!res.ok) {
816
- const errText = await res.text().catch(() => '');
900
+ const errText = await readHubResponseText(res).catch(() => '');
817
901
  // 426 Upgrade Required: hub emits this when our evolver_version is
818
902
  // below the minimum version it requires. The body is JSON of shape
819
903
  // `{ error: 'evolver_min_version_required', force_update: {...} }`
@@ -881,7 +965,7 @@ class LifecycleManager {
881
965
  return { ok: false, error: `http_${res.status}`, statusCode: res.status };
882
966
  }
883
967
 
884
- const data = await res.json();
968
+ const data = await readHubResponseJson(res);
885
969
 
886
970
  this._consecutiveFailures = 0;
887
971
  this.store.setState('last_heartbeat_at', new Date().toISOString());
@@ -981,6 +1065,16 @@ class LifecycleManager {
981
1065
 
982
1066
  return { ok: true, response: data };
983
1067
  } catch (err) {
1068
+ if (isHubUnreachableError(err)) {
1069
+ this._consecutiveFailures++;
1070
+ const retryAfterMs = this._recordHubUnreachable(err);
1071
+ return {
1072
+ ok: false,
1073
+ error: 'hub_unreachable',
1074
+ detail: err.message,
1075
+ retryAfterMs,
1076
+ };
1077
+ }
984
1078
  this._consecutiveFailures++;
985
1079
  this.logger.error(`[lifecycle] heartbeat failed (${this._consecutiveFailures}): ${err.message}`);
986
1080
  return { ok: false, error: err.message };
@@ -1074,7 +1168,14 @@ class LifecycleManager {
1074
1168
  // `DEFAULT_HEARTBEAT_INTERVAL` (6min) or the exponential branch
1075
1169
  // retries faster than the success branch (Bugbot #147 finding).
1076
1170
  const interval = this._heartbeatInterval || DEFAULT_HEARTBEAT_INTERVAL;
1077
- const backoff = this._consecutiveFailures > 0
1171
+ const hubWaitMs = this._hubUnreachableWaitMs();
1172
+ // While a hub-unreachable window is active, wake exactly when it elapses
1173
+ // (1s floor to avoid a busy 0ms reschedule), mirroring SyncEngine's
1174
+ // `Math.max(1_000, retryAfterMs)`. A 30s floor over-waited up to ~30s past
1175
+ // the window's end, delaying recovery once the hub was reachable again.
1176
+ const backoff = hubWaitMs > 0
1177
+ ? Math.max(1_000, hubWaitMs)
1178
+ : this._consecutiveFailures > 0
1078
1179
  ? Math.min(interval * Math.pow(2, this._consecutiveFailures), HEARTBEAT_BACKOFF_CAP_MS)
1079
1180
  : interval;
1080
1181
  const armedGen = this._heartbeatGen;
@@ -86,17 +86,43 @@ function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
86
86
  },
87
87
 
88
88
  'POST /asset/submit': async ({ body }) => {
89
- if (!body.assets && !body.asset_id) {
90
- throw Object.assign(new Error('assets or asset_id is required'), { statusCode: 400 });
89
+ // The publish path builds a bundle from full asset objects; a bare
90
+ // asset_id is not a valid input here (Bugbot #256 Medium — route used to
91
+ // accept asset_id the handler then ignored).
92
+ if (!body.assets && !body.asset) {
93
+ throw Object.assign(new Error('assets (array) or asset (single object) is required'), { statusCode: 400 });
91
94
  }
92
- const result = store.send({
93
- type: 'asset_submit',
94
- payload: body,
95
- priority: body.priority || 'normal',
96
- });
95
+ // Publish synchronously via the signed Gene+Capsule bundle path
96
+ // (POST /a2a/publish). The old `asset_submit` mailbox dispatch is gated
97
+ // off at the Hub (A2A_MAILBOX_ASSET_SUBMIT_ENABLED), so it silently
98
+ // failed; the Hub now enforces bundles. Returns the per-asset Hub result.
99
+ const result = await proxyHandlers.assetPublish(body);
97
100
  return { body: result };
98
101
  },
99
102
 
103
+ 'POST /conversation/distill': async ({ body }) => {
104
+ const { distillConversation } = require('../../gep/conversationDistiller');
105
+ const input = body || {};
106
+ const distill = distillConversation(input, { persist: input.persist !== false });
107
+ if (!distill.ok) return { body: distill };
108
+
109
+ let publishResult = null;
110
+ if (input.publish !== false) {
111
+ publishResult = await proxyHandlers.assetPublish({
112
+ assets: [distill.gene, distill.capsule].filter(Boolean),
113
+ });
114
+ }
115
+
116
+ return {
117
+ body: {
118
+ ...distill,
119
+ queued: false,
120
+ published: Boolean(publishResult),
121
+ publish_result: publishResult,
122
+ },
123
+ };
124
+ },
125
+
100
126
  'GET /asset/submissions': async ({ query }) => {
101
127
  const submissions = store.list({
102
128
  type: 'asset_submit',
@@ -375,6 +401,10 @@ function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
375
401
  const inPending = store.countPending({ direction: 'inbound' });
376
402
  const nodeId = store.getState('node_id');
377
403
  const lastSync = store.getState('last_sync_at');
404
+ const lastSyncError = store.getState('last_sync_error');
405
+ const hubAuthStatus = store.getState('hub_auth_status');
406
+ const reauthBackoffUntil = store.getState('reauth_backoff_until');
407
+ const helloRateLimitUntil = store.getState('hello_rate_limit_until');
378
408
  return {
379
409
  body: {
380
410
  status: 'running',
@@ -384,6 +414,10 @@ function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
384
414
  outbound_pending: outPending,
385
415
  inbound_pending: inPending,
386
416
  last_sync_at: lastSync,
417
+ last_sync_error: lastSyncError || null,
418
+ hub_auth_status: hubAuthStatus || null,
419
+ reauth_backoff_until: reauthBackoffUntil || null,
420
+ hello_rate_limit_until: helloRateLimitUntil || null,
387
421
  },
388
422
  };
389
423
  },
@@ -47,15 +47,19 @@ function writeSettings(data) {
47
47
  return merged;
48
48
  }
49
49
 
50
- function clearSettings() {
50
+ function clearSettings(opts = {}) {
51
51
  try {
52
52
  const file = getSettingsFile();
53
53
  if (fs.existsSync(file)) {
54
54
  const current = readSettings();
55
+ const proxyPid = current.proxy?.pid;
56
+ if (!opts.force && proxyPid && proxyPid !== process.pid) return false;
55
57
  delete current.proxy;
56
58
  fs.writeFileSync(file, JSON.stringify(current, null, 2), 'utf8');
59
+ return true;
57
60
  }
58
61
  } catch {}
62
+ return false;
59
63
  }
60
64
 
61
65
  function isStaleProxy() {
@@ -82,7 +86,7 @@ function isStaleProxy() {
82
86
 
83
87
  function clearIfStale() {
84
88
  if (isStaleProxy()) {
85
- clearSettings();
89
+ clearSettings({ force: true });
86
90
  return true;
87
91
  }
88
92
  return false;
@@ -77,12 +77,14 @@ class SyncEngine {
77
77
  // the whole tick, schedule the next iteration in `finally` so a
78
78
  // surprise throw cannot park the loop.
79
79
  let nextDelay = DEFAULT_OUTBOUND_INTERVAL;
80
+ let hubRetryAfterMs = 0;
80
81
  try {
81
82
  if (!this._running) return;
82
83
  this._outPending = true;
83
84
  try {
84
85
  const result = await this.outbound.flush();
85
86
  if (result.sent > 0) this._lastActivity = Date.now();
87
+ if (result.retryAfterMs > 0) hubRetryAfterMs = result.retryAfterMs;
86
88
  if ((result.sent > 0 || result.dropped > 0) && typeof this.onOutboundFlushed === 'function') {
87
89
  try { this.onOutboundFlushed(result); } catch (e) {
88
90
  this.logger.warn?.('[sync] onOutboundFlushed callback failed:', e.message);
@@ -96,13 +98,17 @@ class SyncEngine {
96
98
  }
97
99
  }
98
100
  this._outPending = false;
99
- try {
100
- const pending = this.store.countPending({ direction: 'outbound' });
101
- if (pending > 0) nextDelay = 1_000;
102
- } catch (err) {
103
- // countPending threw (corrupt store, FS hiccup): keep the
104
- // default cadence rather than parking the loop.
105
- this.logger.error(`[sync] countPending threw (non-fatal): ${err && err.message}`);
101
+ if (hubRetryAfterMs > 0) {
102
+ nextDelay = Math.max(1_000, hubRetryAfterMs);
103
+ } else {
104
+ try {
105
+ const pending = this.store.countPending({ direction: 'outbound' });
106
+ if (pending > 0) nextDelay = 1_000;
107
+ } catch (err) {
108
+ // countPending threw (corrupt store, FS hiccup): keep the
109
+ // default cadence rather than parking the loop.
110
+ this.logger.error(`[sync] countPending threw (non-fatal): ${err && err.message}`);
111
+ }
106
112
  }
107
113
  } catch (err) {
108
114
  // Anything that escaped the inner blocks above. Log and let
@@ -123,11 +129,14 @@ class SyncEngine {
123
129
  // from inbound.pull / ackDelivered / _isIdle used to escape the
124
130
  // setTimeout callback and silently park the inbound loop.
125
131
  let nextDelay = DEFAULT_POLL_INTERVAL_ACTIVE;
132
+ let hubRetryAfterMs = 0;
126
133
  try {
127
134
  if (!this._running) return;
128
135
  try {
129
136
  const result = await this.inbound.pull();
130
- if (result.received > 0) {
137
+ if (result.retryAfterMs > 0) {
138
+ hubRetryAfterMs = result.retryAfterMs;
139
+ } else if (result.received > 0) {
131
140
  this._lastActivity = Date.now();
132
141
  if (typeof this.onInboundReceived === 'function') {
133
142
  try { this.onInboundReceived(result.received); } catch (e) {
@@ -135,7 +144,10 @@ class SyncEngine {
135
144
  }
136
145
  }
137
146
  }
138
- await this.inbound.ackDelivered();
147
+ if (!hubRetryAfterMs) {
148
+ const ack = await this.inbound.ackDelivered();
149
+ if (ack.retryAfterMs > 0) hubRetryAfterMs = ack.retryAfterMs;
150
+ }
139
151
  } catch (err) {
140
152
  if (err instanceof AuthError) {
141
153
  await this._handleAuthError('inbound');
@@ -143,12 +155,16 @@ class SyncEngine {
143
155
  this.logger.error(`[sync] inbound error: ${err.message}`);
144
156
  }
145
157
  }
146
- try {
147
- nextDelay = this._isIdle()
148
- ? DEFAULT_POLL_INTERVAL_IDLE
149
- : DEFAULT_POLL_INTERVAL_ACTIVE;
150
- } catch (err) {
151
- this.logger.error(`[sync] _isIdle threw (non-fatal): ${err && err.message}`);
158
+ if (hubRetryAfterMs > 0) {
159
+ nextDelay = Math.max(1_000, hubRetryAfterMs);
160
+ } else {
161
+ try {
162
+ nextDelay = this._isIdle()
163
+ ? DEFAULT_POLL_INTERVAL_IDLE
164
+ : DEFAULT_POLL_INTERVAL_ACTIVE;
165
+ } catch (err) {
166
+ this.logger.error(`[sync] _isIdle threw (non-fatal): ${err && err.message}`);
167
+ }
152
168
  }
153
169
  } catch (err) {
154
170
  this.logger.error(`[sync] inbound tick threw (non-fatal): ${err && err.message}`);
@@ -2,7 +2,15 @@
2
2
 
3
3
  const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
4
4
  const { AuthError } = require('../lifecycle/manager');
5
- const { hubFetch } = require('../../gep/hubFetch');
5
+ const {
6
+ drainHubResponse,
7
+ hubFetch,
8
+ hubUnreachableBackoffMs,
9
+ isHubUnreachableError,
10
+ readHubResponseJson,
11
+ readHubResponseText,
12
+ throwIfHubUnreachableResponse,
13
+ } = require('../../gep/hubFetch');
6
14
 
7
15
  const DEFAULT_POLL_INTERVAL_ACTIVE = 10_000;
8
16
  const DEFAULT_POLL_INTERVAL_IDLE = 60_000;
@@ -13,9 +21,41 @@ class InboundSync {
13
21
  this.hubUrl = hubUrl;
14
22
  this.logger = logger || console;
15
23
  this.getHeaders = getHeaders;
24
+ this._hubUnreachableFailures = 0;
25
+ this._hubUnreachableUntil = 0;
26
+ }
27
+
28
+ _hubUnreachableWaitMs() {
29
+ return Math.max(0, this._hubUnreachableUntil - Date.now());
30
+ }
31
+
32
+ _recordHubReachable() {
33
+ this._hubUnreachableFailures = 0;
34
+ this._hubUnreachableUntil = 0;
35
+ }
36
+
37
+ _recordHubUnreachable(err) {
38
+ this._hubUnreachableFailures += 1;
39
+ const retryAfterMs = hubUnreachableBackoffMs(this._hubUnreachableFailures);
40
+ this._hubUnreachableUntil = Date.now() + retryAfterMs;
41
+ this.logger.warn?.(
42
+ `[inbound] Hub unreachable; backing off for ${Math.ceil(retryAfterMs / 1000)}s: ` +
43
+ `${err && err.message || err}`
44
+ );
45
+ return retryAfterMs;
16
46
  }
17
47
 
18
48
  async pull(channel = 'evomap-hub', limit = 50) {
49
+ const waitMs = this._hubUnreachableWaitMs();
50
+ if (waitMs > 0) {
51
+ return {
52
+ received: 0,
53
+ error: 'hub_unreachable_backoff',
54
+ hubUnreachable: true,
55
+ retryAfterMs: waitMs,
56
+ };
57
+ }
58
+
19
59
  const cursorKey = `${channel}:inbound_cursor`;
20
60
  const cursor = this.store.getCursor(cursorKey);
21
61
 
@@ -30,17 +70,20 @@ class InboundSync {
30
70
  signal: AbortSignal.timeout(35_000),
31
71
  });
32
72
 
73
+ await throwIfHubUnreachableResponse(res, 'inbound pull');
74
+ this._recordHubReachable();
75
+
33
76
  if (res.status === 403 || res.status === 401) {
34
- const errText = await res.text().catch(() => 'unknown');
77
+ const errText = await readHubResponseText(res).catch(() => 'unknown');
35
78
  throw new AuthError(`Hub ${res.status}: ${errText}`, res.status);
36
79
  }
37
80
 
38
81
  if (!res.ok) {
39
- const errText = await res.text().catch(() => 'unknown');
82
+ const errText = await readHubResponseText(res).catch(() => 'unknown');
40
83
  throw new Error(`Hub returned ${res.status}: ${errText}`);
41
84
  }
42
85
 
43
- const data = await res.json();
86
+ const data = await readHubResponseJson(res);
44
87
  const messages = data.messages || [];
45
88
 
46
89
  if (messages.length > 0) {
@@ -64,12 +107,31 @@ class InboundSync {
64
107
  return { received: messages.length, cursor: data.next_cursor || cursor };
65
108
  } catch (err) {
66
109
  if (err instanceof AuthError) throw err;
110
+ if (isHubUnreachableError(err)) {
111
+ const retryAfterMs = this._recordHubUnreachable(err);
112
+ return {
113
+ received: 0,
114
+ error: 'hub_unreachable',
115
+ hubUnreachable: true,
116
+ retryAfterMs,
117
+ };
118
+ }
67
119
  this.logger.error(`[inbound] pull failed: ${err.message}`);
68
120
  return { received: 0, error: err.message };
69
121
  }
70
122
  }
71
123
 
72
124
  async ackDelivered(channel = 'evomap-hub') {
125
+ const waitMs = this._hubUnreachableWaitMs();
126
+ if (waitMs > 0) {
127
+ return {
128
+ acked: 0,
129
+ error: 'hub_unreachable_backoff',
130
+ hubUnreachable: true,
131
+ retryAfterMs: waitMs,
132
+ };
133
+ }
134
+
73
135
  const delivered = this.store.list({
74
136
  type: '%',
75
137
  direction: 'inbound',
@@ -97,13 +159,29 @@ class InboundSync {
97
159
  body: JSON.stringify({ sender_id: senderId, message_ids: delivered.map(m => m.id) }),
98
160
  signal: AbortSignal.timeout(10_000),
99
161
  });
100
- try {
101
- if (res && res.body && typeof res.body.cancel === 'function') {
102
- await res.body.cancel().catch(() => {});
103
- }
104
- } catch (_) { /* never escape the drain helper */ }
162
+ await throwIfHubUnreachableResponse(res, 'inbound ack');
163
+ this._recordHubReachable();
164
+ if (res.status === 403 || res.status === 401) {
165
+ const errText = await readHubResponseText(res).catch(() => 'unknown');
166
+ throw new AuthError(`Hub ${res.status}: ${errText}`, res.status);
167
+ }
168
+ if (!res.ok) {
169
+ const errText = await readHubResponseText(res).catch(() => 'unknown');
170
+ throw new Error(`Hub returned ${res.status}: ${errText}`);
171
+ }
172
+ await drainHubResponse(res);
105
173
  return { acked: delivered.length };
106
174
  } catch (err) {
175
+ if (err instanceof AuthError) throw err;
176
+ if (isHubUnreachableError(err)) {
177
+ const retryAfterMs = this._recordHubUnreachable(err);
178
+ return {
179
+ acked: 0,
180
+ error: 'hub_unreachable',
181
+ hubUnreachable: true,
182
+ retryAfterMs,
183
+ };
184
+ }
107
185
  this.logger.error(`[inbound] ack failed: ${err.message}`);
108
186
  return { acked: 0, error: err.message };
109
187
  }
@@ -3,7 +3,14 @@
3
3
  const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
4
4
  const { AuthError } = require('../lifecycle/manager');
5
5
  const { isProxyTraceUploadPayloadAllowed, resolveTraceMode } = require('../trace/extractor');
6
- const { hubFetch } = require('../../gep/hubFetch');
6
+ const {
7
+ hubFetch,
8
+ hubUnreachableBackoffMs,
9
+ isHubUnreachableError,
10
+ readHubResponseJson,
11
+ readHubResponseText,
12
+ throwIfHubUnreachableResponse,
13
+ } = require('../../gep/hubFetch');
7
14
 
8
15
  const MAX_BATCH = 50;
9
16
  const MAX_RETRIES = 10;
@@ -14,9 +21,41 @@ class OutboundSync {
14
21
  this.hubUrl = hubUrl;
15
22
  this.logger = logger || console;
16
23
  this.getHeaders = getHeaders;
24
+ this._hubUnreachableFailures = 0;
25
+ this._hubUnreachableUntil = 0;
26
+ }
27
+
28
+ _hubUnreachableWaitMs() {
29
+ return Math.max(0, this._hubUnreachableUntil - Date.now());
30
+ }
31
+
32
+ _recordHubReachable() {
33
+ this._hubUnreachableFailures = 0;
34
+ this._hubUnreachableUntil = 0;
35
+ }
36
+
37
+ _recordHubUnreachable(err) {
38
+ this._hubUnreachableFailures += 1;
39
+ const retryAfterMs = hubUnreachableBackoffMs(this._hubUnreachableFailures);
40
+ this._hubUnreachableUntil = Date.now() + retryAfterMs;
41
+ this.logger.warn?.(
42
+ `[outbound] Hub unreachable; backing off for ${Math.ceil(retryAfterMs / 1000)}s: ` +
43
+ `${err && err.message || err}`
44
+ );
45
+ return retryAfterMs;
17
46
  }
18
47
 
19
48
  async flush(channel = 'evomap-hub') {
49
+ const waitMs = this._hubUnreachableWaitMs();
50
+ if (waitMs > 0) {
51
+ return {
52
+ sent: 0,
53
+ error: 'hub_unreachable_backoff',
54
+ hubUnreachable: true,
55
+ retryAfterMs: waitMs,
56
+ };
57
+ }
58
+
20
59
  const pendingBatch = this.store.pollOutbound({ channel, limit: MAX_BATCH });
21
60
  if (pendingBatch.length === 0) return { sent: 0 };
22
61
 
@@ -65,17 +104,20 @@ class OutboundSync {
65
104
  signal: AbortSignal.timeout(30_000),
66
105
  });
67
106
 
107
+ await throwIfHubUnreachableResponse(res, 'outbound flush');
108
+ this._recordHubReachable();
109
+
68
110
  if (res.status === 403 || res.status === 401) {
69
- const errText = await res.text().catch(() => 'unknown');
111
+ const errText = await readHubResponseText(res).catch(() => 'unknown');
70
112
  throw new AuthError(`Hub ${res.status}: ${errText}`, res.status);
71
113
  }
72
114
 
73
115
  if (!res.ok) {
74
- const errText = await res.text().catch(() => 'unknown');
116
+ const errText = await readHubResponseText(res).catch(() => 'unknown');
75
117
  throw new Error(`Hub returned ${res.status}: ${errText}`);
76
118
  }
77
119
 
78
- const data = await res.json();
120
+ const data = await readHubResponseJson(res);
79
121
  const results = data.results || [];
80
122
 
81
123
  const updates = [];
@@ -112,6 +154,17 @@ class OutboundSync {
112
154
  return result;
113
155
  } catch (err) {
114
156
  if (err instanceof AuthError) throw err;
157
+ if (isHubUnreachableError(err)) {
158
+ const retryAfterMs = this._recordHubUnreachable(err);
159
+ const result = {
160
+ sent: 0,
161
+ error: 'hub_unreachable',
162
+ hubUnreachable: true,
163
+ retryAfterMs,
164
+ };
165
+ if (dropped > 0) result.dropped = dropped;
166
+ return result;
167
+ }
115
168
  this.logger.error(`[outbound] flush failed: ${err.message}`);
116
169
  for (const m of pending) {
117
170
  this.store.incrementRetry(m.id, err.message);