@bridge4dev/runner 0.54.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 +10 -2
- package/dist/adapters/codex.js +33 -0
- package/dist/index.js +25 -4
- package/dist/service-unit.d.ts +45 -9
- package/dist/service-unit.js +71 -21
- package/dist/session-cage.d.ts +217 -6
- package/dist/session-cage.js +458 -30
- package/dist/supervisor.d.ts +49 -0
- package/dist/supervisor.js +172 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { installAgent } from './agent-install.js';
|
|
|
5
5
|
import { pruneNativeClaudeVersions } from './agent-cleanup.js';
|
|
6
6
|
import { type AgentVersionsMeasurement } from './agent-versions.js';
|
|
7
7
|
import { type HostLoadFrame } from './host-load.js';
|
|
8
|
+
import { type ScopeMemoryStatus } from './session-cage.js';
|
|
8
9
|
import type { RunnerWsClient } from './ws-client.js';
|
|
9
10
|
import type { SessionDescriptor } from './protocol.js';
|
|
10
11
|
import type { AgentAdapter } from './adapters/types.js';
|
|
@@ -146,6 +147,14 @@ export interface SupervisorOptions {
|
|
|
146
147
|
* still reports» would be an untested promise. Never set in production.
|
|
147
148
|
*/
|
|
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;
|
|
149
158
|
}
|
|
150
159
|
export declare class Supervisor {
|
|
151
160
|
private readonly ws;
|
|
@@ -222,6 +231,23 @@ export declare class Supervisor {
|
|
|
222
231
|
private readonly verify;
|
|
223
232
|
private readonly verifyReports;
|
|
224
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;
|
|
225
251
|
/** How often the agent versions are re-derived. See the constructor. */
|
|
226
252
|
private static readonly AGENT_VERSIONS_INTERVAL_MS;
|
|
227
253
|
private readonly agentVersionsTimer;
|
|
@@ -402,6 +428,29 @@ export declare class Supervisor {
|
|
|
402
428
|
* reconnect the API would have no measurement at all while this side believed
|
|
403
429
|
* it had one.
|
|
404
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;
|
|
405
454
|
private publishHostLoad;
|
|
406
455
|
private onFrame;
|
|
407
456
|
private startSession;
|
package/dist/supervisor.js
CHANGED
|
@@ -26,6 +26,7 @@ import { agentByDbValue } from './agent-registry.js';
|
|
|
26
26
|
import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
|
|
27
27
|
import { rememberWorkspacePath } from './environment.js';
|
|
28
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';
|
|
29
30
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
30
31
|
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, MAX_BUSY_SESSIONS, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
31
32
|
import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
|
|
@@ -223,9 +224,31 @@ export class Supervisor {
|
|
|
223
224
|
* actually moved (or the heartbeat came due).
|
|
224
225
|
*/
|
|
225
226
|
this.hostLoadHeartbeatMs = opts.hostLoadHeartbeatMs ?? HOST_LOAD_HEARTBEAT_MS;
|
|
226
|
-
this.hostLoadTimer = setInterval(() =>
|
|
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);
|
|
227
233
|
this.hostLoadTimer.unref?.();
|
|
228
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;
|
|
229
252
|
/** How often the agent versions are re-derived. See the constructor. */
|
|
230
253
|
static AGENT_VERSIONS_INTERVAL_MS = 60 * 60 * 1_000;
|
|
231
254
|
agentVersionsTimer;
|
|
@@ -620,6 +643,154 @@ export class Supervisor {
|
|
|
620
643
|
* reconnect the API would have no measurement at all while this side believed
|
|
621
644
|
* it had one.
|
|
622
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
|
+
}
|
|
623
794
|
publishHostLoad() {
|
|
624
795
|
const sample = (this.opts.readHostLoad ?? readHostLoad)();
|
|
625
796
|
if (!sample)
|
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