@getmarrow/install 0.1.64 → 0.1.66

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.
@@ -3,8 +3,90 @@ const fs = require('node:fs');
3
3
  const http = require('node:http');
4
4
  const os = require('node:os');
5
5
  const path = require('node:path');
6
+ const { version: INSTALLER_VERSION } = require('../package.json');
6
7
 
7
8
  const MAX_BODY_BYTES = 64 * 1024;
9
+ const HEARTBEAT_INTERVAL_MS = 30_000;
10
+ const HEARTBEAT_MAX_BACKOFF_MS = 30 * 60_000;
11
+ const HEARTBEAT_CIRCUIT_THRESHOLD = 5;
12
+ const HEARTBEAT_PROBE_MS = 60 * 60_000;
13
+ const CONTROLLER_RESTART = 'npx @getmarrow/install controller stop && npx @getmarrow/install controller ensure';
14
+
15
+ function heartbeatFailure(error) {
16
+ const status = Number.isInteger(error?.status) ? error.status : null;
17
+ const rawCode = error?.details?.code || error?.code;
18
+ const code = typeof rawCode === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(rawCode) ? rawCode : null;
19
+ return {
20
+ status,
21
+ code,
22
+ // A 4xx other than timeout or rate limit repeats until the request or its identity changes.
23
+ permanent: status !== null && status >= 400 && status < 500 && status !== 408 && status !== 429,
24
+ signature: `${status ?? 'network'}:${code ?? ''}`,
25
+ };
26
+ }
27
+
28
+ function heartbeatExactFix({ status, code }) {
29
+ const failure = `HTTP ${status}${code ? ` ${code}` : ''}`;
30
+ if (status === 404) {
31
+ return `Marrow has no registered agent for this controller (${failure}). Create the agent or bind this API key to a registered agent, set MARROW_FLEET_AGENT_ID to its id if the key is not bound, then run ${CONTROLLER_RESTART} to retry now.`;
32
+ }
33
+ if (status === 401 || status === 403) {
34
+ return `Marrow rejected this controller's API key or agent scope (${failure}). Use an active key bound to the registered agent, then run ${CONTROLLER_RESTART} to retry now.`;
35
+ }
36
+ return `Marrow rejected the controller heartbeat (${failure}). Run npx -y @getmarrow/install@latest update, then ${CONTROLLER_RESTART} to retry now.`;
37
+ }
38
+
39
+ // Heartbeats back off exponentially with jitter after any failure. Repeated identical 4xx
40
+ // responses open the circuit: heartbeats then drop to one jittered probe about every hour,
41
+ // so a fleet-wide transient 401/403/404 recovers without restarts. 5xx, 429, and network
42
+ // failures keep retrying at the capped backoff. A notice is returned only when the circuit
43
+ // opens, when its fix changes, and when heartbeats recover.
44
+ function createHeartbeatPolicy({
45
+ intervalMs = HEARTBEAT_INTERVAL_MS,
46
+ maxBackoffMs = HEARTBEAT_MAX_BACKOFF_MS,
47
+ circuitThreshold = HEARTBEAT_CIRCUIT_THRESHOLD,
48
+ probeMs = HEARTBEAT_PROBE_MS,
49
+ random = Math.random,
50
+ } = {}) {
51
+ let consecutive = 0;
52
+ let identical = 0;
53
+ let last = null;
54
+ let openFix = null;
55
+ return {
56
+ get open() { return openFix !== null; },
57
+ success() {
58
+ const recovered = openFix !== null;
59
+ consecutive = 0;
60
+ identical = 0;
61
+ last = null;
62
+ openFix = null;
63
+ return {
64
+ delayMs: intervalMs,
65
+ state: { state: 'ok', failures: 0, exact_fix: null },
66
+ notice: recovered ? 'Marrow controller resumed enforcement heartbeats.' : null,
67
+ };
68
+ },
69
+ failure(error) {
70
+ const failure = heartbeatFailure(error);
71
+ consecutive += 1;
72
+ identical = last?.signature === failure.signature ? identical + 1 : 1;
73
+ last = failure;
74
+ const summary = { status: failure.status, code: failure.code, failures: identical };
75
+ if (openFix !== null || (failure.permanent && identical >= circuitThreshold)) {
76
+ const fix = failure.permanent ? heartbeatExactFix(failure) : openFix;
77
+ const notice = fix === openFix
78
+ ? null
79
+ : `Marrow controller paused enforcement heartbeats and retries about once an hour. ${fix}`;
80
+ openFix = fix;
81
+ const delayMs = Math.round(probeMs * (0.75 + random() * 0.5));
82
+ return { delayMs, state: { state: 'circuit_open', ...summary, retry_in_ms: delayMs, exact_fix: fix }, notice };
83
+ }
84
+ const ceiling = Math.min(maxBackoffMs, intervalMs * 2 ** consecutive);
85
+ const delayMs = Math.round(ceiling / 2 + random() * (ceiling / 2));
86
+ return { delayMs, state: { state: 'backing_off', ...summary, retry_in_ms: delayMs, exact_fix: null }, notice: null };
87
+ },
88
+ };
89
+ }
8
90
 
