@bridge4dev/runner 0.57.0 → 0.58.1
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 +23 -4
- package/dist/adapters/codex-protocol.js +6 -1
- package/dist/adapters/codex.js +11 -2
- package/dist/adapters/types.js +14 -0
- package/dist/cage-authority.d.ts +118 -0
- package/dist/cage-authority.js +241 -0
- package/dist/config.d.ts +83 -5
- package/dist/config.js +59 -1
- package/dist/daemon-lock.d.ts +43 -0
- package/dist/daemon-lock.js +107 -0
- package/dist/host-load.d.ts +9 -0
- package/dist/host-load.js +9 -0
- package/dist/index.js +222 -20
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +68 -0
- package/dist/protocol.d.ts +43 -27
- package/dist/recipe-schema.d.ts +12 -12
- package/dist/self-update.js +22 -1
- package/dist/service-unit.d.ts +35 -3
- package/dist/service-unit.js +82 -5
- package/dist/session-allocator.d.ts +259 -0
- package/dist/session-allocator.js +492 -0
- package/dist/session-cage.d.ts +229 -2
- package/dist/session-cage.js +590 -40
- package/dist/session-limits.d.ts +71 -0
- package/dist/session-limits.js +93 -0
- package/dist/session-stall.d.ts +353 -0
- package/dist/session-stall.js +760 -0
- package/dist/supervisor.d.ts +223 -33
- package/dist/supervisor.js +845 -94
- package/dist/systemd-memory.js +2 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live memory numbers this machine reports (#398 S4).
|
|
3
|
+
*
|
|
4
|
+
* Mirror of `packages/shared/src/schemas/session-limits.ts` — the DevBridge side
|
|
5
|
+
* is the source of truth, exactly like `host-load.ts` mirrors the load frame and
|
|
6
|
+
* `levels.ts` mirrors the level thresholds. Copied rather than imported on
|
|
7
|
+
* purpose: this package is published to npm on its own and installed on machines
|
|
8
|
+
* that have never heard of `@devbridge/shared`.
|
|
9
|
+
*
|
|
10
|
+
* CHECKED: `session-limits.test.ts` reads the shared file and compares every
|
|
11
|
+
* field below against it, so a change on one side that is not made on the other
|
|
12
|
+
* turns a test red instead of turning a card blank.
|
|
13
|
+
*/
|
|
14
|
+
/** One session's three numbers, as they leave the machine. */
|
|
15
|
+
export interface SessionLimitsEntry {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
guaranteedBytes: number;
|
|
18
|
+
brakeBytes: number;
|
|
19
|
+
wallBytes: number;
|
|
20
|
+
holdBytes: number;
|
|
21
|
+
/** This session's guarantee does not fit in the pot beside the others (D9). */
|
|
22
|
+
tight: boolean;
|
|
23
|
+
/** Milliseconds until its biggest command is stopped; null when it is moving. */
|
|
24
|
+
stallRemainingMs: number | null;
|
|
25
|
+
/** Why its limits last moved. */
|
|
26
|
+
reason: 'added' | 'stopped-command' | 'nothing-to-stop' | 'report-only' | null;
|
|
27
|
+
}
|
|
28
|
+
export interface SessionLimitsFrame {
|
|
29
|
+
at: string;
|
|
30
|
+
poolBytes: number;
|
|
31
|
+
heldBytes: number;
|
|
32
|
+
otherHeldBytes: number;
|
|
33
|
+
/** What one session is guaranteed here — the constant, so a card with no
|
|
34
|
+
* session on it can still name the number. */
|
|
35
|
+
guaranteeBytes: number;
|
|
36
|
+
/** The brake a session starting right now would get — the card cannot derive it. */
|
|
37
|
+
sessionCeilingBytes: number;
|
|
38
|
+
guaranteesFit: number;
|
|
39
|
+
seats: number;
|
|
40
|
+
swapPerSessionBytes: number;
|
|
41
|
+
collectiveBrake: boolean;
|
|
42
|
+
conservative: boolean;
|
|
43
|
+
hungry: boolean;
|
|
44
|
+
sessions: SessionLimitsEntry[];
|
|
45
|
+
}
|
|
46
|
+
/** Mirror of `SESSION_LIMITS_HEARTBEAT_MS`. */
|
|
47
|
+
export declare const SESSION_LIMITS_HEARTBEAT_MS = 60000;
|
|
48
|
+
/** Mirror of `SESSION_LIMITS_MIN_DELTA_RATIO`. */
|
|
49
|
+
export declare const SESSION_LIMITS_MIN_DELTA_RATIO = 0.1;
|
|
50
|
+
/**
|
|
51
|
+
* Is this frame different enough from the last one to be worth the socket?
|
|
52
|
+
*
|
|
53
|
+
* The allocator runs every 30 s and its numbers breathe with the machine, so
|
|
54
|
+
* without a gate this would be a write per machine per half-minute for a card
|
|
55
|
+
* nobody is looking at. What counts as a change:
|
|
56
|
+
*
|
|
57
|
+
* - anything about WHO is on the machine — a session appearing or leaving, or
|
|
58
|
+
* a session's limits actually being rewritten. Those are the moments a person
|
|
59
|
+
* would notice, and no ratio must be able to hide them;
|
|
60
|
+
* - a state that a person acts on — the deadline appearing or disappearing, the
|
|
61
|
+
* crowding flag, the collective brake going missing;
|
|
62
|
+
* - and otherwise a tenth of the pot, which is the same dead zone the allocator
|
|
63
|
+
* uses before it will write to a scope at all.
|
|
64
|
+
*/
|
|
65
|
+
export declare function sessionLimitsChangedEnough(previous: SessionLimitsFrame | null, next: SessionLimitsFrame): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Is a heartbeat due? Same shape and same reason as `hostLoadHeartbeatDue`: a
|
|
68
|
+
* backwards clock step is «due», not «early».
|
|
69
|
+
*/
|
|
70
|
+
export declare function sessionLimitsHeartbeatDue(sinceLastSentMs: number, heartbeatMs: number): boolean;
|
|
71
|
+
//# sourceMappingURL=session-limits.d.ts.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live memory numbers this machine reports (#398 S4).
|
|
3
|
+
*
|
|
4
|
+
* Mirror of `packages/shared/src/schemas/session-limits.ts` — the DevBridge side
|
|
5
|
+
* is the source of truth, exactly like `host-load.ts` mirrors the load frame and
|
|
6
|
+
* `levels.ts` mirrors the level thresholds. Copied rather than imported on
|
|
7
|
+
* purpose: this package is published to npm on its own and installed on machines
|
|
8
|
+
* that have never heard of `@devbridge/shared`.
|
|
9
|
+
*
|
|
10
|
+
* CHECKED: `session-limits.test.ts` reads the shared file and compares every
|
|
11
|
+
* field below against it, so a change on one side that is not made on the other
|
|
12
|
+
* turns a test red instead of turning a card blank.
|
|
13
|
+
*/
|
|
14
|
+
/** Mirror of `SESSION_LIMITS_HEARTBEAT_MS`. */
|
|
15
|
+
export const SESSION_LIMITS_HEARTBEAT_MS = 60_000;
|
|
16
|
+
/** Mirror of `SESSION_LIMITS_MIN_DELTA_RATIO`. */
|
|
17
|
+
export const SESSION_LIMITS_MIN_DELTA_RATIO = 0.1;
|
|
18
|
+
/**
|
|
19
|
+
* Is this frame different enough from the last one to be worth the socket?
|
|
20
|
+
*
|
|
21
|
+
* The allocator runs every 30 s and its numbers breathe with the machine, so
|
|
22
|
+
* without a gate this would be a write per machine per half-minute for a card
|
|
23
|
+
* nobody is looking at. What counts as a change:
|
|
24
|
+
*
|
|
25
|
+
* - anything about WHO is on the machine — a session appearing or leaving, or
|
|
26
|
+
* a session's limits actually being rewritten. Those are the moments a person
|
|
27
|
+
* would notice, and no ratio must be able to hide them;
|
|
28
|
+
* - a state that a person acts on — the deadline appearing or disappearing, the
|
|
29
|
+
* crowding flag, the collective brake going missing;
|
|
30
|
+
* - and otherwise a tenth of the pot, which is the same dead zone the allocator
|
|
31
|
+
* uses before it will write to a scope at all.
|
|
32
|
+
*/
|
|
33
|
+
export function sessionLimitsChangedEnough(previous, next) {
|
|
34
|
+
if (previous === null)
|
|
35
|
+
return true;
|
|
36
|
+
if (previous.sessions.length !== next.sessions.length)
|
|
37
|
+
return true;
|
|
38
|
+
if (previous.collectiveBrake !== next.collectiveBrake ||
|
|
39
|
+
previous.conservative !== next.conservative ||
|
|
40
|
+
previous.hungry !== next.hungry ||
|
|
41
|
+
previous.guaranteeBytes !== next.guaranteeBytes ||
|
|
42
|
+
// Exact, and it can be: since #403 this is the ceiling of an IDLE machine,
|
|
43
|
+
// computed from the pot and the owner's knobs alone. It does not follow
|
|
44
|
+
// `MemAvailable`, so it does not breathe, and any move in it is a fact
|
|
45
|
+
// worth a frame. (While it was the live ceiling of the current crowd, this
|
|
46
|
+
// line sent one every thirty seconds from every machine.)
|
|
47
|
+
previous.sessionCeilingBytes !== next.sessionCeilingBytes ||
|
|
48
|
+
previous.guaranteesFit !== next.guaranteesFit ||
|
|
49
|
+
previous.seats !== next.seats ||
|
|
50
|
+
previous.swapPerSessionBytes !== next.swapPerSessionBytes) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
const before = new Map(previous.sessions.map((s) => [s.sessionId, s]));
|
|
54
|
+
for (const session of next.sessions) {
|
|
55
|
+
const was = before.get(session.sessionId);
|
|
56
|
+
if (!was)
|
|
57
|
+
return true;
|
|
58
|
+
if (was.tight !== session.tight ||
|
|
59
|
+
was.reason !== session.reason ||
|
|
60
|
+
// «A countdown is running» and «it is not» are different states; the exact
|
|
61
|
+
// number of seconds left is not, or the strip would repaint every tick.
|
|
62
|
+
(was.stallRemainingMs === null) !== (session.stallRemainingMs === null)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (was.guaranteedBytes !== session.guaranteedBytes ||
|
|
66
|
+
was.brakeBytes !== session.brakeBytes ||
|
|
67
|
+
was.wallBytes !== session.wallBytes) {
|
|
68
|
+
// The allocator has its own dead zone before it writes, so a limit that
|
|
69
|
+
// moved at all is a limit worth reporting.
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const base = Math.max(1, previous.poolBytes);
|
|
74
|
+
if (Math.abs(next.poolBytes - previous.poolBytes) / base >= SESSION_LIMITS_MIN_DELTA_RATIO) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (Math.abs(next.heldBytes - previous.heldBytes) / base >= SESSION_LIMITS_MIN_DELTA_RATIO) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (Math.abs(next.otherHeldBytes - previous.otherHeldBytes) / base >=
|
|
81
|
+
SESSION_LIMITS_MIN_DELTA_RATIO) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Is a heartbeat due? Same shape and same reason as `hostLoadHeartbeatDue`: a
|
|
88
|
+
* backwards clock step is «due», not «early».
|
|
89
|
+
*/
|
|
90
|
+
export function sessionLimitsHeartbeatDue(sinceLastSentMs, heartbeatMs) {
|
|
91
|
+
return sinceLastSentMs >= heartbeatMs || sinceLastSentMs < 0;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=session-limits.js.map
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling «this session is standing still» from «this session is slow» — and
|
|
3
|
+
* doing something about it before the night is over (#398 S2, gotcha §480).
|
|
4
|
+
*
|
|
5
|
+
* Above `MemoryHigh` the kernel does not kill, it puts the allocating thread to
|
|
6
|
+
* sleep on the way back to userspace; with `MemorySwapMax=0` there is nothing to
|
|
7
|
+
* push out, so the penalty is up to 2 s per 256 KB and the process never reaches
|
|
8
|
+
* `MemoryMax` at all — it simply stops. On 09.09.2026 that cost one session an
|
|
9
|
+
* hour of silence on a build that printed nothing but its startup warnings.
|
|
10
|
+
* Ten of the fleet's fifteen caged machines have a session swap of zero, so this
|
|
11
|
+
* is the ordinary case and not the exotic one.
|
|
12
|
+
*
|
|
13
|
+
* The measure is ONE number and it is not the one three earlier drafts reached
|
|
14
|
+
* for.
|
|
15
|
+
*
|
|
16
|
+
* - **Not CPU.** A process wedged in direct reclaim burns system time in bursts:
|
|
17
|
+
* the same stuck process was measured at 3.6 %, 2.9 %, 6.9 %, 18.7 %, 98 %,
|
|
18
|
+
* 53 %, 22 %, 67 % of one core on consecutive seconds. Any threshold over it
|
|
19
|
+
* both misfires and misses.
|
|
20
|
+
* - **Not swap.** The first draft counted growth in `memory.swap.current` and
|
|
21
|
+
* `pgmajfault` as evidence of progress, out of a fear of killing honest work
|
|
22
|
+
* on a machine that swaps. The measurement refuted it: a live session with
|
|
23
|
+
* 232 MB of swap and 1.3 M major faults stood still for 2 h 15 min out of 18.
|
|
24
|
+
* Swap does not separate useful paging from thrashing.
|
|
25
|
+
* - **Pressure does.** `memory.pressure`, line `full`, field `total=`, is
|
|
26
|
+
* cumulative microseconds during which NO task in the cgroup could run for
|
|
27
|
+
* want of memory. Measured on this host: a wedged process, 27.9 s of stall in
|
|
28
|
+
* a 27.5 s window (~100 %); a healthy live session, 10.7 s in six hours
|
|
29
|
+
* (0.05 %); a session thrashing swap, 8093 s in 18 hours with the share close
|
|
30
|
+
* to 1 inside the bad stretches. Three orders of magnitude between honest
|
|
31
|
+
* work and a stall, in one number, on every machine that has PSI.
|
|
32
|
+
*
|
|
33
|
+
* The second condition is a cheap fuse and it is also a measurement: on a live
|
|
34
|
+
* scope of this host `anon` was 308 MB against a `memory.current` of 1033 MB
|
|
35
|
+
* with 9246 brake events — i.e. the brake very often catches page cache, which
|
|
36
|
+
* the kernel takes back with no stall at all. Those episodes must not reach the
|
|
37
|
+
* counter, let alone the deadline.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* One detector window.
|
|
41
|
+
*
|
|
42
|
+
* Five seconds, not the watch's thirty: the deadline below is three minutes, and
|
|
43
|
+
* a measure that needs two consecutive windows would spend a third of the
|
|
44
|
+
* deadline deciding at the 30 s cadence. Five seconds of `full` stall is also
|
|
45
|
+
* long enough that a single scheduling hiccup cannot fill it.
|
|
46
|
+
*/
|
|
47
|
+
export declare const STALL_WINDOW_MS = 5000;
|
|
48
|
+
/**
|
|
49
|
+
* Share of the window spent with nothing in the cgroup able to run, above which
|
|
50
|
+
* the window counts as a standing one.
|
|
51
|
+
*
|
|
52
|
+
* **A fifth, not a half, and the difference was found by measurement.** The plan
|
|
53
|
+
* this module implements said 50 %, taken from a hard stall that read ~100 %.
|
|
54
|
+
* Reproducing the incident itself — a cage with a 64 MiB brake and no swap, a
|
|
55
|
+
* process touching what it allocates — gave four consecutive windows of
|
|
56
|
+
* **0.766, 0.484, 0.443, 0.542** (10.09.2026, this host). Two of the four sit
|
|
57
|
+
* BELOW a half: at that line the very shape this mechanism exists for would
|
|
58
|
+
* trip and clear in turn and never reach its deadline.
|
|
59
|
+
*
|
|
60
|
+
* There is room to move the line because the gap is enormous. Measured on the
|
|
61
|
+
* same host: a healthy live session, 0.0005; honest disk-bound work under the
|
|
62
|
+
* same 64 MiB brake writing 6.3 GB at 739 MB/s, exactly **0.0** with the brake
|
|
63
|
+
* never firing at all. A fifth is four hundred times what healthy work shows and
|
|
64
|
+
* half of what the mildest stalled window did.
|
|
65
|
+
*/
|
|
66
|
+
export declare const STALL_SHARE = 0.2;
|
|
67
|
+
/**
|
|
68
|
+
* …and the share it has to fall BELOW before a running deadline is cleared.
|
|
69
|
+
*
|
|
70
|
+
* Two lines, not one, for the reason the cage watch already had `calmTicks`: a
|
|
71
|
+
* genuine stall is not a flat number, it breathes. One line means one dip
|
|
72
|
+
* cancels three minutes of standing still. Half the trip line, and still two
|
|
73
|
+
* hundred times the 0.0005 healthy work shows.
|
|
74
|
+
*/
|
|
75
|
+
export declare const STALL_SHARE_CLEAR = 0.1;
|
|
76
|
+
/** Consecutive windows over the line before the session counts as standing still. */
|
|
77
|
+
export declare const STALL_WINDOWS_TO_TRIP = 2;
|
|
78
|
+
/**
|
|
79
|
+
* `(anon + shmem) / memory.current` above which the pressure is about memory the
|
|
80
|
+
* kernel cannot simply drop. Below it the brake is grinding page cache.
|
|
81
|
+
*/
|
|
82
|
+
export declare const STALL_ANON_SHARE = 0.9;
|
|
83
|
+
/**
|
|
84
|
+
* How long a standing session is given before its biggest command is stopped
|
|
85
|
+
* (decision D6 of the plan).
|
|
86
|
+
*
|
|
87
|
+
* Three minutes: long enough for a person who is watching to intervene, short
|
|
88
|
+
* enough that a session which stalls at 02:00 is not still standing at 08:00.
|
|
89
|
+
* Doubled on a machine without PSI, where the fallback measure below is weaker.
|
|
90
|
+
*/
|
|
91
|
+
export declare const STALL_GRACE_MS = 180000;
|
|
92
|
+
/**
|
|
93
|
+
* A drop of this much in `memory.current` counts as real progress and clears the
|
|
94
|
+
* deadline.
|
|
95
|
+
*
|
|
96
|
+
* A new line of output does NOT: in the incident the build printed startup
|
|
97
|
+
* warnings for seventeen minutes while allocating nothing.
|
|
98
|
+
*/
|
|
99
|
+
export declare const STALL_PROGRESS_DROP_BYTES: number;
|
|
100
|
+
/**
|
|
101
|
+
* How long the detector stays quiet after WE lowered this session's brake.
|
|
102
|
+
*
|
|
103
|
+
* Measured on this host: a process honestly writing a file at 6.4 MB/s was
|
|
104
|
+
* stopped dead the instant its `MemoryHigh` was narrowed on the live scope. Any
|
|
105
|
+
* lowering therefore manufactures exactly the signal this module looks for, and
|
|
106
|
+
* without the mute the mechanism would catch its own edit and stop a command for
|
|
107
|
+
* it. One window plus the deadline, with room to spare.
|
|
108
|
+
*/
|
|
109
|
+
export declare const STALL_MUTE_AFTER_LOWER_MS = 240000;
|
|
110
|
+
/**
|
|
111
|
+
* Between SIGTERM and SIGKILL for the subtree being stopped.
|
|
112
|
+
*
|
|
113
|
+
* Thirty seconds, not five: neither `pnpm install` nor `docker build` can put
|
|
114
|
+
* its own house in order in five, and half-written state costs the next turn
|
|
115
|
+
* more than the wait costs this one.
|
|
116
|
+
*/
|
|
117
|
+
export declare const STALL_SIGKILL_AFTER_MS = 30000;
|
|
118
|
+
/**
|
|
119
|
+
* Growth under which the PSI-less fallback still calls a session stuck:
|
|
120
|
+
* `min(1 % of the brake, 16 MiB)` per window.
|
|
121
|
+
*/
|
|
122
|
+
export declare const STALL_FALLBACK_GROWTH_BYTES: number;
|
|
123
|
+
/** What one read of a session's cgroup says about whether it is moving. */
|
|
124
|
+
export interface StallSample {
|
|
125
|
+
/**
|
|
126
|
+
* `memory.pressure`, line `full`, `total=` — cumulative microseconds with no
|
|
127
|
+
* task in the cgroup able to run. Null on a kernel built without `CONFIG_PSI`.
|
|
128
|
+
*/
|
|
129
|
+
pressureFullUs: number | null;
|
|
130
|
+
/** `memory.events` `high`, cumulative — the fallback measure. */
|
|
131
|
+
highEvents: number;
|
|
132
|
+
/** `memory.current`. */
|
|
133
|
+
currentBytes: number;
|
|
134
|
+
/** `anon` + `shmem` from `memory.stat` — what cannot simply be dropped. */
|
|
135
|
+
anonBytes: number;
|
|
136
|
+
/** `memory.high`, null for `max`. */
|
|
137
|
+
brakeBytes: number | null;
|
|
138
|
+
/** When this sample was taken. */
|
|
139
|
+
at: number;
|
|
140
|
+
}
|
|
141
|
+
/** The detector's memory of one session. Deliberately its own, not `cageWatch`. */
|
|
142
|
+
export interface StallState {
|
|
143
|
+
/**
|
|
144
|
+
* Which cgroup these numbers came from. A relaunch gets the same unit NAME
|
|
145
|
+
* back once the failed unit is reset, so the counters are checked as well:
|
|
146
|
+
* anything that went backwards is a new cgroup, not a counter that shrank.
|
|
147
|
+
*/
|
|
148
|
+
unit: string;
|
|
149
|
+
last: StallSample | null;
|
|
150
|
+
/** Consecutive windows over the line. */
|
|
151
|
+
hotWindows: number;
|
|
152
|
+
/** When the deadline started running; null = this session is not stuck. */
|
|
153
|
+
stalledSince: number | null;
|
|
154
|
+
/** `memory.current` when the deadline started — the yardstick for «it moved». */
|
|
155
|
+
markBytes: number;
|
|
156
|
+
/** Detection is off until this moment, because we moved the brake ourselves. */
|
|
157
|
+
mutedUntil: number;
|
|
158
|
+
}
|
|
159
|
+
export declare function freshStallState(unit: string): StallState;
|
|
160
|
+
/**
|
|
161
|
+
* The pure half: five files' text in, a sample out. Null when `memory.current`
|
|
162
|
+
* could not be read, which means the cgroup is gone.
|
|
163
|
+
*/
|
|
164
|
+
export declare function parseStallSample(files: {
|
|
165
|
+
current: string;
|
|
166
|
+
high: string;
|
|
167
|
+
events: string;
|
|
168
|
+
stat: string;
|
|
169
|
+
pressure?: string;
|
|
170
|
+
}, at: number): StallSample | null;
|
|
171
|
+
/** Read one sample off a live scope. Null when the cgroup is not there. */
|
|
172
|
+
export declare function readStallSample(unit: string, at: number, readFile?: (p: string) => string): StallSample | null;
|
|
173
|
+
/** What the detector decided about one window. */
|
|
174
|
+
export interface StallVerdict {
|
|
175
|
+
state: StallState;
|
|
176
|
+
/** Is this session standing still right now? */
|
|
177
|
+
stuck: boolean;
|
|
178
|
+
/** Milliseconds left before its biggest command is stopped; null when not stuck. */
|
|
179
|
+
remainingMs: number | null;
|
|
180
|
+
/** The deadline has run out — the caller stops a command now. */
|
|
181
|
+
expired: boolean;
|
|
182
|
+
/** PSI was unavailable, so the weaker measure was used and the grace doubled. */
|
|
183
|
+
degraded: boolean;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* One window of the detector, as a pure function of the previous state.
|
|
187
|
+
*
|
|
188
|
+
* Everything the caller must not get wrong lives here: two windows before the
|
|
189
|
+
* counter trips, the anon fuse, the mute after our own edit, and a reset that
|
|
190
|
+
* only real progress can buy.
|
|
191
|
+
*/
|
|
192
|
+
export declare function stallStep(previous: StallState, sample: StallSample, options?: {
|
|
193
|
+
graceMs?: number;
|
|
194
|
+
now?: number;
|
|
195
|
+
windowMs?: number;
|
|
196
|
+
/**
|
|
197
|
+
* Is this the only session in the slice right now (#398 S7, B9)?
|
|
198
|
+
*
|
|
199
|
+
* When it is, «somebody else's brake» has no somebody else: the collective
|
|
200
|
+
* brake on the slice can only have been filled by this session, and the
|
|
201
|
+
* discriminator below must not read that as innocence.
|
|
202
|
+
*/
|
|
203
|
+
soleSession?: boolean;
|
|
204
|
+
}): StallVerdict;
|
|
205
|
+
/** One process of a session's cgroup, as `/proc` describes it. */
|
|
206
|
+
export interface ScopeProcess {
|
|
207
|
+
pid: number;
|
|
208
|
+
ppid: number;
|
|
209
|
+
/**
|
|
210
|
+
* Field 22 of `/proc/<pid>/stat` — the moment this process started, in clock
|
|
211
|
+
* ticks since boot.
|
|
212
|
+
*
|
|
213
|
+
* The identity of a pid, and the reason it is carried: pids are REUSED. A
|
|
214
|
+
* subtree is asked to stop with SIGTERM and killed thirty seconds later, and
|
|
215
|
+
* on a busy machine a pid freed in between can belong to something else
|
|
216
|
+
* entirely by then — so the second signal is checked against this before it
|
|
217
|
+
* is sent. Found by the independent review of 10.09.2026, which called it the
|
|
218
|
+
* most dangerous line in the change, and it was right.
|
|
219
|
+
*/
|
|
220
|
+
startedAtTicks: number;
|
|
221
|
+
/** `/proc/<pid>/comm` — the executable's name, never its arguments. */
|
|
222
|
+
comm: string;
|
|
223
|
+
/** Resident set, bytes, from `/proc/<pid>/statm`. */
|
|
224
|
+
rssBytes: number;
|
|
225
|
+
/** Does the command line name an MCP server? Then it is the agent's, not work. */
|
|
226
|
+
mcp: boolean;
|
|
227
|
+
}
|
|
228
|
+
/** The chosen victim: a whole subtree, named by its heaviest process. */
|
|
229
|
+
export interface KillCandidate {
|
|
230
|
+
/** Root of the subtree — the process whose whole tree is stopped. */
|
|
231
|
+
rootPid: number;
|
|
232
|
+
/** Every pid in that subtree, deepest last. */
|
|
233
|
+
pids: number[];
|
|
234
|
+
/**
|
|
235
|
+
* The same pids with the moment each one started, so the SIGKILL thirty
|
|
236
|
+
* seconds later can prove it is still signalling the same processes.
|
|
237
|
+
*/
|
|
238
|
+
identities: Array<{
|
|
239
|
+
pid: number;
|
|
240
|
+
startedAtTicks: number;
|
|
241
|
+
}>;
|
|
242
|
+
/** The heaviest process in it, by name only — arguments can carry a token. */
|
|
243
|
+
name: string;
|
|
244
|
+
/** What the whole subtree holds. */
|
|
245
|
+
rssBytes: number;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Anything whose command line says «MCP server» is the agent's own plumbing.
|
|
249
|
+
*
|
|
250
|
+
* Deliberately generous, because the direction of a mistake matters: a false
|
|
251
|
+
* positive costs us a candidate (we stop something smaller, or nothing), a false
|
|
252
|
+
* negative costs the session its tools mid-turn.
|
|
253
|
+
*
|
|
254
|
+
* **Widened after the independent review of 10.09.2026, which found the first
|
|
255
|
+
* version matched nothing real.** The command lines actually on this machine are
|
|
256
|
+
* `npm exec @playwright/mcp@latest …` and `node …/playwright-mcp --output-dir …`
|
|
257
|
+
* — in the first `mcp` is followed by `@`, in the second preceded by `-`, and
|
|
258
|
+
* the original character classes allowed neither. So the rule is now simply
|
|
259
|
+
* «`mcp` as a word», which does match both.
|
|
260
|
+
*
|
|
261
|
+
* The same review found the mirror-image defect, and it was the worse of the
|
|
262
|
+
* two: the AGENT's own command line contains `--mcp-config …`, so the agent
|
|
263
|
+
* matched, and protection used to be spread from anything that matched — which
|
|
264
|
+
* protected the agent's ENTIRE subtree and made every Claude session return no
|
|
265
|
+
* candidate at all. Protection is no longer spread from a tree root: a root is
|
|
266
|
+
* protected on its own account (it is the agent), and spreading from it would
|
|
267
|
+
* protect the very builds this mechanism exists to stop.
|
|
268
|
+
*/
|
|
269
|
+
export declare const MCP_MARKER: RegExp;
|
|
270
|
+
export declare function residentPageSize(readFile?: (p: string) => string, procDir?: string): number;
|
|
271
|
+
/**
|
|
272
|
+
* Read `/proc` for every process in one session's cgroup.
|
|
273
|
+
*
|
|
274
|
+
* **The exception to the rule in `host-load.ts`**, and the only one: that module
|
|
275
|
+
* states in full what this runner may read for telemetry, and says «no
|
|
276
|
+
* `/proc/<pid>` walk, no `ps`». This is not telemetry — nothing here leaves the
|
|
277
|
+
* machine except one command NAME — and choosing a victim cannot be done from
|
|
278
|
+
* two summary files. The rule and this exception are noted in both places.
|
|
279
|
+
*
|
|
280
|
+
* `ppid` is taken from after the LAST `)` of `/proc/<pid>/stat`, never by field
|
|
281
|
+
* number: a process name may contain spaces and parentheses (`(npm exec
|
|
282
|
+
* @playw)`), which moves every field after it (gotcha §441).
|
|
283
|
+
*/
|
|
284
|
+
export declare function readScopeProcesses(unit: string, readFile?: (p: string) => string, procDir?: string): ScopeProcess[];
|
|
285
|
+
/**
|
|
286
|
+
* The biggest command of this session, defined by the letter and not by «the
|
|
287
|
+
* fattest thing on the machine».
|
|
288
|
+
*
|
|
289
|
+
* Every exclusion below is a measurement or an incident:
|
|
290
|
+
*
|
|
291
|
+
* - **only pids of THIS cgroup.** By RSS the fattest processes on a dev server
|
|
292
|
+
* are `dockerd`, the production API and other people's agents; a candidate
|
|
293
|
+
* chosen machine-wide would stop one of those.
|
|
294
|
+
* - **never the agent.** The agent is the one process in the scope whose parent
|
|
295
|
+
* is OUTSIDE it — `systemd-run --scope` execs into it and the runner spawned
|
|
296
|
+
* it. That is a structural fact and needs no pid handed down through two
|
|
297
|
+
* adapters. Stopping the agent is what the top «Stop» button is for; this
|
|
298
|
+
* mechanism has no right to it (D6).
|
|
299
|
+
* - **never an MCP server**, nor anything under one: those are the agent's
|
|
300
|
+
* tools, not its work.
|
|
301
|
+
* - **never `git`.** It is never the fattest thing, and a killed `git` leaves
|
|
302
|
+
* `.git/index.lock` behind, which then costs the session its restore points
|
|
303
|
+
* and its next turns.
|
|
304
|
+
* - **the whole subtree**, not one pid: over a `tsc` there is a `pnpm`, and
|
|
305
|
+
* over that a `sh`, and killing the leaf leaves the parent to start another.
|
|
306
|
+
*/
|
|
307
|
+
export declare function pickKillCandidate(processes: ScopeProcess[], agentPid?: number | null): KillCandidate | null;
|
|
308
|
+
/**
|
|
309
|
+
* Is this pid still the process it was when we decided to stop it?
|
|
310
|
+
*
|
|
311
|
+
* `starttime` never repeats for one pid, so comparing it is the cheap and exact
|
|
312
|
+
* answer. Anything unreadable is «not the same», which is the safe direction: a
|
|
313
|
+
* signal not sent costs a command another thirty seconds, a signal sent to the
|
|
314
|
+
* wrong process costs somebody something we know nothing about.
|
|
315
|
+
*/
|
|
316
|
+
export declare function isSameProcess(identity: {
|
|
317
|
+
pid: number;
|
|
318
|
+
startedAtTicks: number;
|
|
319
|
+
}, readFile?: (p: string) => string, procDir?: string): boolean;
|
|
320
|
+
/**
|
|
321
|
+
* Send one signal to every pid of a subtree. Processes that ended are skipped.
|
|
322
|
+
*
|
|
323
|
+
* When identities are given, each pid is checked against the process it was
|
|
324
|
+
* when the subtree was chosen — see {@link isSameProcess}. The SIGKILL that
|
|
325
|
+
* follows a SIGTERM thirty seconds later is the call that needs it: on a busy
|
|
326
|
+
* machine a pid freed in between can belong to something else by then.
|
|
327
|
+
*/
|
|
328
|
+
export declare function signalSubtree(pids: Array<number | {
|
|
329
|
+
pid: number;
|
|
330
|
+
startedAtTicks: number;
|
|
331
|
+
}>, signal: NodeJS.Signals, kill?: (pid: number, signal: NodeJS.Signals) => void, sameProcess?: (identity: {
|
|
332
|
+
pid: number;
|
|
333
|
+
startedAtTicks: number;
|
|
334
|
+
}) => boolean): number;
|
|
335
|
+
/**
|
|
336
|
+
* Raise this session's brake on the live scope.
|
|
337
|
+
*
|
|
338
|
+
* `systemctl --user set-property --runtime`, never a write into the cgroup
|
|
339
|
+
* files: a direct write is undone by the next `daemon-reload`, and the runner
|
|
340
|
+
* calls one itself every hour. `--runtime` is not optional either — without it
|
|
341
|
+
* systemd persists the property into `~/.config/systemd/user.control/`, which
|
|
342
|
+
* OUTRANKS the drop-in this runner ships and which `doctor` reports as an
|
|
343
|
+
* emergency override (`index.ts`).
|
|
344
|
+
*
|
|
345
|
+
* Never throws. A failure here must not take down the tick, let alone the
|
|
346
|
+
* daemon: the daemon carries a fatal `unhandledRejection` handler, so one
|
|
347
|
+
* unhandled write error would end every live session on the machine. A
|
|
348
|
+
* `set-property` on a unit that ended between the measurement and the write
|
|
349
|
+
* exits 1 with «Unit … not found» — the ordinary outcome of a race, not an
|
|
350
|
+
* error worth a loud line.
|
|
351
|
+
*/
|
|
352
|
+
export declare function setScopeProperties(unit: string, properties: string[], run?: (args: string[]) => Promise<unknown>): Promise<boolean>;
|
|
353
|
+
//# sourceMappingURL=session-stall.d.ts.map
|