@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.
- package/dist/agent-binary.d.ts +28 -0
- package/dist/agent-binary.js +99 -0
- package/dist/index.js +108 -12
- package/dist/self-update.js +49 -1
- package/dist/service-unit.d.ts +122 -6
- package/dist/service-unit.js +300 -16
- package/dist/supervisor.d.ts +51 -3
- package/dist/supervisor.js +435 -244
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +28 -0
- package/dist/ws-client.js +33 -4
- package/package.json +1 -1
|
@@ -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
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
505
|
-
// 2G→infinity
|
|
506
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.');
|
package/dist/self-update.js
CHANGED
|
@@ -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
|
package/dist/service-unit.d.ts
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
|
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
|
*
|