@bridge4dev/runner 0.37.0 → 0.39.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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The platform packages the SDK will look for, in its own order (`sdk.mjs`).
3
+ *
4
+ * BOTH linux variants are accepted rather than detecting musl the way the SDK
5
+ * does: the question here is «did the optional package get installed at all»,
6
+ * and answering it must never be the thing that blocks an update. A glibc
7
+ * binary on a musl host is a different failure — and since #225 it announces
8
+ * itself in the session feed instead of hiding.
9
+ */
10
+ export declare function nativeCandidates(platform?: NodeJS.Platform, arch?: string): string[];
11
+ /**
12
+ * Absolute path to the Claude CLI inside an installed runner package, or null.
13
+ *
14
+ * Resolved from the SDK's own file, exactly like the SDK resolves it — npm may
15
+ * hoist the platform package next to the SDK, next to the runner, or several
16
+ * levels up, and only the module resolver knows which happened here.
17
+ */
18
+ export declare function findClaudeCli(packageDir: string): string | null;
19
+ /**
20
+ * The same question about THIS process's own installation.
21
+ *
22
+ * Goes through the SDK's entry point rather than a path guess, so it answers
23
+ * correctly in every layout the runner runs in — a global npm install, a
24
+ * dedicated-user prefix, and the pnpm store of a source checkout, where the
25
+ * platform package is reachable from the SDK and from nowhere else.
26
+ */
27
+ export declare function claudeCliPath(): string | null;
28
+ //# sourceMappingURL=agent-binary.d.ts.map
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ /**
5
+ * Is the binary this runner would actually launch Claude with present?
6
+ *
7
+ * Not a hypothetical check (ticket #225, гоча #297). The Claude CLI does not
8
+ * live in this package: `@anthropic-ai/claude-agent-sdk` keeps it in a ~300 MB
9
+ * **optional** platform package, one per platform+arch. For npm the failure of
10
+ * an optional dependency is not an error — `npm install -g` exits 0, the runner
11
+ * reports a successful update and restarts into a build that cannot start a
12
+ * single Claude session. That is exactly what happened to transitway-dev-01 on
13
+ * 2026-08-11: every launch threw
14
+ * `Native CLI binary for linux-x64 not found` synchronously, before any event
15
+ * could be emitted, and a live session went silent for an hour and a half.
16
+ *
17
+ * The runner's own smoke test could not catch it: `devbridge-runner --version`
18
+ * loads the module graph, and the SDK resolves the platform binary lazily — on
19
+ * the first `query()`, which is a session, not a startup.
20
+ */
21
+ const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk';
22
+ /**
23
+ * The platform packages the SDK will look for, in its own order (`sdk.mjs`).
24
+ *
25
+ * BOTH linux variants are accepted rather than detecting musl the way the SDK
26
+ * does: the question here is «did the optional package get installed at all»,
27
+ * and answering it must never be the thing that blocks an update. A glibc
28
+ * binary on a musl host is a different failure — and since #225 it announces
29
+ * itself in the session feed instead of hiding.
30
+ */
31
+ export function nativeCandidates(platform = process.platform, arch = process.arch) {
32
+ const exe = platform === 'win32' ? 'claude.exe' : 'claude';
33
+ const packages = platform === 'android'
34
+ ? [`${SDK_PACKAGE}-linux-${arch}-android`]
35
+ : platform === 'linux'
36
+ ? [`${SDK_PACKAGE}-linux-${arch}`, `${SDK_PACKAGE}-linux-${arch}-musl`]
37
+ : [`${SDK_PACKAGE}-${platform}-${arch}`];
38
+ return packages.map((name) => `${name}/${exe}`);
39
+ }
40
+ /**
41
+ * Absolute path to the Claude CLI inside an installed runner package, or null.
42
+ *
43
+ * Resolved from the SDK's own file, exactly like the SDK resolves it — npm may
44
+ * hoist the platform package next to the SDK, next to the runner, or several
45
+ * levels up, and only the module resolver knows which happened here.
46
+ */
47
+ export function findClaudeCli(packageDir) {
48
+ const found = resolveFromSdkEntry(path.join(packageDir, 'node_modules', SDK_PACKAGE, 'sdk.mjs'));
49
+ // Inside THIS installation, or it does not count. Node's resolver walks up the
50
+ // directory tree and consults the global folders, so a package that is absent
51
+ // from the build we just installed can still be found in the one we are about
52
+ // to retire — and answering «present» from there is exactly the false green
53
+ // this check exists to prevent. A global npm install keeps its dependencies
54
+ // under its own package directory, so containment is also simply true.
55
+ return found && isInside(packageDir, found) ? found : null;
56
+ }
57
+ function isInside(dir, file) {
58
+ const relative = path.relative(path.resolve(dir), path.resolve(file));
59
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
60
+ }
61
+ /**
62
+ * The same question about THIS process's own installation.
63
+ *
64
+ * Goes through the SDK's entry point rather than a path guess, so it answers
65
+ * correctly in every layout the runner runs in — a global npm install, a
66
+ * dedicated-user prefix, and the pnpm store of a source checkout, where the
67
+ * platform package is reachable from the SDK and from nowhere else.
68
+ */
69
+ export function claudeCliPath() {
70
+ let sdkEntry;
71
+ try {
72
+ sdkEntry = createRequire(import.meta.url).resolve(SDK_PACKAGE);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ return resolveFromSdkEntry(sdkEntry);
78
+ }
79
+ function resolveFromSdkEntry(sdkEntry) {
80
+ let resolve;
81
+ try {
82
+ resolve = createRequire(sdkEntry).resolve;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ for (const candidate of nativeCandidates()) {
88
+ try {
89
+ const resolved = resolve(candidate);
90
+ if (fs.existsSync(resolved))
91
+ return resolved;
92
+ }
93
+ catch {
94
+ // Not installed under this name — try the next candidate.
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ //# sourceMappingURL=agent-binary.js.map
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { ClaudeAdapter } from './adapters/claude.js';
9
9
  import { CodexAdapter } from './adapters/codex.js';
10
10
  import { ensureCodexHome } from './adapters/codex-home.js';
11
+ import { claudeCliPath } from './agent-binary.js';
11
12
  import { loadConfig, requireConfig, saveConfig } from './config.js';
12
13
  import { log } from './log.js';
13
14
  import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
@@ -16,7 +17,7 @@ import { Supervisor } from './supervisor.js';
16
17
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
17
18
  import { RunnerWsClient } from './ws-client.js';
18
19
  import { RUNNER_VERSION } from './version.js';
19
- import { buildUnit, cpuQuotaPercent, limitsOverrideIsOutdated, limitsOverridePath, unitExecTarget, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
20
+ import { buildUnit, cpuQuotaPercent, limitsOverrideIsOutdated, limitsOverridePath, memoryPolicy, readMemoryFacts, unitExecTarget, unitPath, writeLimitsOverride, LIMITS_VERSION, } from './service-unit.js';
20
21
  import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
21
22
  import { agentAuthStatuses } from './auth-relay.js';
22
23
  import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, systemdUserEnv, } from './environment.js';
@@ -62,8 +63,14 @@ function argValue(args, flag) {
62
63
  */
63
64
  function installedAgents() {
64
65
  const agents = [];
65
- // Claude is bundled inside the Agent SDK, so it is always available.
66
- agents.push('claude');
66
+ // Claude comes with the Agent SDK — but «comes with» is a claim about THIS
67
+ // installation, not a law (ticket #225). The CLI is an optional platform
68
+ // package, and an update that silently lost it leaves a runner that reports
69
+ // Claude, accepts Claude sessions, and cannot start a single one. Reported as
70
+ // measured, so the dashboard greys the agent out instead of offering a
71
+ // session that dies before its first word.
72
+ if (claudeCliPath())
73
+ agents.push('claude');
67
74
  if (hasExecutable('codex'))
68
75
  agents.push('codex');
69
76
  return agents;
@@ -352,7 +359,9 @@ function runnerCapabilities(apiUrlOverride) {
352
359
  memTotalBytes: os.totalmem(),
353
360
  memAvailableBytes: os.freemem(),
354
361
  limitsVersion: LIMITS_VERSION,
355
- limitsCurrent: !limitsOverrideIsOutdated(),
362
+ // Facts passed on purpose: since 0.39.0 «current» also means the measured
363
+ // ceiling still fits this machine, not just that the version matches.
364
+ limitsCurrent: !limitsOverrideIsOutdated(undefined, undefined, readMemoryFacts()),
356
365
  },
357
366
  /**
358
367
  * Identity of THIS process, so the API can tell a network blink from a
@@ -490,20 +499,32 @@ const RESTART_DELAY_MS = 1_500;
490
499
  */
491
500
  async function repairResourceLimits() {
492
501
  try {
493
- if (!writeLimitsOverride())
502
+ const facts = readMemoryFacts();
503
+ if (facts && memoryPolicy(facts).starved) {
504
+ // The only signal the machine's owner will ever get that the neighbours
505
+ // have taken the box: the honest headroom was below the 2 GB floor, so the
506
+ // ceiling we are about to write sits above what is actually free.
507
+ log.warn('daemon: not enough free memory for a real ceiling — neighbours have the machine', {
508
+ availableMB: Math.round(facts.availableBytes / 1048576),
509
+ totalMB: Math.round(facts.totalBytes / 1048576),
510
+ });
511
+ }
512
+ if (!writeLimitsOverride(false, undefined, facts))
494
513
  return;
495
514
  log.warn('daemon: resource limits drop-in written — reloading systemd', {
496
515
  path: limitsOverridePath(),
497
516
  version: LIMITS_VERSION,
517
+ ...(facts ? memoryPolicy(facts) : {}),
498
518
  });
499
519
  await execFileAsync('systemctl', ['--user', 'daemon-reload'], {
500
520
  timeout: 15_000,
501
521
  env: systemdUserEnv(),
502
522
  });
503
523
  // Deliberately no restart: `daemon-reload` alone is enough for these
504
- // directives (verified live — OOMPolicy went stop→continue and MemoryMax
505
- // 2G→infinity with the PID unchanged), and restarting here would park every
506
- // live session to apply a setting that is already in force.
524
+ // directives — verified live twice, in both directions: 2026-07-30 removing a
525
+ // ceiling (MemoryMax 2G→infinity, OOMPolicy stop→continue) and 2026-08-12
526
+ // installing one (infinity→1234M), each with the PID unchanged. Restarting
527
+ // here would park every live session to apply a setting already in force.
507
528
  log.info('daemon: resource limits applied without a restart');
508
529
  }
509
530
  catch (error) {
@@ -513,6 +534,51 @@ async function repairResourceLimits() {
513
534
  });
514
535
  }
515
536
  }
537
+ /**
538
+ * How often the daemon re-measures the machine after the first time.
539
+ *
540
+ * Doing this only at startup left a hole big enough to matter. The runner is a
541
+ * user unit and comes up 20–60 seconds into a boot, which is inside
542
+ * `BOOT_SETTLE_SEC` — so a machine that reboots gets the blind static fraction
543
+ * and then keeps it for as long as the daemon lives, which can be weeks. On a
544
+ * dedicated 8 GB box that is 3.5 GB of soft brake against the 3.3 GB three
545
+ * sessions and a build actually cost: throttling ordinary work for no reason.
546
+ * Worse, `limitsCurrent` in `hello` is computed from FRESH facts, so the API
547
+ * would keep logging «limits outdated» on every reconnect for a machine that had
548
+ * no way to fix itself.
549
+ *
550
+ * Hourly is right because drift is slow by definition — it tracks what else is
551
+ * installed on the machine, not what it is doing this minute — and because the
552
+ * check writes nothing when the version matches and the ceiling still fits.
553
+ */
554
+ const LIMITS_RECHECK_MS = 3_600_000;
555
+ /**
556
+ * What the SERVICE's cgroup holds, for the paths that are not the service.
557
+ *
558
+ * `readMemoryFacts()` defaults to reading `/proc/self`, which is right for the
559
+ * daemon and wrong for everything a person types: `doctor --fix` and
560
+ * `install-service` run in the operator's own `session-N.scope`, worth a few MB.
561
+ * Measuring that and calling it «what the runner holds» made the CLI compute a
562
+ * ceiling 34 % away from the daemon's answer on a real host — each path then saw
563
+ * the other as drift and rewrote the file, forever.
564
+ *
565
+ * `MemoryCurrent` is `memory.current`, so it still counts page cache the way
566
+ * `MemAvailable` does. Subtracting it is not worth a second systemd call here:
567
+ * the CLI paths run once, by hand, and erring toward a LOWER ceiling is the safe
568
+ * direction. Returns null when the service is not running or systemd cannot be
569
+ * reached — the caller then has no facts, and writes nothing rather than writing
570
+ * a ceiling with no floor under it.
571
+ */
572
+ async function serviceMemoryCurrent() {
573
+ try {
574
+ const { stdout } = await execFileAsync('systemctl', ['--user', 'show', 'devbridge-runner', '-p', 'MemoryCurrent', '--value'], { timeout: 10_000, env: systemdUserEnv() });
575
+ const value = Number(stdout.trim());
576
+ return Number.isFinite(value) && value > 0 ? value : null;
577
+ }
578
+ catch {
579
+ return null;
580
+ }
581
+ }
516
582
  /**
517
583
  * Ticket #119. `ClaudeAdapter.stop()` removes each session's MCP config file,
518
584
  * but a kill -9, an OOM or a machine reboot leaves it behind — a live project
@@ -613,6 +679,10 @@ async function cmdDaemon() {
613
679
  // that stays connected for weeks.
614
680
  const pruneTimer = setInterval(() => supervisor.pruneJournals(), 6 * 3_600_000);
615
681
  pruneTimer.unref();
682
+ // Re-measure the machine — see `LIMITS_RECHECK_MS`. Writes nothing in the
683
+ // normal case, so this is a file read and some arithmetic once an hour.
684
+ const limitsTimer = setInterval(() => void repairResourceLimits(), LIMITS_RECHECK_MS);
685
+ limitsTimer.unref();
616
686
  const shutdown = (signal) => {
617
687
  log.info(`daemon: ${signal} received, shutting down`);
618
688
  supervisor.shutdown();
@@ -713,8 +783,9 @@ async function cmdInstallService() {
713
783
  print(`Wrote ${target}`);
714
784
  // Resource policy is a versioned drop-in, not part of the unit — see
715
785
  // `buildLimitsOverride`. Forced here: a fresh install must have it even if a
716
- // file from an older runner is already sitting there.
717
- writeLimitsOverride(true);
786
+ // file from an older runner is already sitting there. Facts come from the
787
+ // service rather than from `/proc/self`, which here is the installing shell.
788
+ writeLimitsOverride(true, undefined, readMemoryFacts(await serviceMemoryCurrent()));
718
789
  print(`Wrote ${limitsOverridePath()}`);
719
790
  if (!exec.viaCommand) {
720
791
  // Worth saying out loud: a unit pinned to a file inside the package directory
@@ -1136,10 +1207,22 @@ async function cmdDoctor(args) {
1136
1207
  print(` fits sessions ~${advised} (at ~1.5 GB per session under load)`);
1137
1208
  print('');
1138
1209
  print('Service limits');
1139
- const outdated = limitsOverrideIsOutdated();
1210
+ const memFacts = readMemoryFacts(await serviceMemoryCurrent());
1211
+ const outdated = limitsOverrideIsOutdated(undefined, undefined, memFacts);
1140
1212
  print(` drop-in ${limitsOverridePath()}`);
1141
1213
  print(` version ${outdated ? `MISSING or OLD (want ${LIMITS_VERSION})` : LIMITS_VERSION}`);
1142
1214
  print(` cpu quota ${quota === null ? 'none (fewer than 4 cpus)' : `${quota}%`}`);
1215
+ // Spelled out because the whole point of 0.39.0 is that this number is measured
1216
+ // rather than a percentage of the machine: on a server with neighbours, «80% of
1217
+ // total» sat above what was actually free and never engaged (gotcha #301).
1218
+ if (memFacts) {
1219
+ const policy = memoryPolicy(memFacts);
1220
+ const mb = (bytes) => `${Math.round(bytes / 1024 / 1024)} MB`;
1221
+ print(` memory ceiling ${mb(policy.maxBytes)} hard / ${mb(policy.highBytes)} soft`);
1222
+ print(` sized from ${policy.measured
1223
+ ? `${mb(memFacts.availableBytes)} available + ${mb(memFacts.ownUsageBytes)} ours`
1224
+ : `55% of ${mb(memFacts.totalBytes)} — still booting, remeasured on the next start`}`);
1225
+ }
1143
1226
  let effective;
1144
1227
  try {
1145
1228
  const { stdout } = await execFileAsync('systemctl', [
@@ -1215,9 +1298,22 @@ async function cmdDoctor(args) {
1215
1298
  process.exit(1);
1216
1299
  return;
1217
1300
  }
1218
- writeLimitsOverride(true);
1301
+ const fixFacts = readMemoryFacts(await serviceMemoryCurrent());
1302
+ writeLimitsOverride(true, undefined, fixFacts);
1219
1303
  print('');
1220
1304
  print(`Wrote ${limitsOverridePath()}`);
1305
+ // Say it out loud when the ceiling had to be raised above the honest answer to
1306
+ // avoid killing whatever is running right now. Lowering a ceiling under load is
1307
+ // a decision, not a side effect of running a diagnostic — and without this line
1308
+ // the number written here looks unexplainably generous.
1309
+ if (fixFacts) {
1310
+ const honest = memoryPolicy(fixFacts, 0);
1311
+ const applied = memoryPolicy(fixFacts);
1312
+ if (applied.maxBytes > honest.maxBytes) {
1313
+ const mb = (b) => Math.round(b / 1024 / 1024);
1314
+ print(`note: ceiling raised to ${mb(applied.maxBytes)} MB — the service already holds ${mb(fixFacts.ownUsageBytes)} MB, and ${mb(honest.maxBytes)} MB would kill it on reload. Re-run when idle.`);
1315
+ }
1316
+ }
1221
1317
  try {
1222
1318
  await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
1223
1319
  print('systemctl --user daemon-reload — done.');
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { findClaudeCli } from './agent-binary.js';
6
7
  import { log } from './log.js';
7
8
  import { stateDir } from './paths.js';
8
9
  import { RUNNER_VERSION } from './version.js';
@@ -235,10 +236,19 @@ function installArgs(source, prefix) {
235
236
  // a directory the daemon cannot write — and, on the rarer host where it can,
236
237
  // npm cheerfully installs a SECOND copy somewhere the service does not exec,
237
238
  // reports success, and the runner restarts on the old version forever.
239
+ //
240
+ // `--include=optional` is npm's default and is stated anyway (ticket #225):
241
+ // the Claude CLI ships as an OPTIONAL platform package, and a single
242
+ // `omit=optional` inherited from an `.npmrc`, an environment variable or a CI
243
+ // habit turns an update into a runner that cannot start a single Claude
244
+ // session — silently, because for npm a failed optional dependency is not a
245
+ // failure at all. The flag makes this deployment's intent explicit rather
246
+ // than dependent on whatever configuration the machine happens to carry.
238
247
  return [
239
248
  'install',
240
249
  '-g',
241
250
  '--ignore-scripts',
251
+ '--include=optional',
242
252
  '--loglevel=error',
243
253
  ...(prefix ? ['--prefix', prefix] : []),
244
254
  source,
@@ -259,7 +269,7 @@ export function manualUpdateCommand(tarballUrl, options = {}) {
259
269
  const prefix = options.prefix === undefined ? installPrefixFor(packageDir) : options.prefix;
260
270
  const user = options.user ?? os.userInfo().username;
261
271
  const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : -1);
262
- const install = ['npm install -g --ignore-scripts --loglevel=error']
272
+ const install = ['npm install -g --ignore-scripts --include=optional --loglevel=error']
263
273
  .concat(prefix ? [`--prefix ${prefix}`] : [])
264
274
  .concat([tarballUrl])
265
275
  .join(' ');
@@ -420,6 +430,44 @@ export async function selfUpdate(options) {
420
430
  `Restore it on the server with: npm install -g${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
421
431
  }
422
432
  }
433
+ // The binary the sessions will actually be started with (ticket #225).
434
+ //
435
+ // The probe above is not enough and 2026-08-11 proved it: `--version` loads
436
+ // the module graph, but the SDK resolves the Claude CLI lazily — on the first
437
+ // `query()`, i.e. inside a session, long after this update reported success.
438
+ // A missing OPTIONAL platform package therefore sailed through every check
439
+ // here, the daemon restarted, and the machine spent an hour and a half
440
+ // answering «продолжай» with nothing.
441
+ //
442
+ // One repair attempt first, because that is what the failure usually deserves:
443
+ // a 300 MB optional package that did not download is a network hiccup, not a
444
+ // broken release, and reinstalling it is cheaper for the user than a rollback.
445
+ if (!findClaudeCli(newPackageDir)) {
446
+ log.warn('self-update: the Claude CLI is missing from the new build — repairing', {
447
+ packageDir: newPackageDir,
448
+ });
449
+ try {
450
+ await installGlobal(exec, options.tarballUrl, prefix);
451
+ }
452
+ catch (error) {
453
+ log.warn('self-update: the repair install failed', { error: describe(error) });
454
+ }
455
+ }
456
+ if (!findClaudeCli(newPackageDir)) {
457
+ log.error('self-update: still no Claude CLI after the repair — rolling back', {
458
+ packageDir: newPackageDir,
459
+ });
460
+ const detail = 'the Claude CLI (an optional platform package of @anthropic-ai/claude-agent-sdk) did not install, ' +
461
+ 'so no Claude session could have started on this build';
462
+ try {
463
+ await installGlobal(exec, rollbackTarball, prefix);
464
+ return fail(`The new version was not activated (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
465
+ }
466
+ catch (rollbackError) {
467
+ return fail(`The new version is broken (${detail}) and the rollback failed too (${describe(rollbackError)}). ` +
468
+ `Restore it on the server with: npm install -g --include=optional${prefix ? ` --prefix ${prefix}` : ''} ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
469
+ }
470
+ }
423
471
  // The service unit may be pinned to a file inside the directory this update
424
472
  // just replaced — early versions wrote the resolved script path, and a package
425
473
  // rename moves it. Then the restart we are about to ask for would fail with
@@ -57,8 +57,114 @@ export declare function buildUnit(execStart?: string, nodeBinary?: string): stri
57
57
  * which is the only way to fix the servers that already have the bad numbers
58
58
  * baked in — and it never overwrites a unit the operator edited by hand.
59
59
  */
60
- export declare const LIMITS_VERSION = 2;
60
+ export declare const LIMITS_VERSION = 3;
61
61
  export declare function limitsOverridePath(home?: string): string;
62
+ /**
63
+ * What the memory policy needs to know about this machine. Read once and passed
64
+ * in, so the policy itself is a pure function that a test can drive with the
65
+ * numbers of a server it does not have.
66
+ */
67
+ export interface MemoryFacts {
68
+ /** `MemTotal` — the whole machine. */
69
+ totalBytes: number;
70
+ /** `MemAvailable` — what can be handed out without swapping. Already excludes
71
+ * what the co-tenants hold, and already counts reclaimable page cache. */
72
+ availableBytes: number;
73
+ /**
74
+ * What our own cgroup holds that `MemAvailable` has not already counted —
75
+ * `memory.current` minus its page cache. Added back to the headroom, and used
76
+ * as the floor under the ceiling; see `memoryPolicy`.
77
+ */
78
+ ownUsageBytes: number;
79
+ /** Seconds since boot. Below `BOOT_SETTLE_SEC` the measurement is a lie. */
80
+ uptimeSec: number;
81
+ }
82
+ export interface MemoryPolicy {
83
+ maxBytes: number;
84
+ highBytes: number;
85
+ /** false = the conservative static pair, because the machine was still booting. */
86
+ measured: boolean;
87
+ /**
88
+ * The honest headroom was below the 2 GB floor — the neighbours have eaten the
89
+ * machine and this ceiling sits ABOVE what is free. Not an error (a dev server
90
+ * that cannot run one agent is worse than a busy one), but the only signal the
91
+ * owner will ever get that the box is oversubscribed, so callers log it.
92
+ */
93
+ starved: boolean;
94
+ }
95
+ /**
96
+ * How long after boot `MemAvailable` starts telling the truth.
97
+ *
98
+ * The runner is a user unit ordered `After=network-online.target`, and the things
99
+ * it shares the machine with — dockerd and its containers, mysql, pm2 — come up
100
+ * around the same time. Measure at second 20 and the neighbours have not claimed
101
+ * their memory yet: on the 12 GB machine below that reads as 11 GB free and
102
+ * produces a ceiling that protects nothing.
103
+ */
104
+ export declare const BOOT_SETTLE_SEC = 600;
105
+ /**
106
+ * The two numbers, and the incident that decides them.
107
+ *
108
+ * Until 0.21.0 the policy was a 2 GB hard ceiling with systemd's default
109
+ * `OOMPolicy=stop`: one agent over the line and the whole service died, taking
110
+ * every other session with it (QA-112, gotcha #100). The fix removed the ceiling
111
+ * entirely and left a soft `MemoryHigh=80%`, on this reasoning, quoted from the
112
+ * code it replaced: «the machine keeps a fifth of its memory for everything that
113
+ * is not this service».
114
+ *
115
+ * That reasoning holds on a DEDICATED dev server and fails on a shared one, which
116
+ * is what vmi2502773 is: 11.9 GB total with ~4 GB permanently held by 18 docker
117
+ * containers, mysql, flowise, chroma and pm2. There, 80 % of TOTAL is 9.5 GB while
118
+ * only ~8 GB is actually free — so the soft brake sat ABOVE the real headroom and
119
+ * could never engage. The machine swapped, `kcompactd0` blocked for over 120
120
+ * seconds and the box hung; 2026-08-05 it was a `claude` at 4.2 GB, 2026-08-12 a
121
+ * single grep at 6.8 GB with 160 MB left. Only a power cycle recovered it.
122
+ *
123
+ * So the percentage has to be of what the machine can SPARE, not of what it has:
124
+ *
125
+ * headroom = MemAvailable + our own usage (ours is added back, or every
126
+ * rewrite would walk the ceiling down by what we already hold)
127
+ * MemoryMax = headroom − reserve
128
+ * MemoryHigh = 80 % of MemoryMax (reclaim and throttle first, kill last)
129
+ *
130
+ * A hard ceiling is safe to have again only because the OTHER half of the 0.21.0
131
+ * fix stayed: `OOMPolicy=continue` is still set below, so the kernel killing one
132
+ * runaway child no longer tears down the service. Restoring the ceiling without
133
+ * that would re-open QA-112.
134
+ *
135
+ * The clamps are the two ways this can go wrong:
136
+ * - floor 2 GB: on a machine whose neighbours already ate everything, a computed
137
+ * ceiling of a few hundred MB would mean a dev server that cannot run one
138
+ * agent. Measured cost of ordinary work is 512–646 MB per `claude` and 1571 MB
139
+ * for a workspace `pnpm -r typecheck`, so anything under 2 GB is not a dev
140
+ * server at all — an unusable one is a worse failure than a busy one, the same
141
+ * call `cpuQuotaPercent` makes below;
142
+ * - cap 85 % of total: on an idle dedicated box `MemAvailable` is nearly the whole
143
+ * machine, and a ceiling of «everything» is the bug this function exists to fix.
144
+ */
145
+ export declare function memoryPolicy(facts: MemoryFacts, minCeilingBytes?: number): MemoryPolicy;
146
+ /**
147
+ * What the cgroup holds that `MemAvailable` has NOT already counted.
148
+ *
149
+ * `memory.current` is `anon + file + kernel`, and `file` is page cache — which
150
+ * `MemAvailable` already lists as reclaimable. Adding the whole of
151
+ * `memory.current` back to `MemAvailable` therefore counts our page cache twice
152
+ * and inflates the ceiling by exactly that much: measured at +19 % on this host
153
+ * (1.1 GB of cache in a 2.3 GB cgroup), and the cache is largest during builds —
154
+ * precisely when memory is tightest. Subtracting `file` keeps the part we really
155
+ * do hold and cannot give back on demand.
156
+ */
157
+ export declare function readCgroupUnreclaimable(dir: string): number | null;
158
+ /**
159
+ * Everything `memoryPolicy` needs, straight off this machine.
160
+ *
161
+ * `ownUsageBytes` is passed in by callers that are not the daemon — `doctor` and
162
+ * `install-service` run in the operator's own cgroup and cannot read the
163
+ * service's usage from `/proc/self`. Returns null when the service's usage is
164
+ * unknowable, because guessing 0 there is the one dangerous direction: it removes
165
+ * the floor that stops a live session from being killed on `daemon-reload`.
166
+ */
167
+ export declare function readMemoryFacts(ownUsageBytes?: number | null): MemoryFacts | null;
62
168
  /**
63
169
  * `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
64
170
  * box unreachable, but never so little that ordinary work is throttled.
@@ -69,17 +175,27 @@ export declare function limitsOverridePath(home?: string): string;
69
175
  * than a busy one.
70
176
  */
71
177
  export declare function cpuQuotaPercent(cpuCount?: number): number | null;
72
- export declare function buildLimitsOverride(cpuCount?: number): string;
178
+ export declare function buildLimitsOverride(cpuCount?: number, facts?: MemoryFacts | null): string;
73
179
  /**
74
180
  * Is the shipped resource policy missing or from an older runner?
75
181
  *
76
182
  * Deliberately version-based rather than content-based: an operator may add
77
183
  * their own directives to our file, and re-writing on every start would fight
78
- * them. Only the version number decides.
184
+ * them. The version number decides — plus, since 0.39.0 and only when `facts` are
185
+ * supplied, whether the measured ceiling still fits the machine (see
186
+ * `memoryCeilingHasDrifted`). Callers that pass no facts get the old, purely
187
+ * version-based answer.
188
+ */
189
+ export declare function limitsOverrideIsOutdated(readFile?: (p: string) => string, home?: string, facts?: MemoryFacts | null): boolean;
190
+ /**
191
+ * Write the drop-in. Returns false when nothing needed doing.
192
+ *
193
+ * The «never below what we already hold» guard lives in `memoryPolicy` itself
194
+ * (`CEILING_HEADROOM_OVER_CURRENT`), so writing, reading and the drift check all
195
+ * arrive at the same number — otherwise a write whose floor was binding would be
196
+ * seen as drifted on the very next call and rewritten forever.
79
197
  */
80
- export declare function limitsOverrideIsOutdated(readFile?: (p: string) => string, home?: string): boolean;
81
- /** Write the drop-in. Returns false when nothing needed doing. */
82
- export declare function writeLimitsOverride(force?: boolean, home?: string): boolean;
198
+ export declare function writeLimitsOverride(force?: boolean, home?: string, facts?: MemoryFacts | null): boolean;
83
199
  /**
84
200
  * Does the installed unit point at something that no longer exists?
85
201
  *