9
91
  function sidecarStateDir() {
10
92
  return process.env.MARROW_SIDECAR_STATE_DIR || path.join(os.homedir(), '.marrow', 'sidecar');
@@ -142,6 +224,7 @@ async function startGovernanceSidecar(options, handlers) {
142
224
  repaired: [],
143
225
  exact_fix: handlers.maintain ? null : 'Run npx @getmarrow/install --repair in the managed project.',
144
226
  };
227
+ let latestHeartbeat = { state: 'pending', failures: 0, exact_fix: null, checked_at: null };
145
228
 
146
229
  const server = http.createServer(async (req, res) => {
147
230
  try {
@@ -158,7 +241,9 @@ async function startGovernanceSidecar(options, handlers) {
158
241
  instance_id: instanceId,
159
242
  pid: process.pid,
160
243
  started_at: startedAt,
244
+ installer_version: INSTALLER_VERSION,
161
245
  maintenance: latestMaintenance,
246
+ heartbeat: latestHeartbeat,
162
247
  });
163
248
  }
164
249
  if (req.method === 'GET' && url.pathname === '/coverage') {
@@ -194,33 +279,64 @@ async function startGovernanceSidecar(options, handlers) {
194
279
  port: boundPort,
195
280
  token: authToken,
196
281
  started_at: startedAt,
282
+ installer_version: INSTALLER_VERSION,
283
+ ...(process.env.MARROW_CONTROLLER_PROJECT_ROOT
284
+ ? { project_root: path.resolve(process.env.MARROW_CONTROLLER_PROJECT_ROOT) }
285
+ : {}),
197
286
  });
198
287
  } catch (error) {
199
288
  await new Promise((resolve) => server.close(resolve));
200
289
  throw error;
201
290
  }
202
291
 
203
- const heartbeat = async () => {
204
- if (handlers.maintain) {
205
- try {
206
- latestMaintenance = await handlers.maintain();
207
- } catch {
208
- latestMaintenance = {
209
- state: 'attention_required',
210
- checked_at: new Date().toISOString(),
211
- repaired: [],
212
- exact_fix: 'Run npx @getmarrow/install --repair in the managed project.',
213
- };
214
- }
215
- }
292
+ const heartbeatPolicy = createHeartbeatPolicy({
293
+ intervalMs: options.heartbeatIntervalMs,
294
+ probeMs: options.heartbeatProbeMs,
295
+ random: options.heartbeatRandom,
296
+ });
297
+ let nextHeartbeatAt = 0;
298
+ // Maintenance and heartbeat have separate in-flight guards, so a hung heartbeat request
299
+ // never stalls drift repair.
300
+ let maintenanceRunning = false;
301
+ let heartbeatRunning = false;
302
+ const runMaintenance = async () => {
303
+ if (!handlers.maintain || maintenanceRunning) return;
304
+ maintenanceRunning = true;
216
305
  try {
217
- latestCoverage = await handlers.heartbeat({ sidecarInstanceId: instanceId });
306
+ latestMaintenance = await handlers.maintain();
218
307
  } catch {
219
- // Coverage will mark stale heartbeat; never weaken execution policy here.
308
+ latestMaintenance = {
309
+ state: 'attention_required',
310
+ checked_at: new Date().toISOString(),
311
+ repaired: [],
312
+ exact_fix: 'Run npx @getmarrow/install --repair in the managed project.',
313
+ };
314
+ } finally {
315
+ maintenanceRunning = false;
316
+ }
317
+ };
318
+ const runHeartbeat = async () => {
319
+ if (heartbeatRunning || Date.now() < nextHeartbeatAt) return;
320
+ heartbeatRunning = true;
321
+ try {
322
+ let result;
323
+ try {
324
+ latestCoverage = await handlers.heartbeat({ sidecarInstanceId: instanceId });
325
+ result = heartbeatPolicy.success();
326
+ } catch (error) {
327
+ // Coverage will mark a stale heartbeat; never weaken execution policy here.
328
+ result = heartbeatPolicy.failure(error);
329
+ }
330
+ latestHeartbeat = { ...result.state, checked_at: new Date().toISOString() };
331
+ nextHeartbeatAt = Date.now() + result.delayMs;
332
+ if (result.notice) process.stderr.write(`${result.notice}\n`);
333
+ } finally {
334
+ heartbeatRunning = false;
220
335
  }
221
336
  };
222
- await heartbeat();
223
- const timer = setInterval(heartbeat, 30_000);
337
+ const tick = () => Promise.all([runMaintenance(), runHeartbeat()]);
338
+ await tick();
339
+ const timer = setInterval(tick, options.heartbeatIntervalMs || HEARTBEAT_INTERVAL_MS);
224
340
  timer.unref();
225
341
 
226
342
  let closed = false;
@@ -239,4 +355,4 @@ async function startGovernanceSidecar(options, handlers) {
239
355
  return { server, instanceId, port: boundPort, stateFile, close };
240
356
  }
241
357
 
242
- module.exports = { startGovernanceSidecar, sidecarStateDir };
358
+ module.exports = { startGovernanceSidecar, sidecarStateDir, createHeartbeatPolicy };