@bridge4dev/runner 0.53.0 → 0.55.0

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.
@@ -1,9 +1,12 @@
1
1
  import { query, } from '@anthropic-ai/claude-agent-sdk';
2
+ import { spawn } from 'node:child_process';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { AsyncQueue } from '../async-queue.js';
5
6
  import { log } from '../log.js';
6
7
  import { mcpConfigPath } from '../paths.js';
8
+ import { lowerPriority } from '../process-priority.js';
9
+ import { cageSpawn, memoryDeathSentence, releaseSessionScope } from '../session-cage.js';
7
10
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
8
11
  import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
@@ -11,6 +14,8 @@ import { applyUsagePercentages, parseUsageText, probeUsageText } from './claude-
11
14
  import { claudeExecutableOption, sessionClaudePath } from '../agent-binary.js';
12
15
  /** How often `/usage` may be read. Free, but still a process. */
13
16
  const USAGE_PROBE_INTERVAL_MS = 3 * 60 * 1000;
17
+ /** Same 2KB the SDK keeps: enough for the CLI's last words, not a log sink. */
18
+ const STDERR_TAIL_LIMIT = 2048;
14
19
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
15
20
  // Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
16
21
  // 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
@@ -293,6 +298,17 @@ class ClaudeSession {
293
298
  mcpConfigFile = null;
294
299
  /** Set when the write failed and the key went into argv after all. */
295
300
  mcpFallbackNotice = null;
301
+ /**
302
+ * The last few KB the CLI wrote to stderr, kept because we spawn it ourselves.
303
+ *
304
+ * The SDK's own spawn keeps this tail and appends it to the error it throws
305
+ * when the process dies (`. stderr: …`); a custom `spawnClaudeCodeProcess`
306
+ * gets no such service, and that text is load-bearing here — «no conversation
307
+ * found» and «No message found with message.uuid» reach `classifyRunError`
308
+ * exactly that way. Keeping our own copy is what makes the priority hook cost
309
+ * nothing diagnostically.
310
+ */
311
+ stderrTail = '';
296
312
  /** Guards against overlapping capability probes. */
297
313
  capabilitiesInFlight = false;
298
314
  /**
@@ -508,6 +524,12 @@ class ClaudeSession {
508
524
  // bundled binary, exactly as before. After C3 this pins the system
509
525
  // `claude`, which is the file the card measures and the button installs.
510
526
  ...claudeExecutableOption(),
527
+ // Stage 1a of the host-resources plan: the CLI, and therefore everything
528
+ // it starts, is spawned at nice 10 so the daemon that supervises it keeps
529
+ // the processor. The SDK has no post-spawn hook — replacing the spawn is
530
+ // the only way in, which is why `spawnAgentProcess` has to re-create the
531
+ // one thing the default spawn did for us (the stderr tail).
532
+ spawnClaudeCodeProcess: (spawnOptions) => this.spawnAgentProcess(spawnOptions),
511
533
  /**
512
534
  * Everything the machine's own Claude has (owner's call, 2026-07-30).
513
535
  *
@@ -623,6 +645,75 @@ class ClaudeSession {
623
645
  // model's window) from the moment the process is up.
624
646
  this.refreshContextUsage();
625
647
  }
648
+ /**
649
+ * Start the Claude CLI ourselves, one notch below the daemon.
650
+ *
651
+ * The SDK offers no «after spawn» callback, so the only place to renice the
652
+ * process is a spawn we own. Two things the default spawn did have to be done
653
+ * here, and neither is optional:
654
+ *
655
+ * - **stderr must be read.** With `stdio: 'pipe'` and nobody draining it, the
656
+ * CLI blocks on a full pipe after ~64KB — a session that hangs for no
657
+ * visible reason. That is a worse outcome than anything nice(2) can buy.
658
+ * - **the tail must be kept**, because the SDK appends it to the error it
659
+ * throws on process death and `classifyRunError` reads that text. Without
660
+ * it, «the resume id is unknown» comes back as a bare exit code and the
661
+ * supervisor's recovery never fires.
662
+ *
663
+ * `signal` is the SDK's own forwarded abort, not the caller's: it fires only
664
+ * after stdin EOF and the ~2s grace, so handing it to `spawn()` kills a CLI
665
+ * that already had its chance to shut down cleanly.
666
+ */
667
+ spawnAgentProcess(options) {
668
+ // The same hook carries stage 2: the CLI, and everything it starts, goes
669
+ // into this session's own cgroup with its own memory ceiling. On a machine
670
+ // where the cage was not proved to work `cageSpawn` hands the command back
671
+ // untouched, and the renice below is the whole of the containment — which
672
+ // is exactly the state of every machine before this release.
673
+ const caged = cageSpawn({
674
+ id: this.spec.sessionId,
675
+ command: options.command,
676
+ args: options.args,
677
+ });
678
+ const child = spawn(caged.command, caged.args, {
679
+ cwd: options.cwd,
680
+ env: { ...options.env, ...caged.env },
681
+ stdio: ['pipe', 'pipe', 'pipe'],
682
+ signal: options.signal,
683
+ windowsHide: true,
684
+ });
685
+ // `systemd-run --scope` execs into the same pid and nice survives `exec`,
686
+ // so this still lands on the CLI itself.
687
+ lowerPriority(child.pid);
688
+ // Read `Result` and clear the unit once the process is gone. Only an
689
+ // `exit` listener: stdout belongs to the SDK, and attaching a reader to it
690
+ // here would put the stream in flowing mode and steal the conversation.
691
+ child.on('exit', () => {
692
+ void releaseSessionScope(caged.unit, this.spec.sessionId);
693
+ });
694
+ // `setEncoding` puts a StringDecoder on the stream, so a multi-byte
695
+ // character split across two reads survives — the same reason `verify.ts`
696
+ // decodes rather than `toString`s.
697
+ child.stderr.setEncoding('utf8');
698
+ child.stderr.on('data', (chunk) => this.appendStderr(chunk));
699
+ // A read error on stderr is not a session error: the process itself is
700
+ // still on stdout, which is where the conversation lives.
701
+ child.stderr.on('error', (error) => {
702
+ log.debug('claude: stderr read failed', { error: String(error) });
703
+ });
704
+ return child;
705
+ }
706
+ appendStderr(chunk) {
707
+ if (!chunk)
708
+ return;
709
+ const next = this.stderrTail + chunk;
710
+ this.stderrTail = next.length > STDERR_TAIL_LIMIT ? next.slice(-STDERR_TAIL_LIMIT) : next;
711
+ }
712
+ /** What the CLI said on its way out, in the SDK's own wording. */
713
+ stderrSuffix() {
714
+ const tail = this.stderrTail.trim();
715
+ return tail ? `. stderr: ${tail}` : '';
716
+ }
626
717
  /**
627
718
  * Write this session's MCP config to a 0600 file and return its path, or null
628
719
  * if anything went wrong (the caller then keeps the old inline shape).
@@ -2347,11 +2438,23 @@ class ClaudeSession {
2347
2438
  }
2348
2439
  }
2349
2440
  catch (error) {
2350
- const message = maskString(String(error instanceof Error ? error.message : error));
2441
+ // The stderr tail rides along because we spawn the CLI ourselves now (see
2442
+ // `spawnAgentProcess`) — the SDK used to add it and cannot any more. This
2443
+ // catch IS the process falling over, which is the only case the SDK
2444
+ // appended it to as well.
2445
+ const message = maskString(String(error instanceof Error ? error.message : error) + this.stderrSuffix());
2351
2446
  const code = errorCode(message);
2447
+ // #387: «exited with code 143» and «terminated by signal SIGKILL» were the
2448
+ // two faces of one cause — the kernel's OOM killer inside this session's
2449
+ // cgroup — and neither said so. The cage remembers the kill counter at
2450
+ // exit; when it moved, the sentence about memory rides on the error.
2451
+ // Awaited, not read: on a systemd that stops the whole scope the cgroup
2452
+ // is already gone when the process's `exit` fires, and the only surviving
2453
+ // record is `Result=oom-kill`, which the release is still fetching.
2454
+ const memory = await memoryDeathSentence(this.spec.sessionId);
2352
2455
  this.emit({
2353
2456
  type: 'error',
2354
- message: classifyRunError(message),
2457
+ message: memory ? `${classifyRunError(message)} ${memory}` : classifyRunError(message),
2355
2458
  ...(code ? { code } : {}),
2356
2459
  // #373: this catch is the CLI process falling over under the read loop —
2357
2460
  // the only place in this adapter where that is what happened. Our own
@@ -30,6 +30,13 @@ export interface AppServerOptions {
30
30
  args: string[];
31
31
  cwd?: string;
32
32
  env: Record<string, string>;
33
+ /**
34
+ * Whose session this app-server belongs to — the name of its cgroup cage
35
+ * (`session-cage.ts`). Required rather than optional: a spawn with no id
36
+ * cannot be caged, and a session running outside the cage while everything
37
+ * reports that the cage is on is the one failure this feature must not have.
38
+ */
39
+ sessionId: string;
33
40
  onNotification: (method: string, params: Record<string, unknown>) => void;
34
41
  onServerRequest: (request: ServerRequest) => void;
35
42
  /** Fired once when the child is gone — the session's end-of-stream signal. */
@@ -47,6 +54,10 @@ export declare class AppServerClient {
47
54
  private nextId;
48
55
  private stdoutBuffer;
49
56
  private exited;
57
+ /** The scope this process runs in, or null when the machine has no cage. */
58
+ private readonly scopeUnit;
59
+ /** Has the far end said anything at all? Reads the start window — see below. */
60
+ private sawOutput;
50
61
  constructor(opts: AppServerOptions);
51
62
  get pid(): number | undefined;
52
63
  get alive(): boolean;
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { log } from '../log.js';
3
+ import { lowerPriority } from '../process-priority.js';
4
+ import { cageSpawn, killedBeforeExec, releaseSessionScope } from '../session-cage.js';
3
5
  export class RpcError extends Error {
4
6
  code;
5
7
  method;
@@ -36,15 +38,32 @@ export class AppServerClient {
36
38
  nextId = 1;
37
39
  stdoutBuffer = '';
38
40
  exited = false;
41
+ /** The scope this process runs in, or null when the machine has no cage. */
42
+ scopeUnit;
43
+ /** Has the far end said anything at all? Reads the start window — see below. */
44
+ sawOutput = false;
39
45
  constructor(opts) {
40
46
  this.opts = opts;
41
- this.child = spawn(opts.command, opts.args, {
47
+ // `codex app-server` is the root of everything this session will run, so
48
+ // this is where the cage goes: the scope holds the whole tree below it, and
49
+ // the ceiling is per session rather than per machine.
50
+ const caged = cageSpawn({ id: opts.sessionId, command: opts.command, args: opts.args });
51
+ this.scopeUnit = caged.unit;
52
+ this.child = spawn(caged.command, caged.args, {
42
53
  ...(opts.cwd ? { cwd: opts.cwd } : {}),
43
- env: opts.env,
54
+ env: { ...opts.env, ...caged.env },
44
55
  stdio: ['pipe', 'pipe', 'pipe'],
45
56
  });
57
+ // Before a single byte of protocol, and still worth doing inside a scope:
58
+ // nice is inherited at fork AND across `exec`, so renicing `systemd-run`
59
+ // renices the app-server it turns into. On a machine with no cage this is
60
+ // the only containment there is.
61
+ lowerPriority(this.child.pid);
46
62
  this.child.stdout.setEncoding('utf8');
47
- this.child.stdout.on('data', (chunk) => this.onStdout(chunk));
63
+ this.child.stdout.on('data', (chunk) => {
64
+ this.sawOutput = true;
65
+ this.onStdout(chunk);
66
+ });
48
67
  this.child.stderr.setEncoding('utf8');
49
68
  this.child.stderr.on('data', (chunk) => opts.onStderr?.(chunk));
50
69
  this.child.on('error', (error) => {
@@ -67,6 +86,25 @@ export class AppServerClient {
67
86
  if (this.exited)
68
87
  return;
69
88
  this.exited = true;
89
+ if (killedBeforeExec({
90
+ code: info.code,
91
+ signal: info.signal,
92
+ sawOutput: this.sawOutput,
93
+ caged: this.scopeUnit !== null,
94
+ })) {
95
+ // Not a silent death of the agent. Starting a scope takes 0.07–2.5 s (up
96
+ // to 2741 ms on a loaded machine), and a stop inside that window signals
97
+ // `systemd-run` before it has exec'd — so the process that got SIGTERM
98
+ // was the wrapper, and `codex app-server` never existed. Said plainly
99
+ // here, because from the outside it is indistinguishable from a crash
100
+ // with an empty stdout.
101
+ log.warn('codex: the session was stopped before its process started', {
102
+ unit: this.scopeUnit,
103
+ });
104
+ }
105
+ // Read the cause, THEN let systemd forget the unit: `Result=oom-kill` is
106
+ // only readable while the failed scope is still there (`session-cage.ts`).
107
+ void releaseSessionScope(this.scopeUnit, this.opts.sessionId);
70
108
  this.opts.onExit(info);
71
109
  }
72
110
  failAll(error) {
@@ -1,6 +1,7 @@
1
1
  import { AsyncQueue } from '../async-queue.js';
2
2
  import { log } from '../log.js';
3
3
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
4
+ import { memoryDeathSentence } from '../session-cage.js';
4
5
  import { RUNNER_VERSION } from '../version.js';
5
6
  import { repairCodexAuth } from './codex-home.js';
6
7
  import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
@@ -220,6 +221,10 @@ class CodexSession {
220
221
  command: CODEX_BIN,
221
222
  args: ['app-server'],
222
223
  cwd: spec.cwd,
224
+ // Names this session's cgroup cage (`session-cage.ts`). Not part of
225
+ // `wiring`: an injected client is a scripted object with no process
226
+ // under it, and there is nothing to cage.
227
+ sessionId: spec.sessionId,
223
228
  ...wiring,
224
229
  });
225
230
  if (this.modeRefusedAtLaunch) {
@@ -1862,8 +1867,36 @@ class CodexSession {
1862
1867
  processGone: true,
1863
1868
  });
1864
1869
  }
1870
+ if (!this.stopped)
1871
+ this.sayIfMemoryKilledIt();
1865
1872
  this.finish();
1866
1873
  }
1874
+ /**
1875
+ * «codex app-server exited (code null)» is the same sentence whether the CLI
1876
+ * crashed or the kernel took it for memory, and #387 is the ticket about
1877
+ * exactly that silence. The cage knows which it was; here it reaches the
1878
+ * person.
1879
+ *
1880
+ * A follow-up `notice` rather than a longer error message, because both death
1881
+ * paths in this adapter are synchronous by design (one is a notification
1882
+ * handler, the other a catch that closes the output queue on the next line),
1883
+ * and the verdict needs an await — `Result=oom-kill` is read after the exit.
1884
+ */
1885
+ sayIfMemoryKilledIt() {
1886
+ if (this.memoryVerdictAsked)
1887
+ return;
1888
+ this.memoryVerdictAsked = true;
1889
+ void memoryDeathSentence(this.spec.sessionId)
1890
+ .then((sentence) => {
1891
+ if (sentence)
1892
+ this.emit({ type: 'notice', level: 'warn', text: sentence });
1893
+ })
1894
+ .catch(() => {
1895
+ // A verdict we could not fetch is no verdict; the error text stands.
1896
+ });
1897
+ }
1898
+ /** One verdict per process — both death paths can fire for the same exit. */
1899
+ memoryVerdictAsked = false;
1867
1900
  // ─── Capabilities ──────────────────────────────────────────────────
1868
1901
  refreshCapabilities() {
1869
1902
  if (this.capabilitiesInFlight || this.stopped)
@@ -2116,6 +2149,10 @@ class CodexSession {
2116
2149
  return code;
2117
2150
  }
2118
2151
  this.emit({ type: 'error', message: `Codex session error: ${message.slice(0, 1_000)}` });
2152
+ // The generic door: a pending request rejected because the app-server died
2153
+ // (`failAll`). If the kernel is why it died, say so (#387).
2154
+ if (/app-server exited|SIGKILL/i.test(message))
2155
+ this.sayIfMemoryKilledIt();
2119
2156
  return null;
2120
2157
  }
2121
2158
  /**
@@ -0,0 +1,156 @@
1
+ /**
2
+ * What this machine can say about its own load, read straight from `/proc`.
3
+ *
4
+ * Until this existed, an overloaded dev server was visible in exactly one
5
+ * place: `sar` on the machine itself. The product could not say «this machine
6
+ * is on its knees», so it said nothing, and the first symptom a user saw was a
7
+ * session that had gone quiet (plan `agent-sessions-host-resources.md` §5.3).
8
+ *
9
+ * Two files and nothing else. That is a security property, not an
10
+ * implementation detail: this runner reports to a server, so what it may read
11
+ * for telemetry is listed here in full — `/proc/loadavg` and `/proc/meminfo`,
12
+ * both world-readable, neither containing a path, a command line, an
13
+ * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
14
+ *
15
+ * Mirror of `packages/shared/src/schemas/host-load.ts` and the `HOST_LOAD_*`
16
+ * block of `packages/shared/src/constants/runner.ts` — the DevBridge side is
17
+ * the source of truth, exactly like `levels.ts` mirrors the level thresholds
18
+ * and `protocol.ts` mirrors the API's wire types. Copied rather than imported
19
+ * on purpose: this package is published to npm on its own and installed by
20
+ * users who have no DevBridge workspace, so a `@devbridge/shared` import would
21
+ * make the published tarball unresolvable (see `recipe-schema.ts`).
22
+ *
23
+ * CHECKED: `host-load.test.ts` reads the shared file and compares every number
24
+ * below against it. A threshold edited on one side only would make the runner
25
+ * send frames the API throws away — or go silent while a card waits for one.
26
+ */
27
+ /**
28
+ * How often the runner LOOKS at `/proc`. Not how often it sends.
29
+ *
30
+ * Fine enough that a card is never more than half a minute stale, coarse
31
+ * enough that reading two small files costs nothing measurable.
32
+ */
33
+ export declare const HOST_LOAD_SAMPLE_INTERVAL_MS = 30000;
34
+ /**
35
+ * The heartbeat: an unchanged machine still reports this often.
36
+ *
37
+ * Without it a quiet runner would go silent and the API's key would expire,
38
+ * turning «nothing is happening» into «we have no idea» on the card. With it,
39
+ * silence really does mean the runner stopped talking.
40
+ *
41
+ * Bound to the API's `HOST_LOAD_TTL_MS` (120 s), and the order is the design:
42
+ * a heartbeat LONGER than the TTL means a healthy quiet machine shows «no
43
+ * data» for the gap between them. The first draft had 5 minutes against a
44
+ * 2-minute TTL — three silent minutes out of every five.
45
+ */
46
+ export declare const HOST_LOAD_HEARTBEAT_MS = 60000;
47
+ /**
48
+ * How much load1 must move before an otherwise unchanged sample earns a frame.
49
+ *
50
+ * Same principle as `publishSlots`: a tick that would repeat what the server
51
+ * already knows is not sent at all. 0.5 is below the resolution at which a
52
+ * person reads the number and well above the noise of an idle machine.
53
+ */
54
+ export declare const HOST_LOAD_MIN_DELTA_LOAD1 = 0.5;
55
+ /** The same idea for memory, as a fraction — see `hostLoadChangedEnough`. */
56
+ export declare const HOST_LOAD_MIN_DELTA_MEM_RATIO = 0.05;
57
+ /**
58
+ * One measurement of this machine. Only the moving parts.
59
+ *
60
+ * `machine: {cpuCount, memTotalBytes, memAvailableBytes}` already travels in
61
+ * `hello` (`index.ts`), so nothing static is repeated here. `cpuCount` is the
62
+ * exception and it earns its place: load1 is meaningless without it, and a
63
+ * consumer that had to join two sources to answer «is 12.4 a lot?» would
64
+ * eventually paint one machine's load against another's core count.
65
+ */
66
+ export interface HostLoadFrame {
67
+ /** `/proc/loadavg`, the three windows as the kernel reports them. */
68
+ load1: number;
69
+ load5: number;
70
+ load15: number;
71
+ /** Processors, so load1 can be read as a ratio without a second lookup. */
72
+ cpuCount: number;
73
+ /**
74
+ * `MemAvailable`, not `MemFree` — the kernel's own estimate of what a new
75
+ * allocation could actually get. `MemFree` on a healthy Linux box is near
76
+ * zero by design (page cache), and a card drawn from it would cry wolf on
77
+ * every machine, every minute.
78
+ */
79
+ memAvailableBytes: number;
80
+ /** Zero on a machine with no swap; consumers must not divide blindly. */
81
+ swapTotalBytes: number;
82
+ swapFreeBytes: number;
83
+ /**
84
+ * When THIS MACHINE measured it, ISO.
85
+ *
86
+ * Load-bearing, same as in `agent_versions`: frames without a session id take
87
+ * the gateway's unordered fast path, so two of these can be handled out of
88
+ * order. The card dates the number by this field, never by arrival.
89
+ */
90
+ at: string;
91
+ }
92
+ /** Test seams. Production calls `readHostLoad()` with nothing. */
93
+ export interface HostLoadSources {
94
+ /**
95
+ * Where `/proc` is mounted. A seam so the parser can be driven by fixtures:
96
+ * a test that had to arrange the real kernel into a state would not be
97
+ * written, and the states worth covering (no swap, no `MemAvailable`, no
98
+ * file at all) cannot be arranged at all.
99
+ */
100
+ procDir?: string;
101
+ /** Processors. Seam for the same reason — a test cannot change its own. */
102
+ cpuCount?: number;
103
+ /** The clock behind `at`. */
104
+ now?: () => Date;
105
+ }
106
+ /**
107
+ * Measure this machine, or say honestly that we cannot.
108
+ *
109
+ * `null` is a first-class answer and every caller must treat it as «send
110
+ * nothing». There is deliberately no `process.platform` check in front of it:
111
+ * the absence of the files IS the check, and it is the wider one — it also
112
+ * covers a Linux container with `/proc` masked or mounted `hidepid`, which a
113
+ * platform test would sail straight past into an exception. The runner is
114
+ * Linux-only by its installer, but «only runs on Linux» must never mean
115
+ * «crashes anywhere else»: this is called from a timer, and an exception here
116
+ * would take the whole session supervisor with it.
117
+ */
118
+ export declare function readHostLoad(sources?: HostLoadSources): HostLoadFrame | null;
119
+ /**
120
+ * Is the heartbeat due, given how long ago the last frame actually went out?
121
+ *
122
+ * The second half of the answer is the whole reason this is a function: a
123
+ * backwards clock step makes «how long ago» NEGATIVE, and a machine whose load
124
+ * never moves would then say nothing for the entire offset — an hour of silence
125
+ * on a machine that may be on fire. A clock correction is due, not early.
126
+ *
127
+ * The API makes the same allowance for the same step, and has to: it drops
128
+ * frames older than the one it holds unless the step is larger than a
129
+ * measurement's lifetime (`HOST_LOAD_TTL_MS`, `runner-gateway.ts`). Half of this
130
+ * fix on one side of the socket is no fix at all.
131
+ */
132
+ export declare function hostLoadHeartbeatDue(sinceLastSentMs: number, heartbeatMs: number): boolean;
133
+ /**
134
+ * Is this sample worth a frame at all? The «a quiet runner says nothing» rule.
135
+ *
136
+ * Same shape as `publishSlots`: what the server already knows is not repeated.
137
+ * The alternative is 2 880 identical frames a day per machine, each one waking
138
+ * the gateway, a Redis write and every open dashboard socket.
139
+ *
140
+ * The memory threshold is 5 % of the machine's TOTAL memory, not 5 % of what
141
+ * is currently available. Two reasons, and both were the deciding one:
142
+ *
143
+ * - a fraction of `memAvailable` shrinks as the machine fills, so the frames
144
+ * would get chattiest exactly when the machine is least able to afford it —
145
+ * 5 % of the last 200 MB is a 10 MB trigger;
146
+ * - `memTotal` is fixed per machine, so «the card moves when a GB moves» reads
147
+ * the same on every server instead of meaning something different on each.
148
+ *
149
+ * Swap counts too, though the plan names only load and memory. It is one of the
150
+ * three verdicts behind «overloaded», and it is the only one with no other
151
+ * trigger: a machine that starts paging with its load and its `MemAvailable`
152
+ * both flat would otherwise wait up to five minutes for the heartbeat to say
153
+ * so, and paging is the state whose wall-clock cost is unbounded.
154
+ */
155
+ export declare function hostLoadChangedEnough(previous: HostLoadFrame | null, next: HostLoadFrame, memTotalBytes?: number): boolean;
156
+ //# sourceMappingURL=host-load.d.ts.map