@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
package/dist/service-unit.js
CHANGED
|
@@ -129,13 +129,220 @@ export function buildUnit(execStart, nodeBinary = process.execPath) {
|
|
|
129
129
|
* which is the only way to fix the servers that already have the bad numbers
|
|
130
130
|
* baked in — and it never overwrites a unit the operator edited by hand.
|
|
131
131
|
*/
|
|
132
|
-
export const LIMITS_VERSION =
|
|
132
|
+
export const LIMITS_VERSION = 3;
|
|
133
133
|
const LIMITS_MARKER = '# devbridge-limits-version:';
|
|
134
134
|
/** `zz-` so it sorts last: an operator's own drop-in should still win. */
|
|
135
135
|
const LIMITS_FILE = 'zz-devbridge-limits.conf';
|
|
136
136
|
export function limitsOverridePath(home = systemdUserHome()) {
|
|
137
137
|
return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service.d`, LIMITS_FILE);
|
|
138
138
|
}
|
|
139
|
+
const MIB = 1024 * 1024;
|
|
140
|
+
const GIB = 1024 * MIB;
|
|
141
|
+
/**
|
|
142
|
+
* How long after boot `MemAvailable` starts telling the truth.
|
|
143
|
+
*
|
|
144
|
+
* The runner is a user unit ordered `After=network-online.target`, and the things
|
|
145
|
+
* it shares the machine with — dockerd and its containers, mysql, pm2 — come up
|
|
146
|
+
* around the same time. Measure at second 20 and the neighbours have not claimed
|
|
147
|
+
* their memory yet: on the 12 GB machine below that reads as 11 GB free and
|
|
148
|
+
* produces a ceiling that protects nothing.
|
|
149
|
+
*/
|
|
150
|
+
export const BOOT_SETTLE_SEC = 600;
|
|
151
|
+
/**
|
|
152
|
+
* The ceiling is never written BELOW what the cgroup already holds.
|
|
153
|
+
*
|
|
154
|
+
* Until 0.39.0 this policy only ever REMOVED a limit, so applying it to a running
|
|
155
|
+
* service was free. Now it sets one, and systemd applies `memory.max` to a LIVE
|
|
156
|
+
* cgroup on `daemon-reload`: a value under current usage makes the kernel reclaim
|
|
157
|
+
* and then kill inside the cgroup. At daemon start that is harmless — nothing is
|
|
158
|
+
* running yet — but `doctor --fix` on a busy machine would otherwise take out
|
|
159
|
+
* somebody's session as a side effect of running a diagnostic.
|
|
160
|
+
*
|
|
161
|
+
* The cost is that a machine fixed WHILE a runaway is in progress writes a ceiling
|
|
162
|
+
* above that runaway, which protects nothing. It resolves itself: the next daemon
|
|
163
|
+
* start measures an idle cgroup, and the drift check rewrites the inflated number.
|
|
164
|
+
*/
|
|
165
|
+
const CEILING_HEADROOM_OVER_CURRENT = 1.25;
|
|
166
|
+
/**
|
|
167
|
+
* The two numbers, and the incident that decides them.
|
|
168
|
+
*
|
|
169
|
+
* Until 0.21.0 the policy was a 2 GB hard ceiling with systemd's default
|
|
170
|
+
* `OOMPolicy=stop`: one agent over the line and the whole service died, taking
|
|
171
|
+
* every other session with it (QA-112, gotcha #100). The fix removed the ceiling
|
|
172
|
+
* entirely and left a soft `MemoryHigh=80%`, on this reasoning, quoted from the
|
|
173
|
+
* code it replaced: «the machine keeps a fifth of its memory for everything that
|
|
174
|
+
* is not this service».
|
|
175
|
+
*
|
|
176
|
+
* That reasoning holds on a DEDICATED dev server and fails on a shared one, which
|
|
177
|
+
* is what vmi2502773 is: 11.9 GB total with ~4 GB permanently held by 18 docker
|
|
178
|
+
* containers, mysql, flowise, chroma and pm2. There, 80 % of TOTAL is 9.5 GB while
|
|
179
|
+
* only ~8 GB is actually free — so the soft brake sat ABOVE the real headroom and
|
|
180
|
+
* could never engage. The machine swapped, `kcompactd0` blocked for over 120
|
|
181
|
+
* seconds and the box hung; 2026-08-05 it was a `claude` at 4.2 GB, 2026-08-12 a
|
|
182
|
+
* single grep at 6.8 GB with 160 MB left. Only a power cycle recovered it.
|
|
183
|
+
*
|
|
184
|
+
* So the percentage has to be of what the machine can SPARE, not of what it has:
|
|
185
|
+
*
|
|
186
|
+
* headroom = MemAvailable + our own usage (ours is added back, or every
|
|
187
|
+
* rewrite would walk the ceiling down by what we already hold)
|
|
188
|
+
* MemoryMax = headroom − reserve
|
|
189
|
+
* MemoryHigh = 80 % of MemoryMax (reclaim and throttle first, kill last)
|
|
190
|
+
*
|
|
191
|
+
* A hard ceiling is safe to have again only because the OTHER half of the 0.21.0
|
|
192
|
+
* fix stayed: `OOMPolicy=continue` is still set below, so the kernel killing one
|
|
193
|
+
* runaway child no longer tears down the service. Restoring the ceiling without
|
|
194
|
+
* that would re-open QA-112.
|
|
195
|
+
*
|
|
196
|
+
* The clamps are the two ways this can go wrong:
|
|
197
|
+
* - floor 2 GB: on a machine whose neighbours already ate everything, a computed
|
|
198
|
+
* ceiling of a few hundred MB would mean a dev server that cannot run one
|
|
199
|
+
* agent. Measured cost of ordinary work is 512–646 MB per `claude` and 1571 MB
|
|
200
|
+
* for a workspace `pnpm -r typecheck`, so anything under 2 GB is not a dev
|
|
201
|
+
* server at all — an unusable one is a worse failure than a busy one, the same
|
|
202
|
+
* call `cpuQuotaPercent` makes below;
|
|
203
|
+
* - cap 85 % of total: on an idle dedicated box `MemAvailable` is nearly the whole
|
|
204
|
+
* machine, and a ceiling of «everything» is the bug this function exists to fix.
|
|
205
|
+
*/
|
|
206
|
+
export function memoryPolicy(facts, minCeilingBytes = facts.ownUsageBytes * CEILING_HEADROOM_OVER_CURRENT) {
|
|
207
|
+
const { totalBytes, availableBytes, ownUsageBytes, uptimeSec } = facts;
|
|
208
|
+
const withFloor = (maxBytes, measured, starved = false) => {
|
|
209
|
+
const ceiling = Math.floor(Math.max(maxBytes, minCeilingBytes));
|
|
210
|
+
return { maxBytes: ceiling, highBytes: Math.floor(ceiling * 0.8), measured, starved };
|
|
211
|
+
};
|
|
212
|
+
// Still booting: the neighbours have not claimed their memory yet, so measuring
|
|
213
|
+
// now would hand back almost the whole machine. Take a fraction of TOTAL
|
|
214
|
+
// instead — it needs no measurement at all.
|
|
215
|
+
//
|
|
216
|
+
// 55 % is chosen against the machine that failed rather than against a tidy
|
|
217
|
+
// number: 55 % of 11.9 GB is 6.5 GB, which still leaves 1.4 GB after the 4 GB
|
|
218
|
+
// its neighbours hold, and puts the brake at 5.2 GB — engaged with plenty free.
|
|
219
|
+
// 60 % looked reasonable and left only 760 MB, which is not a margin. This
|
|
220
|
+
// number works blind, so it is sized for the crowded case, and it costs a
|
|
221
|
+
// dedicated box nothing: it only applies for the first ten minutes of uptime,
|
|
222
|
+
// before any session exists. The next daemon start measures and replaces it.
|
|
223
|
+
if (!Number.isFinite(uptimeSec) || uptimeSec < BOOT_SETTLE_SEC) {
|
|
224
|
+
return withFloor(totalBytes * 0.55, false);
|
|
225
|
+
}
|
|
226
|
+
// Enough for sshd, journald, the kernel and some page cache to keep working
|
|
227
|
+
// while the cgroup sits at its ceiling. Proportional on a big machine, absolute
|
|
228
|
+
// on a small one, because 15 % of 4 GB is not enough to stay reachable.
|
|
229
|
+
const reserve = Math.max(1.5 * GIB, totalBytes * 0.15);
|
|
230
|
+
const headroom = availableBytes + ownUsageBytes;
|
|
231
|
+
const cap = totalBytes * 0.85;
|
|
232
|
+
// `min` with the cap, not a bare 2 GiB: below ~2.4 GB of RAM the floor would be
|
|
233
|
+
// ABOVE the cap and `clamp` would silently return the cap anyway — with the
|
|
234
|
+
// reserve ignored and the «2 GB or nothing» promise quietly broken. Saying it
|
|
235
|
+
// here makes the tiny-machine answer deliberate instead of accidental.
|
|
236
|
+
const floor = Math.min(2 * GIB, cap);
|
|
237
|
+
const wanted = headroom - reserve;
|
|
238
|
+
return withFloor(clamp(wanted, floor, cap), true, wanted < floor);
|
|
239
|
+
}
|
|
240
|
+
function clamp(value, low, high) {
|
|
241
|
+
return Math.min(Math.max(value, low), high);
|
|
242
|
+
}
|
|
243
|
+
/** systemd takes a plain byte count, but a unit file is read by people. */
|
|
244
|
+
function asMiB(bytes) {
|
|
245
|
+
return `${Math.max(1, Math.floor(bytes / MIB))}M`;
|
|
246
|
+
}
|
|
247
|
+
/** `MemTotal`/`MemAvailable` in bytes, or null where there is no `/proc`. */
|
|
248
|
+
function readMemInfo() {
|
|
249
|
+
let text;
|
|
250
|
+
try {
|
|
251
|
+
text = fs.readFileSync('/proc/meminfo', 'utf8');
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const field = (name) => {
|
|
257
|
+
const match = new RegExp(`^${name}:\\s+(\\d+) kB$`, 'm').exec(text);
|
|
258
|
+
return match?.[1] ? Number(match[1]) * 1024 : null;
|
|
259
|
+
};
|
|
260
|
+
const totalBytes = field('MemTotal');
|
|
261
|
+
const availableBytes = field('MemAvailable');
|
|
262
|
+
return totalBytes && availableBytes ? { totalBytes, availableBytes } : null;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* What our own cgroup currently holds.
|
|
266
|
+
*
|
|
267
|
+
* Read through `/proc/self/cgroup` rather than assembling the path from the
|
|
268
|
+
* service name: the runner runs as root and as a dedicated user, under
|
|
269
|
+
* `user@0.service` and under `user@1001.service`, and guessing that path wrong
|
|
270
|
+
* silently returns 0 — which would quietly shrink the ceiling by whatever we are
|
|
271
|
+
* already using. Returns 0 on cgroup v1 or in a container without the file, which
|
|
272
|
+
* is the safe direction: a slightly lower ceiling, never a higher one.
|
|
273
|
+
*/
|
|
274
|
+
function readOwnCgroupUsage() {
|
|
275
|
+
let cgroup;
|
|
276
|
+
try {
|
|
277
|
+
const line = fs
|
|
278
|
+
.readFileSync('/proc/self/cgroup', 'utf8')
|
|
279
|
+
.split('\n')
|
|
280
|
+
.find((l) => l.startsWith('0::'));
|
|
281
|
+
if (!line)
|
|
282
|
+
return null; // cgroup v1 — no v2 path to read
|
|
283
|
+
cgroup = line.slice('0::'.length).trim();
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
// `/proc/self` is the CALLER. For the daemon that is the service, and this is
|
|
289
|
+
// the hot path. For `doctor --fix` or `install-service` typed over ssh it is a
|
|
290
|
+
// `session-N.scope` holding a few MB — measuring that and calling it «what the
|
|
291
|
+
// runner holds» produced a ceiling 34 % away from the daemon's own answer on
|
|
292
|
+
// this very host, so the two paths would each see the other as drift and
|
|
293
|
+
// rewrite the file forever. Anything that is not the service must say «I do not
|
|
294
|
+
// know» and let the caller supply the number.
|
|
295
|
+
if (!cgroup.endsWith(`/${SERVICE_NAME}.service`))
|
|
296
|
+
return null;
|
|
297
|
+
return readCgroupUnreclaimable(path.join('/sys/fs/cgroup', cgroup));
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* What the cgroup holds that `MemAvailable` has NOT already counted.
|
|
301
|
+
*
|
|
302
|
+
* `memory.current` is `anon + file + kernel`, and `file` is page cache — which
|
|
303
|
+
* `MemAvailable` already lists as reclaimable. Adding the whole of
|
|
304
|
+
* `memory.current` back to `MemAvailable` therefore counts our page cache twice
|
|
305
|
+
* and inflates the ceiling by exactly that much: measured at +19 % on this host
|
|
306
|
+
* (1.1 GB of cache in a 2.3 GB cgroup), and the cache is largest during builds —
|
|
307
|
+
* precisely when memory is tightest. Subtracting `file` keeps the part we really
|
|
308
|
+
* do hold and cannot give back on demand.
|
|
309
|
+
*/
|
|
310
|
+
export function readCgroupUnreclaimable(dir) {
|
|
311
|
+
try {
|
|
312
|
+
const current = Number(fs.readFileSync(path.join(dir, 'memory.current'), 'utf8').trim());
|
|
313
|
+
if (!Number.isFinite(current))
|
|
314
|
+
return null;
|
|
315
|
+
const file = /^file (\d+)$/m.exec(fs.readFileSync(path.join(dir, 'memory.stat'), 'utf8'));
|
|
316
|
+
const cache = file?.[1] ? Number(file[1]) : 0;
|
|
317
|
+
return Math.max(0, current - cache);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function readUptimeSec() {
|
|
324
|
+
try {
|
|
325
|
+
return Number(fs.readFileSync('/proc/uptime', 'utf8').split(/\s+/)[0]) || 0;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Everything `memoryPolicy` needs, straight off this machine.
|
|
333
|
+
*
|
|
334
|
+
* `ownUsageBytes` is passed in by callers that are not the daemon — `doctor` and
|
|
335
|
+
* `install-service` run in the operator's own cgroup and cannot read the
|
|
336
|
+
* service's usage from `/proc/self`. Returns null when the service's usage is
|
|
337
|
+
* unknowable, because guessing 0 there is the one dangerous direction: it removes
|
|
338
|
+
* the floor that stops a live session from being killed on `daemon-reload`.
|
|
339
|
+
*/
|
|
340
|
+
export function readMemoryFacts(ownUsageBytes = readOwnCgroupUsage()) {
|
|
341
|
+
const info = readMemInfo();
|
|
342
|
+
if (!info || ownUsageBytes === null)
|
|
343
|
+
return null;
|
|
344
|
+
return { ...info, ownUsageBytes, uptimeSec: readUptimeSec() };
|
|
345
|
+
}
|
|
139
346
|
/**
|
|
140
347
|
* `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
|
|
141
348
|
* box unreachable, but never so little that ordinary work is throttled.
|
|
@@ -148,8 +355,16 @@ export function limitsOverridePath(home = systemdUserHome()) {
|
|
|
148
355
|
export function cpuQuotaPercent(cpuCount = os.cpus().length) {
|
|
149
356
|
return cpuCount >= 4 ? (cpuCount - 1) * 100 : null;
|
|
150
357
|
}
|
|
151
|
-
export function buildLimitsOverride(cpuCount = os.cpus().length) {
|
|
358
|
+
export function buildLimitsOverride(cpuCount = os.cpus().length, facts = readMemoryFacts()) {
|
|
152
359
|
const quota = cpuQuotaPercent(cpuCount);
|
|
360
|
+
// No `minCeilingBytes` parameter on purpose. It existed here for one revision
|
|
361
|
+
// and passed an explicit `0`, which silently DEFEATED the default in
|
|
362
|
+
// `memoryPolicy` — the writer produced a ceiling below live cgroup usage while
|
|
363
|
+
// the drift check and `doctor` both computed the floored one. Two consequences,
|
|
364
|
+
// both found in QA: `doctor --fix` could kill a session on a loaded machine,
|
|
365
|
+
// and the file was rewritten on every start because no fixed point existed.
|
|
366
|
+
// The floor belongs to the policy, so every caller gets it and none can opt out.
|
|
367
|
+
const memory = facts ? memoryPolicy(facts) : null;
|
|
153
368
|
return ([
|
|
154
369
|
`${LIMITS_MARKER} ${LIMITS_VERSION}`,
|
|
155
370
|
'# Managed by devbridge-runner. Put your own overrides in a file that sorts',
|
|
@@ -163,14 +378,34 @@ export function buildLimitsOverride(cpuCount = os.cpus().length) {
|
|
|
163
378
|
'StartLimitBurst=0',
|
|
164
379
|
'',
|
|
165
380
|
'[Service]',
|
|
166
|
-
// The whole point of the change
|
|
381
|
+
// The whole point of the 0.21.0 change, and the reason the ceiling below is
|
|
382
|
+
// safe to have at all: one child's OOM must not take the fleet.
|
|
167
383
|
'OOMPolicy=continue',
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
384
|
+
// Both values also override the `MemoryMax=2G` baked into units written
|
|
385
|
+
// before 0.21.0.
|
|
386
|
+
//
|
|
387
|
+
// The unmeasurable branch used to write `MemoryMax=` + `MemoryHigh=80%` —
|
|
388
|
+
// literally the pair this release exists to remove. Because an empty
|
|
389
|
+
// assignment RESETS a directive and a drop-in sorts after the unit, that
|
|
390
|
+
// branch would have stripped the ceiling the API's fallback unit now carries
|
|
391
|
+
// and left the machine weaker than if we had written nothing at all. A
|
|
392
|
+
// drop-in must never disarm the unit it overrides, so it falls back to the
|
|
393
|
+
// same static pair used during boot.
|
|
394
|
+
...(memory
|
|
395
|
+
? [
|
|
396
|
+
`# ${memory.measured ? 'measured headroom' : 'still booting — conservative fraction of total'}`,
|
|
397
|
+
`MemoryMax=${asMiB(memory.maxBytes)}`,
|
|
398
|
+
// Soft pressure below the hard stop: the kernel reclaims and throttles
|
|
399
|
+
// here, and only kills at MemoryMax. This is the line that failed on
|
|
400
|
+
// vmi2502773 — as a percentage of TOTAL it sat above what the machine
|
|
401
|
+
// could spare, so it never engaged.
|
|
402
|
+
`MemoryHigh=${asMiB(memory.highBytes)}`,
|
|
403
|
+
]
|
|
404
|
+
: [
|
|
405
|
+
'# machine not measurable — same conservative pair as the fallback unit',
|
|
406
|
+
'MemoryMax=55%',
|
|
407
|
+
'MemoryHigh=44%',
|
|
408
|
+
]),
|
|
174
409
|
// Either a computed quota, or an explicit reset — both of which clear the
|
|
175
410
|
// `CPUQuota=80%` baked into units written before 0.21.0.
|
|
176
411
|
quota === null ? 'CPUQuota=' : `CPUQuota=${quota}%`,
|
|
@@ -186,9 +421,12 @@ export function buildLimitsOverride(cpuCount = os.cpus().length) {
|
|
|
186
421
|
*
|
|
187
422
|
* Deliberately version-based rather than content-based: an operator may add
|
|
188
423
|
* their own directives to our file, and re-writing on every start would fight
|
|
189
|
-
* them.
|
|
424
|
+
* them. The version number decides — plus, since 0.39.0 and only when `facts` are
|
|
425
|
+
* supplied, whether the measured ceiling still fits the machine (see
|
|
426
|
+
* `memoryCeilingHasDrifted`). Callers that pass no facts get the old, purely
|
|
427
|
+
* version-based answer.
|
|
190
428
|
*/
|
|
191
|
-
export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'utf8'), home = systemdUserHome()) {
|
|
429
|
+
export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'utf8'), home = systemdUserHome(), facts = null) {
|
|
192
430
|
let contents;
|
|
193
431
|
try {
|
|
194
432
|
contents = readFile(limitsOverridePath(home));
|
|
@@ -200,15 +438,61 @@ export function limitsOverrideIsOutdated(readFile = (p) => fs.readFileSync(p, 'u
|
|
|
200
438
|
if (!line)
|
|
201
439
|
return true;
|
|
202
440
|
const version = Number.parseInt(line.slice(LIMITS_MARKER.length).trim(), 10);
|
|
203
|
-
|
|
441
|
+
if (!Number.isFinite(version) || version < LIMITS_VERSION)
|
|
442
|
+
return true;
|
|
443
|
+
return facts ? memoryCeilingHasDrifted(contents, facts) : false;
|
|
204
444
|
}
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
|
|
445
|
+
/**
|
|
446
|
+
* Since 0.39.0 the ceiling is a measurement, and a measurement goes stale.
|
|
447
|
+
*
|
|
448
|
+
* A machine that gained a database and a dozen containers after the drop-in was
|
|
449
|
+
* written would keep a ceiling sized for the machine it used to be — which is the
|
|
450
|
+
* original bug wearing a different hat. So the version marker is no longer the
|
|
451
|
+
* only thing that can make the file outdated.
|
|
452
|
+
*
|
|
453
|
+
* The 15 % band is what keeps this from becoming a rewrite on every start:
|
|
454
|
+
* `MemAvailable` moves by hundreds of MB just from page cache, and re-writing our
|
|
455
|
+
* file that often would fight an operator who added their own directives to it
|
|
456
|
+
* (the reason the check was version-only to begin with).
|
|
457
|
+
*/
|
|
458
|
+
const CEILING_DRIFT_TOLERANCE = 0.15;
|
|
459
|
+
function memoryCeilingHasDrifted(contents, facts) {
|
|
460
|
+
// Never re-measure a machine that is still booting: the static pair it is
|
|
461
|
+
// holding is deliberate, and «drift» against a lie is not drift.
|
|
462
|
+
if (!memoryPolicy(facts).measured)
|
|
463
|
+
return false;
|
|
464
|
+
// LAST match, not the first: systemd applies the last assignment, and the file
|
|
465
|
+
// explicitly invites the operator to add their own lines. Reading the first one
|
|
466
|
+
// would measure drift against a directive that is not in force.
|
|
467
|
+
const written = [...contents.matchAll(/^MemoryMax=(\d+)([KMG]?)$/gm)].at(-1);
|
|
468
|
+
// Anything we cannot read as a byte count is replaced, and that is deliberate:
|
|
469
|
+
// an EMPTY `MemoryMax=` is the 0.21.0 shape this release exists to remove, and
|
|
470
|
+
// `MemoryMax=55%` is the blind pair we write when the machine is unmeasurable —
|
|
471
|
+
// both must give way the moment a real measurement is available. The operator's
|
|
472
|
+
// own directives belong in a file that sorts after this one, which the header of
|
|
473
|
+
// every generated file says; a value they appended HERE is still honoured,
|
|
474
|
+
// because a plain `4G` parses above and is compared like any other.
|
|
475
|
+
if (!written?.[1])
|
|
476
|
+
return true;
|
|
477
|
+
const scale = { '': 1, K: 1024, M: MIB, G: 1024 * MIB }[written[2] ?? ''] ?? MIB;
|
|
478
|
+
const writtenBytes = Number(written[1]) * scale;
|
|
479
|
+
const wanted = memoryPolicy(facts).maxBytes;
|
|
480
|
+
return Math.abs(writtenBytes - wanted) / wanted > CEILING_DRIFT_TOLERANCE;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Write the drop-in. Returns false when nothing needed doing.
|
|
484
|
+
*
|
|
485
|
+
* The «never below what we already hold» guard lives in `memoryPolicy` itself
|
|
486
|
+
* (`CEILING_HEADROOM_OVER_CURRENT`), so writing, reading and the drift check all
|
|
487
|
+
* arrive at the same number — otherwise a write whose floor was binding would be
|
|
488
|
+
* seen as drifted on the very next call and rewritten forever.
|
|
489
|
+
*/
|
|
490
|
+
export function writeLimitsOverride(force = false, home = systemdUserHome(), facts = readMemoryFacts()) {
|
|
491
|
+
if (!force && !limitsOverrideIsOutdated(undefined, home, facts))
|
|
208
492
|
return false;
|
|
209
493
|
const target = limitsOverridePath(home);
|
|
210
494
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
211
|
-
fs.writeFileSync(target, buildLimitsOverride(), { mode: 0o644 });
|
|
495
|
+
fs.writeFileSync(target, buildLimitsOverride(undefined, facts), { mode: 0o644 });
|
|
212
496
|
return true;
|
|
213
497
|
}
|
|
214
498
|
/**
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface SupervisorOptions {
|
|
|
19
19
|
runnerToken?: string;
|
|
20
20
|
/** Test seam for the `self_update` command. */
|
|
21
21
|
selfUpdate?: typeof selfUpdate;
|
|
22
|
+
/**
|
|
23
|
+
* How long a freshly started agent process may say nothing before the session
|
|
24
|
+
* says so out loud (ticket #225). Test seam — the default is a minute, and a
|
|
25
|
+
* test that had to wait one would not be written.
|
|
26
|
+
*/
|
|
27
|
+
startupSilenceMs?: number;
|
|
22
28
|
/**
|
|
23
29
|
* Called after a successful update, once the reply is on the wire. The daemon
|
|
24
30
|
* exits here and systemd starts the new build; without a handler the runner
|
|
@@ -113,11 +119,53 @@ export declare class Supervisor {
|
|
|
113
119
|
* free CHAT session with no prompt at all (the agent boots, reports its
|
|
114
120
|
* capabilities and waits for the first message).
|
|
115
121
|
*
|
|
116
|
-
* Returns
|
|
117
|
-
*
|
|
118
|
-
*
|
|
122
|
+
* Returns what came of it: a caller holding a user message needs to know,
|
|
123
|
+
* because anything but `ok` means the message has to stay queued rather than
|
|
124
|
+
* be marked delivered (session 9).
|
|
125
|
+
*
|
|
126
|
+
* NEVER throws (ticket #225). Everything from reading the project prompt to
|
|
127
|
+
* the adapter's own constructor runs inside one guard, because the caller
|
|
128
|
+
* chain above cannot tell the difference between «did not start» and
|
|
129
|
+
* «threw»: the message path swallows the exception into a log line, and the
|
|
130
|
+
* reconnect path lets it abort the restore of every OTHER session on the
|
|
131
|
+
* machine. A launch that fails is a state this session reports, not an
|
|
132
|
+
* exception somebody else has to remember to catch.
|
|
119
133
|
*/
|
|
120
134
|
private launchAgent;
|
|
135
|
+
/**
|
|
136
|
+
* The agent process could not be started at all (ticket #225).
|
|
137
|
+
*
|
|
138
|
+
* Three things have to happen here, and until this ticket none of them did:
|
|
139
|
+
* the reason is said WHERE THE PERSON IS LOOKING (an `error` event is the
|
|
140
|
+
* feed's red line), the session stops claiming to be working, and the stack
|
|
141
|
+
* reaches journald for whoever has to fix the machine. The status is the
|
|
142
|
+
* honest one for a session with no process — the agent is not running, and a
|
|
143
|
+
* session left in `RUNNING` shows a stop button for a turn that does not
|
|
144
|
+
* exist.
|
|
145
|
+
*
|
|
146
|
+
* Never rethrows: this IS the handling. The caller gets `crashed` and decides
|
|
147
|
+
* what to do with the message it was holding.
|
|
148
|
+
*/
|
|
149
|
+
private launchCrashed;
|
|
150
|
+
/** How long a freshly started agent may say nothing before we say so. */
|
|
151
|
+
private static readonly STARTUP_SILENCE_MS;
|
|
152
|
+
/**
|
|
153
|
+
* Watch for the first word out of a process we just started (ticket #225).
|
|
154
|
+
*
|
|
155
|
+
* «The adapter object exists» is not «the agent is running». A CLI that hangs
|
|
156
|
+
* before its first frame — a stuck hook, an MCP server that never answers, a
|
|
157
|
+
* transcript it cannot read — produces no events, no error and no exit, and
|
|
158
|
+
* the session sits in `RUNNING` forever. On a healthy launch the first event
|
|
159
|
+
* arrives in about two seconds, so a minute of silence is not a slow start,
|
|
160
|
+
* it is something worth saying out loud.
|
|
161
|
+
*
|
|
162
|
+
* Says it and stops there: no kill. A long conversation has the right to boot
|
|
163
|
+
* slowly, and killing it would cost the person the very turn they are waiting
|
|
164
|
+
* for.
|
|
165
|
+
*/
|
|
166
|
+
private watchForFirstSignOfLife;
|
|
167
|
+
/** The process spoke, or went away — either way the watch is over. */
|
|
168
|
+
private clearStartupWatch;
|
|
121
169
|
/**
|
|
122
170
|
* The project's own prompt file, read fresh for THIS agent process.
|
|
123
171
|
*
|