@bridge4dev/runner 0.53.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.js +105 -2
- package/dist/adapters/codex-protocol.d.ts +11 -0
- package/dist/adapters/codex-protocol.js +41 -3
- package/dist/adapters/codex.js +37 -0
- package/dist/host-load.d.ts +156 -0
- package/dist/host-load.js +223 -0
- package/dist/index.js +211 -40
- package/dist/process-priority.d.ts +55 -0
- package/dist/process-priority.js +99 -0
- package/dist/protocol.d.ts +25 -3
- package/dist/recipe-schema.d.ts +6 -6
- package/dist/self-update.js +43 -2
- package/dist/service-unit.d.ts +268 -10
- package/dist/service-unit.js +432 -53
- package/dist/session-cage.d.ts +508 -0
- package/dist/session-cage.js +1183 -0
- package/dist/supervisor.d.ts +106 -0
- package/dist/supervisor.js +243 -0
- package/dist/systemd-memory.d.ts +35 -0
- package/dist/systemd-memory.js +115 -0
- package/dist/verify.js +28 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { selfUpdate, type SelfUpdateOutcome } from './self-update.js';
|
|
|
4
4
|
import { installAgent } from './agent-install.js';
|
|
5
5
|
import { pruneNativeClaudeVersions } from './agent-cleanup.js';
|
|
6
6
|
import { type AgentVersionsMeasurement } from './agent-versions.js';
|
|
7
|
+
import { type HostLoadFrame } from './host-load.js';
|
|
8
|
+
import { type ScopeMemoryStatus } from './session-cage.js';
|
|
7
9
|
import type { RunnerWsClient } from './ws-client.js';
|
|
8
10
|
import type { SessionDescriptor } from './protocol.js';
|
|
9
11
|
import type { AgentAdapter } from './adapters/types.js';
|
|
@@ -122,6 +124,37 @@ export interface SupervisorOptions {
|
|
|
122
124
|
* in production.
|
|
123
125
|
*/
|
|
124
126
|
stopSettleMs?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Test seam for the host-load measurement (§5.3).
|
|
129
|
+
*
|
|
130
|
+
* A seam because the real one reads THIS machine's `/proc`, and a test that
|
|
131
|
+
* had to arrange the kernel into «load 12.4, swap 98 % used» would not be
|
|
132
|
+
* written — so the rule this frame exists for (send only what moved) would
|
|
133
|
+
* go unverified on the one machine shape that matters.
|
|
134
|
+
*/
|
|
135
|
+
readHostLoad?: () => HostLoadFrame | null;
|
|
136
|
+
/**
|
|
137
|
+
* How often `/proc` is sampled, in ms — a test seam over
|
|
138
|
+
* `HOST_LOAD_SAMPLE_INTERVAL_MS`, for the same reason `agentCleanupMs`
|
|
139
|
+
* exists: the real cadence is 30 s and this suite runs on real timers (fake
|
|
140
|
+
* ones fire the WS client's liveness watchdog and kill the socket). Never
|
|
141
|
+
* set in production.
|
|
142
|
+
*/
|
|
143
|
+
hostLoadSampleMs?: number;
|
|
144
|
+
/**
|
|
145
|
+
* The heartbeat window, in ms — a test seam over `HOST_LOAD_HEARTBEAT_MS`.
|
|
146
|
+
* The real one is five minutes; without this seam «an unchanged machine
|
|
147
|
+
* still reports» would be an untested promise. Never set in production.
|
|
148
|
+
*/
|
|
149
|
+
hostLoadHeartbeatMs?: number;
|
|
150
|
+
/**
|
|
151
|
+
* #387: the memory watch's two readers, so a test can put a caged session
|
|
152
|
+
* over its brake without a cgroup under it. Production reads the cage module.
|
|
153
|
+
*/
|
|
154
|
+
sessionScopeUnitOf?: (id: string) => string | null;
|
|
155
|
+
readScopeMemoryStatus?: (unit: string) => ScopeMemoryStatus | null;
|
|
156
|
+
/** How long over the brake before the second, «do something» warning. A test seam. */
|
|
157
|
+
cageBrakeLongMs?: number;
|
|
125
158
|
}
|
|
126
159
|
export declare class Supervisor {
|
|
127
160
|
private readonly ws;
|
|
@@ -198,6 +231,23 @@ export declare class Supervisor {
|
|
|
198
231
|
private readonly verify;
|
|
199
232
|
private readonly verifyReports;
|
|
200
233
|
constructor(ws: RunnerWsClient, opts: SupervisorOptions);
|
|
234
|
+
/**
|
|
235
|
+
* How long a session may sit over its brake before the feed says more than
|
|
236
|
+
* «slower» (#387, step 3). Five minutes is a starting point, not a
|
|
237
|
+
* measurement: an honest `pnpm typecheck` overshoots for tens of seconds, a
|
|
238
|
+
* runaway for ever, and the number that tells them apart on real machines is
|
|
239
|
+
* still to be read off `memory.events`. Sized so the first warning is never
|
|
240
|
+
* the only one a night-time run leaves behind.
|
|
241
|
+
*/
|
|
242
|
+
private static readonly CAGE_BRAKE_LONG_MS;
|
|
243
|
+
/**
|
|
244
|
+
* Calm ticks before the feed says the braking is over.
|
|
245
|
+
*
|
|
246
|
+
* `memory.high` holds usage AT the line, so a single sample under it means
|
|
247
|
+
* nothing. Two (a minute at the 30 s cadence) is the difference between an
|
|
248
|
+
* episode that ended and a build that breathed.
|
|
249
|
+
*/
|
|
250
|
+
private static readonly CAGE_CALM_TICKS_TO_RELEASE;
|
|
201
251
|
/** How often the agent versions are re-derived. See the constructor. */
|
|
202
252
|
private static readonly AGENT_VERSIONS_INTERVAL_MS;
|
|
203
253
|
private readonly agentVersionsTimer;
|
|
@@ -346,6 +396,62 @@ export declare class Supervisor {
|
|
|
346
396
|
* it had room. Both were caught by the independent QA review of this change.
|
|
347
397
|
*/
|
|
348
398
|
private publishSlots;
|
|
399
|
+
/** The heartbeat window actually used — the constant, or a test's own. */
|
|
400
|
+
private readonly hostLoadHeartbeatMs;
|
|
401
|
+
private readonly hostLoadTimer;
|
|
402
|
+
/** The last measurement the API actually took from us, or `null` for «nothing yet». */
|
|
403
|
+
private lastPublishedHostLoad;
|
|
404
|
+
/** When that frame went out, by this machine's clock. `0` = never. */
|
|
405
|
+
private lastHostLoadSentAt;
|
|
406
|
+
/**
|
|
407
|
+
* Tell the API what this machine's load looks like — when it is worth telling.
|
|
408
|
+
*
|
|
409
|
+
* Three ways a frame goes out, and each covers a hole the others leave:
|
|
410
|
+
*
|
|
411
|
+
* - the value MOVED past a threshold (`hostLoadChangedEnough`) — the reason
|
|
412
|
+
* the frame exists, and the only one that makes the card timely;
|
|
413
|
+
* - the heartbeat came due — an idle machine still has to say «still here,
|
|
414
|
+
* still idle», because the API expires the measurement after two minutes
|
|
415
|
+
* and silence would otherwise turn «nothing is happening» into «we have no
|
|
416
|
+
* idea» on a perfectly healthy card;
|
|
417
|
+
* - nothing was ever sent on this connection (`hello_ack` clears the memory)
|
|
418
|
+
* — a fresh socket knows nothing about what the last one was told.
|
|
419
|
+
*
|
|
420
|
+
* `null` from the sampler is a complete answer: not Linux, `/proc` masked, a
|
|
421
|
+
* kernel too old for `MemAvailable`. Nothing is sent and nothing is logged —
|
|
422
|
+
* a machine that cannot measure itself leaves the card saying «no data»,
|
|
423
|
+
* which is exactly what is true.
|
|
424
|
+
*
|
|
425
|
+
* Recorded ONLY when the socket took it, same as `publishSlots`: a frame
|
|
426
|
+
* dropped by a dead socket must not be remembered as sent, or a machine whose
|
|
427
|
+
* load never moves again would go silent until the heartbeat — and on a
|
|
428
|
+
* reconnect the API would have no measurement at all while this side believed
|
|
429
|
+
* it had one.
|
|
430
|
+
*/
|
|
431
|
+
/**
|
|
432
|
+
* Tell the person what the kernel is doing to each caged session (#387).
|
|
433
|
+
*
|
|
434
|
+
* Three things the cgroup can say, each with its own line in the feed:
|
|
435
|
+
*
|
|
436
|
+
* - the brake engaged (`memory.current` over `memory.high`): the session is
|
|
437
|
+
* being slowed down, not killed. Said once per episode, and again when it
|
|
438
|
+
* has lasted `CAGE_BRAKE_LONG_MS` — that second line is the one that
|
|
439
|
+
* suggests an action, because by then «slower» may mean «stuck»;
|
|
440
|
+
* - the brake released: said once, so a person who saw the warning knows the
|
|
441
|
+
* wait is over without reading the numbers;
|
|
442
|
+
* - `oom_kill` moved: the kernel killed a process inside the session. With
|
|
443
|
+
* `OOMPolicy=continue` the session itself is still here, and the person
|
|
444
|
+
* has to be told that the command that was running is probably what died.
|
|
445
|
+
*
|
|
446
|
+
* Until this tick existed the runner knew all three and wrote them to its own
|
|
447
|
+
* log only; the feed said «exited with code 143» and nothing about memory.
|
|
448
|
+
*
|
|
449
|
+
* A parked session has no process and no cgroup, and is skipped without a
|
|
450
|
+
* read. A session whose cgroup cannot be read (no cage on this machine, or
|
|
451
|
+
* the scope already gone) is skipped the same way: the watch never guesses.
|
|
452
|
+
*/
|
|
453
|
+
private watchSessionCages;
|
|
454
|
+
private publishHostLoad;
|
|
349
455
|
private onFrame;
|
|
350
456
|
private startSession;
|
|
351
457
|
/**
|
package/dist/supervisor.js
CHANGED
|
@@ -25,6 +25,8 @@ import { pruneNativeClaudeVersions } from './agent-cleanup.js';
|
|
|
25
25
|
import { agentByDbValue } from './agent-registry.js';
|
|
26
26
|
import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
|
|
27
27
|
import { rememberWorkspacePath } from './environment.js';
|
|
28
|
+
import { hostLoadChangedEnough, hostLoadHeartbeatDue, readHostLoad, HOST_LOAD_HEARTBEAT_MS, HOST_LOAD_SAMPLE_INTERVAL_MS, } from './host-load.js';
|
|
29
|
+
import { markScopeOomKillsSeen, readScopeMemoryStatus, sessionScopeUnitOf, } from './session-cage.js';
|
|
28
30
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
29
31
|
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, MAX_BUSY_SESSIONS, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
30
32
|
import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
|
|
@@ -209,7 +211,44 @@ export class Supervisor {
|
|
|
209
211
|
this.agentCleanupFirstTimer.unref?.();
|
|
210
212
|
this.agentCleanupTimer = setInterval(() => this.sweepOldAgentVersions(), cleanupEvery);
|
|
211
213
|
this.agentCleanupTimer.unref?.();
|
|
214
|
+
/**
|
|
215
|
+
* The machine's own load, sampled on a timer for the reason the seat report
|
|
216
|
+
* is: nothing on this side is an EVENT. Load is not something the runner
|
|
217
|
+
* does, it is something that happens to it — a `pnpm build` a person
|
|
218
|
+
* started over ssh, a neighbour container, this machine's own three agents
|
|
219
|
+
* — and there is no call site to hook. A tick that re-reads the truth is
|
|
220
|
+
* the only shape that cannot be forgotten.
|
|
221
|
+
*
|
|
222
|
+
* Nearly free, and quiet by default: two small files are read, and
|
|
223
|
+
* `publishHostLoad` returns without touching the socket unless the number
|
|
224
|
+
* actually moved (or the heartbeat came due).
|
|
225
|
+
*/
|
|
226
|
+
this.hostLoadHeartbeatMs = opts.hostLoadHeartbeatMs ?? HOST_LOAD_HEARTBEAT_MS;
|
|
227
|
+
this.hostLoadTimer = setInterval(() => {
|
|
228
|
+
this.publishHostLoad();
|
|
229
|
+
// Same cadence, same reason: what a session's cgroup is going through is
|
|
230
|
+
// something that happens to it, and only a tick can notice (#387).
|
|
231
|
+
this.watchSessionCages();
|
|
232
|
+
}, opts.hostLoadSampleMs ?? HOST_LOAD_SAMPLE_INTERVAL_MS);
|
|
233
|
+
this.hostLoadTimer.unref?.();
|
|
212
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* How long a session may sit over its brake before the feed says more than
|
|
237
|
+
* «slower» (#387, step 3). Five minutes is a starting point, not a
|
|
238
|
+
* measurement: an honest `pnpm typecheck` overshoots for tens of seconds, a
|
|
239
|
+
* runaway for ever, and the number that tells them apart on real machines is
|
|
240
|
+
* still to be read off `memory.events`. Sized so the first warning is never
|
|
241
|
+
* the only one a night-time run leaves behind.
|
|
242
|
+
*/
|
|
243
|
+
static CAGE_BRAKE_LONG_MS = 5 * 60_000;
|
|
244
|
+
/**
|
|
245
|
+
* Calm ticks before the feed says the braking is over.
|
|
246
|
+
*
|
|
247
|
+
* `memory.high` holds usage AT the line, so a single sample under it means
|
|
248
|
+
* nothing. Two (a minute at the 30 s cadence) is the difference between an
|
|
249
|
+
* episode that ended and a build that breathed.
|
|
250
|
+
*/
|
|
251
|
+
static CAGE_CALM_TICKS_TO_RELEASE = 2;
|
|
213
252
|
/** How often the agent versions are re-derived. See the constructor. */
|
|
214
253
|
static AGENT_VERSIONS_INTERVAL_MS = 60 * 60 * 1_000;
|
|
215
254
|
agentVersionsTimer;
|
|
@@ -572,6 +611,201 @@ export class Supervisor {
|
|
|
572
611
|
this.lastPublishedSlots = fingerprint;
|
|
573
612
|
}
|
|
574
613
|
}
|
|
614
|
+
/** The heartbeat window actually used — the constant, or a test's own. */
|
|
615
|
+
hostLoadHeartbeatMs;
|
|
616
|
+
hostLoadTimer;
|
|
617
|
+
/** The last measurement the API actually took from us, or `null` for «nothing yet». */
|
|
618
|
+
lastPublishedHostLoad = null;
|
|
619
|
+
/** When that frame went out, by this machine's clock. `0` = never. */
|
|
620
|
+
lastHostLoadSentAt = 0;
|
|
621
|
+
/**
|
|
622
|
+
* Tell the API what this machine's load looks like — when it is worth telling.
|
|
623
|
+
*
|
|
624
|
+
* Three ways a frame goes out, and each covers a hole the others leave:
|
|
625
|
+
*
|
|
626
|
+
* - the value MOVED past a threshold (`hostLoadChangedEnough`) — the reason
|
|
627
|
+
* the frame exists, and the only one that makes the card timely;
|
|
628
|
+
* - the heartbeat came due — an idle machine still has to say «still here,
|
|
629
|
+
* still idle», because the API expires the measurement after two minutes
|
|
630
|
+
* and silence would otherwise turn «nothing is happening» into «we have no
|
|
631
|
+
* idea» on a perfectly healthy card;
|
|
632
|
+
* - nothing was ever sent on this connection (`hello_ack` clears the memory)
|
|
633
|
+
* — a fresh socket knows nothing about what the last one was told.
|
|
634
|
+
*
|
|
635
|
+
* `null` from the sampler is a complete answer: not Linux, `/proc` masked, a
|
|
636
|
+
* kernel too old for `MemAvailable`. Nothing is sent and nothing is logged —
|
|
637
|
+
* a machine that cannot measure itself leaves the card saying «no data»,
|
|
638
|
+
* which is exactly what is true.
|
|
639
|
+
*
|
|
640
|
+
* Recorded ONLY when the socket took it, same as `publishSlots`: a frame
|
|
641
|
+
* dropped by a dead socket must not be remembered as sent, or a machine whose
|
|
642
|
+
* load never moves again would go silent until the heartbeat — and on a
|
|
643
|
+
* reconnect the API would have no measurement at all while this side believed
|
|
644
|
+
* it had one.
|
|
645
|
+
*/
|
|
646
|
+
/**
|
|
647
|
+
* Tell the person what the kernel is doing to each caged session (#387).
|
|
648
|
+
*
|
|
649
|
+
* Three things the cgroup can say, each with its own line in the feed:
|
|
650
|
+
*
|
|
651
|
+
* - the brake engaged (`memory.current` over `memory.high`): the session is
|
|
652
|
+
* being slowed down, not killed. Said once per episode, and again when it
|
|
653
|
+
* has lasted `CAGE_BRAKE_LONG_MS` — that second line is the one that
|
|
654
|
+
* suggests an action, because by then «slower» may mean «stuck»;
|
|
655
|
+
* - the brake released: said once, so a person who saw the warning knows the
|
|
656
|
+
* wait is over without reading the numbers;
|
|
657
|
+
* - `oom_kill` moved: the kernel killed a process inside the session. With
|
|
658
|
+
* `OOMPolicy=continue` the session itself is still here, and the person
|
|
659
|
+
* has to be told that the command that was running is probably what died.
|
|
660
|
+
*
|
|
661
|
+
* Until this tick existed the runner knew all three and wrote them to its own
|
|
662
|
+
* log only; the feed said «exited with code 143» and nothing about memory.
|
|
663
|
+
*
|
|
664
|
+
* A parked session has no process and no cgroup, and is skipped without a
|
|
665
|
+
* read. A session whose cgroup cannot be read (no cage on this machine, or
|
|
666
|
+
* the scope already gone) is skipped the same way: the watch never guesses.
|
|
667
|
+
*/
|
|
668
|
+
watchSessionCages() {
|
|
669
|
+
const unitOf = this.opts.sessionScopeUnitOf ?? sessionScopeUnitOf;
|
|
670
|
+
const read = this.opts.readScopeMemoryStatus ?? readScopeMemoryStatus;
|
|
671
|
+
const longMs = this.opts.cageBrakeLongMs ?? Supervisor.CAGE_BRAKE_LONG_MS;
|
|
672
|
+
const now = Date.now();
|
|
673
|
+
const mb = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
674
|
+
for (const running of this.sessions.values()) {
|
|
675
|
+
if (running.session === null)
|
|
676
|
+
continue;
|
|
677
|
+
const unit = unitOf(running.descriptor.id);
|
|
678
|
+
if (unit === null)
|
|
679
|
+
continue;
|
|
680
|
+
const status = read(unit);
|
|
681
|
+
if (status === null)
|
|
682
|
+
continue;
|
|
683
|
+
const seen = running.cageWatch;
|
|
684
|
+
/**
|
|
685
|
+
* A cgroup this watch has not seen before starts from zero.
|
|
686
|
+
*
|
|
687
|
+
* Two ways that happens, and the second is why the counters are checked
|
|
688
|
+
* as well as the name: a session relaunched after its scope was released
|
|
689
|
+
* gets the SAME unit name back (`sessionScopeUnit` falls back to the
|
|
690
|
+
* plain name once the failed unit is reset), so «same name» does not mean
|
|
691
|
+
* «same cgroup». Counters that went backwards can only be a new one —
|
|
692
|
+
* without this the episode of the dead process carried over, and the
|
|
693
|
+
* feed opened with «memory is back under the share» for a process that
|
|
694
|
+
* had never been over it (#387 QA).
|
|
695
|
+
*/
|
|
696
|
+
const carriedOver = seen !== undefined &&
|
|
697
|
+
seen.unit === unit &&
|
|
698
|
+
status.oomKills >= seen.oomKills &&
|
|
699
|
+
status.highEvents >= seen.highEvents &&
|
|
700
|
+
status.ownLimitOom >= seen.ownOom;
|
|
701
|
+
const base = carriedOver
|
|
702
|
+
? seen
|
|
703
|
+
: {
|
|
704
|
+
unit,
|
|
705
|
+
oomKills: 0,
|
|
706
|
+
ownOom: 0,
|
|
707
|
+
highEvents: 0,
|
|
708
|
+
brakedSince: null,
|
|
709
|
+
warnedLong: false,
|
|
710
|
+
calmTicks: 0,
|
|
711
|
+
};
|
|
712
|
+
if (status.oomKills > base.oomKills) {
|
|
713
|
+
// WHOSE limit was hit decides the sentence. `oom` moves only in the
|
|
714
|
+
// cgroup whose own ceiling was reached; a kill with our counter still
|
|
715
|
+
// where it was came from the slice over ALL sessions, and telling this
|
|
716
|
+
// session it went over its ceiling would be false (measured on this
|
|
717
|
+
// host: the killed neighbour read `max 0, oom 0, oom_kill 1`).
|
|
718
|
+
const ownWall = status.ownLimitOom > base.ownOom;
|
|
719
|
+
const killed = status.oomKills - base.oomKills === 1
|
|
720
|
+
? 'a process'
|
|
721
|
+
: `${status.oomKills - base.oomKills} processes`;
|
|
722
|
+
this.sendEvent(running, 'notice', {
|
|
723
|
+
level: 'warn',
|
|
724
|
+
text: ownWall
|
|
725
|
+
? `This session went over its memory ceiling${status.maxBytes === null ? '' : ` of ${mb(status.maxBytes)}`} and the kernel killed ${killed} inside it. ` +
|
|
726
|
+
'The session itself is still running; the command that was in flight most likely died with it — check its output before going on.'
|
|
727
|
+
: `The machine ran out of memory for agent sessions — they share one ceiling — and the kernel killed ${killed} in this session. ` +
|
|
728
|
+
`This session was using ${mb(status.currentBytes)}${status.maxBytes === null ? '' : ` of its own ${mb(status.maxBytes)}`}, so it was not necessarily the greedy one. ` +
|
|
729
|
+
'It is still running; the command that was in flight most likely died with it.',
|
|
730
|
+
});
|
|
731
|
+
// So a death minutes later is not blamed on a kill the feed already carries.
|
|
732
|
+
markScopeOomKillsSeen(running.descriptor.id, status.oomKills);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* «Being braked» is the EVENT counter first, the level second.
|
|
736
|
+
*
|
|
737
|
+
* `memory.high` works by holding usage at the line: a 30 s sample of
|
|
738
|
+
* `current > high` lands over it and under it in turn, which produced a
|
|
739
|
+
* warning and an all-clear on alternate ticks for one long build. The
|
|
740
|
+
* `high` counter only ever moves when the kernel actually throttled this
|
|
741
|
+
* cgroup, so a delta is the honest answer to «is it being slowed down
|
|
742
|
+
* right now», and the level is kept as the answer for a session that is
|
|
743
|
+
* sitting over the line without allocating.
|
|
744
|
+
*/
|
|
745
|
+
const throttled = status.highEvents > base.highEvents ||
|
|
746
|
+
(status.highBytes !== null && status.currentBytes > status.highBytes);
|
|
747
|
+
let { brakedSince, warnedLong, calmTicks } = base;
|
|
748
|
+
if (throttled) {
|
|
749
|
+
calmTicks = 0;
|
|
750
|
+
if (brakedSince === null) {
|
|
751
|
+
brakedSince = now;
|
|
752
|
+
warnedLong = false;
|
|
753
|
+
this.sendEvent(running, 'notice', {
|
|
754
|
+
level: 'warn',
|
|
755
|
+
text: `This session is over its memory share (${mb(status.highBytes ?? 0)}, using ${mb(status.currentBytes)}), so the kernel is slowing it down instead of killing it. ` +
|
|
756
|
+
'Heavy commands will finish slower until memory is freed.',
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
else if (!warnedLong && now - brakedSince >= longMs) {
|
|
760
|
+
warnedLong = true;
|
|
761
|
+
this.sendEvent(running, 'notice', {
|
|
762
|
+
level: 'warn',
|
|
763
|
+
text: `Still over its memory share after ${Math.round(longMs / 60_000)} min (using ${mb(status.currentBytes)} of a ${mb(status.highBytes ?? 0)} share). ` +
|
|
764
|
+
`If the current command is stuck rather than slow, stop it or pause the session.${status.maxBytes === null
|
|
765
|
+
? ''
|
|
766
|
+
: ` The hard ceiling, where a process would be killed, is ${mb(status.maxBytes)}.`}`,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
else if (brakedSince !== null) {
|
|
771
|
+
// One calm sample is noise — see above. Two in a row is an episode that
|
|
772
|
+
// really ended, and only then is the all-clear worth a line in the feed.
|
|
773
|
+
calmTicks += 1;
|
|
774
|
+
if (calmTicks >= Supervisor.CAGE_CALM_TICKS_TO_RELEASE) {
|
|
775
|
+
brakedSince = null;
|
|
776
|
+
warnedLong = false;
|
|
777
|
+
this.sendEvent(running, 'notice', {
|
|
778
|
+
level: 'info',
|
|
779
|
+
text: `Memory is back under the session's share (${mb(status.highBytes ?? 0)}); it runs at full speed again.`,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
running.cageWatch = {
|
|
784
|
+
unit,
|
|
785
|
+
oomKills: status.oomKills,
|
|
786
|
+
ownOom: status.ownLimitOom,
|
|
787
|
+
highEvents: status.highEvents,
|
|
788
|
+
brakedSince,
|
|
789
|
+
warnedLong,
|
|
790
|
+
calmTicks,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
publishHostLoad() {
|
|
795
|
+
const sample = (this.opts.readHostLoad ?? readHostLoad)();
|
|
796
|
+
if (!sample)
|
|
797
|
+
return;
|
|
798
|
+
const now = Date.now();
|
|
799
|
+
const sinceLastSent = now - this.lastHostLoadSentAt;
|
|
800
|
+
// A backwards clock step is «due», not «early» — see `hostLoadHeartbeatDue`.
|
|
801
|
+
const heartbeatDue = hostLoadHeartbeatDue(sinceLastSent, this.hostLoadHeartbeatMs);
|
|
802
|
+
if (!heartbeatDue && !hostLoadChangedEnough(this.lastPublishedHostLoad, sample))
|
|
803
|
+
return;
|
|
804
|
+
if (this.ws.send({ type: 'host_load', ...sample })) {
|
|
805
|
+
this.lastPublishedHostLoad = sample;
|
|
806
|
+
this.lastHostLoadSentAt = now;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
575
809
|
async onFrame(frame) {
|
|
576
810
|
switch (frame.type) {
|
|
577
811
|
case 'hello_ack':
|
|
@@ -580,9 +814,17 @@ export class Supervisor {
|
|
|
580
814
|
// because it is only true while the socket is. Forget what we told the
|
|
581
815
|
// old one so the first tick after this reconnect actually sends.
|
|
582
816
|
this.lastPublishedSlots = '';
|
|
817
|
+
// Same rule for the load: the API keeps the measurement in Redis beside
|
|
818
|
+
// the socket, with a TTL shorter than our heartbeat, so a machine that
|
|
819
|
+
// has just come back has no load on its card at all. Forget what the
|
|
820
|
+
// last connection was told and say it again immediately — a card that
|
|
821
|
+
// is right only after five minutes of silence is not right.
|
|
822
|
+
this.lastPublishedHostLoad = null;
|
|
823
|
+
this.lastHostLoadSentAt = 0;
|
|
583
824
|
this.setMaxSessions(frame.maxSessions);
|
|
584
825
|
await this.reconcile(frame.sessions);
|
|
585
826
|
this.publishSlots();
|
|
827
|
+
this.publishHostLoad();
|
|
586
828
|
// A build that finished while the socket was down has its verdict
|
|
587
829
|
// sitting on disk. This is the moment it can be delivered.
|
|
588
830
|
this.flushVerifyReports();
|
|
@@ -5805,6 +6047,7 @@ export class Supervisor {
|
|
|
5805
6047
|
/** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
|
|
5806
6048
|
shutdown() {
|
|
5807
6049
|
clearInterval(this.slotsTimer);
|
|
6050
|
+
clearInterval(this.hostLoadTimer);
|
|
5808
6051
|
clearInterval(this.agentVersionsTimer);
|
|
5809
6052
|
clearTimeout(this.agentCleanupFirstTimer);
|
|
5810
6053
|
clearInterval(this.agentCleanupTimer);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type CgroupMemory, type MemoryFacts } from './service-unit.js';
|
|
2
|
+
/**
|
|
3
|
+
* Read the unit's group, never the CLI's own `/proc/self` group. A missing
|
|
4
|
+
* reading is not an empty service: a timed-out user bus can belong to a busy
|
|
5
|
+
* machine. Raw `memory.current` deliberately survives beside the split, so the
|
|
6
|
+
* safety floor never depends on an estimate of how much can be reclaimed.
|
|
7
|
+
*/
|
|
8
|
+
export declare function unitMemoryReading(output: string, cgroupRoot?: string): CgroupMemory | null;
|
|
9
|
+
/**
|
|
10
|
+
* Both units, measured through systemd, with the filesystem underneath.
|
|
11
|
+
*
|
|
12
|
+
* One helper so `doctor`, `doctor --fix`, `install-service` and the daemon's
|
|
13
|
+
* hourly re-measure cannot drift apart in what they measure — which is how the
|
|
14
|
+
* CLI once computed a ceiling 34 % away from the daemon's and the two rewrote
|
|
15
|
+
* the file forever.
|
|
16
|
+
*
|
|
17
|
+
* The fallback is not a second opinion, it is the same reading taken another
|
|
18
|
+
* way, and it is safe for every caller precisely because each reader
|
|
19
|
+
* self-identifies: `readOwnCgroupMemory` hands back null for any process that
|
|
20
|
+
* is not the service itself, so the daemon measures itself when the bus is
|
|
21
|
+
* unhappy and a CLI never mistakes its own `session-N.scope` for the service.
|
|
22
|
+
* Without it, one slow `systemctl` meant the daemon wrote no policy at all —
|
|
23
|
+
* for an hour, on the overloaded machine this policy exists to protect.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readMemoryFactsFromSystemd(): Promise<MemoryReadings>;
|
|
26
|
+
export interface MemoryReadings {
|
|
27
|
+
facts: MemoryFacts | null;
|
|
28
|
+
sessionsUsageBytes: number | null;
|
|
29
|
+
}
|
|
30
|
+
/** The half of the above that has no bus in it, so it can be tested. */
|
|
31
|
+
export declare function memoryReadings(service: CgroupMemory | null, sessions: CgroupMemory | null, fromFilesystem?: {
|
|
32
|
+
own: () => CgroupMemory | null;
|
|
33
|
+
sessions: () => CgroupMemory | null;
|
|
34
|
+
}): MemoryReadings;
|
|
35
|
+
//# sourceMappingURL=systemd-memory.d.ts.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { systemdUserEnv } from './environment.js';
|
|
6
|
+
import { readCgroupMemory, readMemoryFacts, readOwnCgroupMemory, readSessionsSliceMemory, SERVICE_NAME, SESSIONS_SLICE, } from './service-unit.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
function bytes(raw) {
|
|
9
|
+
if (raw === undefined || !/^\d+$/.test(raw.trim()))
|
|
10
|
+
return null;
|
|
11
|
+
const value = Number(raw);
|
|
12
|
+
// systemd prints UINT64_MAX for «not set» on some versions, which is above
|
|
13
|
+
// `MAX_SAFE_INTEGER` and would otherwise arrive as a plausible byte count.
|
|
14
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read the unit's group, never the CLI's own `/proc/self` group. A missing
|
|
18
|
+
* reading is not an empty service: a timed-out user bus can belong to a busy
|
|
19
|
+
* machine. Raw `memory.current` deliberately survives beside the split, so the
|
|
20
|
+
* safety floor never depends on an estimate of how much can be reclaimed.
|
|
21
|
+
*/
|
|
22
|
+
export function unitMemoryReading(output, cgroupRoot = '/sys/fs/cgroup') {
|
|
23
|
+
const properties = new Map(output.split('\n').map((line) => {
|
|
24
|
+
const separator = line.indexOf('=');
|
|
25
|
+
return [line.slice(0, separator), line.slice(separator + 1).trim()];
|
|
26
|
+
}));
|
|
27
|
+
// An empty answer is not a fact about the unit: everything below reads meaning
|
|
28
|
+
// into what systemd said, so there has to have been an answer first. A dead
|
|
29
|
+
// bus prints nothing and stays «unknown». `ControlGroup` is not required to be
|
|
30
|
+
// PRESENT — only to be empty where it decides emptiness below — so a systemd
|
|
31
|
+
// that does not print it leaves «unknown» too, and the filesystem reader
|
|
32
|
+
// behind this one covers that machine.
|
|
33
|
+
if (!properties.has('ActiveState'))
|
|
34
|
+
return null;
|
|
35
|
+
const current = bytes(properties.get('MemoryCurrent'));
|
|
36
|
+
const group = properties.get('ControlGroup');
|
|
37
|
+
if (group !== undefined && group.startsWith('/') && group !== '/') {
|
|
38
|
+
const root = path.resolve(cgroupRoot);
|
|
39
|
+
const directory = path.resolve(root, `.${group}`);
|
|
40
|
+
if (directory.startsWith(`${root}${path.sep}`)) {
|
|
41
|
+
try {
|
|
42
|
+
const measured = readCgroupMemory(directory);
|
|
43
|
+
if (measured !== null)
|
|
44
|
+
return measured;
|
|
45
|
+
const raw = bytes(fs.readFileSync(path.join(directory, 'memory.current'), 'utf8'));
|
|
46
|
+
// Without `memory.stat` the split between «cache we can give back» and
|
|
47
|
+
// «memory that has to be killed for» is unknown, and null says so: the
|
|
48
|
+
// floor then assumes all of it, the headroom none of it.
|
|
49
|
+
if (raw !== null)
|
|
50
|
+
return { currentBytes: raw, unreclaimableBytes: null };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Unsupported controller, removed cgroup or unreadable file: unknown.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (current !== null)
|
|
58
|
+
return { currentBytes: current, unreclaimableBytes: null };
|
|
59
|
+
// A unit with NO cgroup holds nothing a new ceiling could kill, whatever its
|
|
60
|
+
// ActiveState says — and that is the whole point of not asking for
|
|
61
|
+
// `inactive` here. `failed` and `activating (auto-restart)` report exactly
|
|
62
|
+
// the shape `inactive` does (verified on systemd 255: `ControlGroup=`,
|
|
63
|
+
// `MemoryCurrent=[not set]`), and those are precisely the states a machine is
|
|
64
|
+
// in when someone runs `install.sh --repair` or `doctor --fix` on it. Reading
|
|
65
|
+
// them as «unknown» turned the cure into a refusal on the only machines that
|
|
66
|
+
// need it.
|
|
67
|
+
return group === '' ? { currentBytes: 0, unreclaimableBytes: 0 } : null;
|
|
68
|
+
}
|
|
69
|
+
async function readUnitUsage(unit) {
|
|
70
|
+
try {
|
|
71
|
+
const { stdout } = await execFileAsync('systemctl', ['--user', 'show', unit, '-p', 'MemoryCurrent', '-p', 'ActiveState', '-p', 'ControlGroup'], { timeout: 10_000, env: systemdUserEnv() });
|
|
72
|
+
return unitMemoryReading(stdout);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Both units, measured through systemd, with the filesystem underneath.
|
|
80
|
+
*
|
|
81
|
+
* One helper so `doctor`, `doctor --fix`, `install-service` and the daemon's
|
|
82
|
+
* hourly re-measure cannot drift apart in what they measure — which is how the
|
|
83
|
+
* CLI once computed a ceiling 34 % away from the daemon's and the two rewrote
|
|
84
|
+
* the file forever.
|
|
85
|
+
*
|
|
86
|
+
* The fallback is not a second opinion, it is the same reading taken another
|
|
87
|
+
* way, and it is safe for every caller precisely because each reader
|
|
88
|
+
* self-identifies: `readOwnCgroupMemory` hands back null for any process that
|
|
89
|
+
* is not the service itself, so the daemon measures itself when the bus is
|
|
90
|
+
* unhappy and a CLI never mistakes its own `session-N.scope` for the service.
|
|
91
|
+
* Without it, one slow `systemctl` meant the daemon wrote no policy at all —
|
|
92
|
+
* for an hour, on the overloaded machine this policy exists to protect.
|
|
93
|
+
*/
|
|
94
|
+
export async function readMemoryFactsFromSystemd() {
|
|
95
|
+
const [service, sessions] = await Promise.all([
|
|
96
|
+
readUnitUsage(SERVICE_NAME),
|
|
97
|
+
readUnitUsage(SESSIONS_SLICE),
|
|
98
|
+
]);
|
|
99
|
+
return memoryReadings(service, sessions);
|
|
100
|
+
}
|
|
101
|
+
/** The half of the above that has no bus in it, so it can be tested. */
|
|
102
|
+
export function memoryReadings(service, sessions, fromFilesystem = { own: readOwnCgroupMemory, sessions: readSessionsSliceMemory }) {
|
|
103
|
+
const serviceMemory = service ?? fromFilesystem.own();
|
|
104
|
+
const sessionsMemory = sessions ?? fromFilesystem.sessions();
|
|
105
|
+
return {
|
|
106
|
+
facts: readMemoryFacts(serviceMemory, sessionsMemory),
|
|
107
|
+
// The floor reading, and «unknown» kept apart from «empty»: this number is
|
|
108
|
+
// the only thing standing between a live slice and the ceiling written by
|
|
109
|
+
// a command typed on a machine whose service cannot be measured.
|
|
110
|
+
sessionsUsageBytes: sessionsMemory === null
|
|
111
|
+
? null
|
|
112
|
+
: (sessionsMemory.unreclaimableBytes ?? sessionsMemory.currentBytes),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=systemd-memory.js.map
|
package/dist/verify.js
CHANGED
|
@@ -5,6 +5,8 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import { log } from './log.js';
|
|
6
6
|
import { maskString } from './policy.js';
|
|
7
7
|
import { evaluateRecipeCommand } from './policy.js';
|
|
8
|
+
import { lowerPriority } from './process-priority.js';
|
|
9
|
+
import { cageSpawn, releaseSessionScope } from './session-cage.js';
|
|
8
10
|
import { recipeFingerprint } from './recipe.js';
|
|
9
11
|
import { StringDecoder } from 'node:string_decoder';
|
|
10
12
|
import { stateDir } from './paths.js';
|
|
@@ -301,6 +303,7 @@ export class VerifyRunner {
|
|
|
301
303
|
logPath,
|
|
302
304
|
logBytes: 0,
|
|
303
305
|
child: null,
|
|
306
|
+
scopeUnit: null,
|
|
304
307
|
cancelled: false,
|
|
305
308
|
branch: input.branch,
|
|
306
309
|
commitSha: input.commitSha,
|
|
@@ -540,6 +543,11 @@ export class VerifyRunner {
|
|
|
540
543
|
settled = true;
|
|
541
544
|
clearTimeout(timer);
|
|
542
545
|
run.child = null;
|
|
546
|
+
// Read why the scope ended and let systemd forget it, in that order —
|
|
547
|
+
// a scope the OOM killer took stays in `failed` and would refuse the
|
|
548
|
+
// very same unit name to the next step (`session-cage.ts`).
|
|
549
|
+
void releaseSessionScope(run.scopeUnit, `verify-${run.runId}`);
|
|
550
|
+
run.scopeUnit = null;
|
|
543
551
|
resolve({
|
|
544
552
|
step: run.currentStep ?? '',
|
|
545
553
|
durationMs: this.now() - startedAt,
|
|
@@ -547,14 +555,26 @@ export class VerifyRunner {
|
|
|
547
555
|
...(timedOut ? { timedOut: true } : {}),
|
|
548
556
|
});
|
|
549
557
|
};
|
|
558
|
+
// A verification step is the heaviest thing on the machine that has an id
|
|
559
|
+
// of its own, so it gets a cage of its own too — keyed by the run rather
|
|
560
|
+
// than by a session, because that is the identity a verify run has. The
|
|
561
|
+
// scope survives `detached: true`: `systemd-run --scope` execs in the
|
|
562
|
+
// same pid, so `setsid` still makes the child its own group leader and
|
|
563
|
+
// `process.kill(-pid)` below still reaches the compilers.
|
|
564
|
+
const caged = cageSpawn({
|
|
565
|
+
id: `verify-${run.runId}`,
|
|
566
|
+
command: '/bin/sh',
|
|
567
|
+
args: ['-c', step.run],
|
|
568
|
+
});
|
|
569
|
+
run.scopeUnit = caged.unit;
|
|
550
570
|
let child;
|
|
551
571
|
try {
|
|
552
572
|
// `detached` so the whole process tree gets the signal: a build script
|
|
553
573
|
// is a shell that spawns compilers, and killing only the shell leaves
|
|
554
574
|
// them running with the disk and the CPU.
|
|
555
|
-
child = spawn(
|
|
575
|
+
child = spawn(caged.command, caged.args, {
|
|
556
576
|
cwd: run.cwd,
|
|
557
|
-
env: buildEnv(step.env),
|
|
577
|
+
env: { ...buildEnv(step.env), ...caged.env },
|
|
558
578
|
detached: true,
|
|
559
579
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
560
580
|
});
|
|
@@ -565,6 +585,12 @@ export class VerifyRunner {
|
|
|
565
585
|
return;
|
|
566
586
|
}
|
|
567
587
|
run.child = child;
|
|
588
|
+
// A recipe step is the heaviest thing the runner starts on its own — a
|
|
589
|
+
// full build or a full test run — and the one with nobody waiting on a
|
|
590
|
+
// keystroke. It goes behind the daemon and level with the agent sessions;
|
|
591
|
+
// the whole process group inherits it, which is the point, because
|
|
592
|
+
// `detached` means the compilers are down there and not here.
|
|
593
|
+
lowerPriority(child.pid);
|
|
568
594
|
// A StringDecoder per stream, not `buffer.toString('utf8')` per chunk.
|
|
569
595
|
//
|
|
570
596
|
// A pipe read ends wherever the kernel filled the buffer, which is
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.55.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED