@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.
- package/dist/adapters/claude.js +105 -2
- package/dist/adapters/codex-protocol.d.ts +11 -0
- package/dist/adapters/codex-protocol.js +41 -3
- package/dist/adapters/codex.js +37 -0
- package/dist/host-load.d.ts +156 -0
- package/dist/host-load.js +223 -0
- package/dist/index.js +211 -40
- package/dist/process-priority.d.ts +55 -0
- package/dist/process-priority.js +99 -0
- package/dist/protocol.d.ts +25 -3
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/self-update.js +43 -2
- package/dist/service-unit.d.ts +268 -10
- package/dist/service-unit.js +432 -53
- package/dist/session-cage.d.ts +508 -0
- package/dist/session-cage.js +1183 -0
- package/dist/supervisor.d.ts +106 -0
- package/dist/supervisor.js +243 -0
- package/dist/systemd-memory.d.ts +35 -0
- package/dist/systemd-memory.js +115 -0
- package/dist/verify.js +28 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) =>
|
|
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) {
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|