@bridge4dev/runner 0.56.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.
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { HostLoadFrame } from './host-load.js';
3
+ import type { SessionLimitsFrame } from './session-limits.js';
3
4
  export declare const SessionDescriptorSchema: z.ZodObject<{
4
5
  id: z.ZodString;
5
6
  kind: z.ZodEnum<["TICKET", "CHAT"]>;
@@ -102,8 +103,8 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
102
103
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
103
104
  }, "strip", z.ZodTypeAny, {
104
105
  path: string;
105
- trustMode: "STRICT" | "NORMAL" | "AUTO";
106
106
  id: string;
107
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
107
108
  projectId: string;
108
109
  budgetUsd: number | null;
109
110
  budgetMinutes: number | null;
@@ -115,8 +116,8 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
115
116
  agentAllowDestructiveGit?: boolean | undefined;
116
117
  }, {
117
118
  path: string;
118
- trustMode: "STRICT" | "NORMAL" | "AUTO";
119
119
  id: string;
120
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
120
121
  projectId: string;
121
122
  budgetUsd: number | null;
122
123
  budgetMinutes: number | null;
@@ -208,8 +209,8 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
208
209
  }, "strip", z.ZodTypeAny, {
209
210
  mode: "ask" | "plan" | "auto" | "full";
210
211
  agent: "CLAUDE" | "CODEX";
211
- id: string;
212
212
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
213
+ id: string;
213
214
  kind: "TICKET" | "CHAT";
214
215
  model: string | null;
215
216
  effort: string | null;
@@ -222,8 +223,8 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
222
223
  extraBudgetMinutes: number | null;
223
224
  workspace: {
224
225
  path: string;
225
- trustMode: "STRICT" | "NORMAL" | "AUTO";
226
226
  id: string;
227
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
227
228
  projectId: string;
228
229
  budgetUsd: number | null;
229
230
  budgetMinutes: number | null;
@@ -256,15 +257,15 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
256
257
  } | undefined;
257
258
  }, {
258
259
  agent: "CLAUDE" | "CODEX";
259
- id: string;
260
260
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
261
+ id: string;
261
262
  kind: "TICKET" | "CHAT";
262
263
  prompt: string;
263
264
  providerSessionId: string | null;
264
265
  workspace: {
265
266
  path: string;
266
- trustMode: "STRICT" | "NORMAL" | "AUTO";
267
267
  id: string;
268
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
268
269
  projectId: string;
269
270
  budgetUsd: number | null;
270
271
  budgetMinutes: number | null;
@@ -520,8 +521,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
520
521
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
521
522
  }, "strip", z.ZodTypeAny, {
522
523
  path: string;
523
- trustMode: "STRICT" | "NORMAL" | "AUTO";
524
524
  id: string;
525
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
525
526
  projectId: string;
526
527
  budgetUsd: number | null;
527
528
  budgetMinutes: number | null;
@@ -533,8 +534,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
533
534
  agentAllowDestructiveGit?: boolean | undefined;
534
535
  }, {
535
536
  path: string;
536
- trustMode: "STRICT" | "NORMAL" | "AUTO";
537
537
  id: string;
538
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
538
539
  projectId: string;
539
540
  budgetUsd: number | null;
540
541
  budgetMinutes: number | null;
@@ -626,8 +627,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
626
627
  }, "strip", z.ZodTypeAny, {
627
628
  mode: "ask" | "plan" | "auto" | "full";
628
629
  agent: "CLAUDE" | "CODEX";
629
- id: string;
630
630
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
631
+ id: string;
631
632
  kind: "TICKET" | "CHAT";
632
633
  model: string | null;
633
634
  effort: string | null;
@@ -640,8 +641,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
640
641
  extraBudgetMinutes: number | null;
641
642
  workspace: {
642
643
  path: string;
643
- trustMode: "STRICT" | "NORMAL" | "AUTO";
644
644
  id: string;
645
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
645
646
  projectId: string;
646
647
  budgetUsd: number | null;
647
648
  budgetMinutes: number | null;
@@ -674,15 +675,15 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
674
675
  } | undefined;
675
676
  }, {
676
677
  agent: "CLAUDE" | "CODEX";
677
- id: string;
678
678
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
679
+ id: string;
679
680
  kind: "TICKET" | "CHAT";
680
681
  prompt: string;
681
682
  providerSessionId: string | null;
682
683
  workspace: {
683
684
  path: string;
684
- trustMode: "STRICT" | "NORMAL" | "AUTO";
685
685
  id: string;
686
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
686
687
  projectId: string;
687
688
  budgetUsd: number | null;
688
689
  budgetMinutes: number | null;
@@ -721,8 +722,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
721
722
  sessions: {
722
723
  mode: "ask" | "plan" | "auto" | "full";
723
724
  agent: "CLAUDE" | "CODEX";
724
- id: string;
725
725
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
726
+ id: string;
726
727
  kind: "TICKET" | "CHAT";
727
728
  model: string | null;
728
729
  effort: string | null;
@@ -735,8 +736,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
735
736
  extraBudgetMinutes: number | null;
736
737
  workspace: {
737
738
  path: string;
738
- trustMode: "STRICT" | "NORMAL" | "AUTO";
739
739
  id: string;
740
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
740
741
  projectId: string;
741
742
  budgetUsd: number | null;
742
743
  budgetMinutes: number | null;
@@ -775,15 +776,15 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
775
776
  }, {
776
777
  sessions: {
777
778
  agent: "CLAUDE" | "CODEX";
778
- id: string;
779
779
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
780
+ id: string;
780
781
  kind: "TICKET" | "CHAT";
781
782
  prompt: string;
782
783
  providerSessionId: string | null;
783
784
  workspace: {
784
785
  path: string;
785
- trustMode: "STRICT" | "NORMAL" | "AUTO";
786
786
  id: string;
787
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
787
788
  projectId: string;
788
789
  budgetUsd: number | null;
789
790
  budgetMinutes: number | null;
@@ -953,8 +954,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
953
954
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
954
955
  }, "strip", z.ZodTypeAny, {
955
956
  path: string;
956
- trustMode: "STRICT" | "NORMAL" | "AUTO";
957
957
  id: string;
958
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
958
959
  projectId: string;
959
960
  budgetUsd: number | null;
960
961
  budgetMinutes: number | null;
@@ -966,8 +967,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
966
967
  agentAllowDestructiveGit?: boolean | undefined;
967
968
  }, {
968
969
  path: string;
969
- trustMode: "STRICT" | "NORMAL" | "AUTO";
970
970
  id: string;
971
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
971
972
  projectId: string;
972
973
  budgetUsd: number | null;
973
974
  budgetMinutes: number | null;
@@ -1059,8 +1060,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1059
1060
  }, "strip", z.ZodTypeAny, {
1060
1061
  mode: "ask" | "plan" | "auto" | "full";
1061
1062
  agent: "CLAUDE" | "CODEX";
1062
- id: string;
1063
1063
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
1064
+ id: string;
1064
1065
  kind: "TICKET" | "CHAT";
1065
1066
  model: string | null;
1066
1067
  effort: string | null;
@@ -1073,8 +1074,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1073
1074
  extraBudgetMinutes: number | null;
1074
1075
  workspace: {
1075
1076
  path: string;
1076
- trustMode: "STRICT" | "NORMAL" | "AUTO";
1077
1077
  id: string;
1078
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
1078
1079
  projectId: string;
1079
1080
  budgetUsd: number | null;
1080
1081
  budgetMinutes: number | null;
@@ -1107,15 +1108,15 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1107
1108
  } | undefined;
1108
1109
  }, {
1109
1110
  agent: "CLAUDE" | "CODEX";
1110
- id: string;
1111
1111
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
1112
+ id: string;
1112
1113
  kind: "TICKET" | "CHAT";
1113
1114
  prompt: string;
1114
1115
  providerSessionId: string | null;
1115
1116
  workspace: {
1116
1117
  path: string;
1117
- trustMode: "STRICT" | "NORMAL" | "AUTO";
1118
1118
  id: string;
1119
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
1119
1120
  projectId: string;
1120
1121
  budgetUsd: number | null;
1121
1122
  budgetMinutes: number | null;
@@ -1154,8 +1155,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1154
1155
  session: {
1155
1156
  mode: "ask" | "plan" | "auto" | "full";
1156
1157
  agent: "CLAUDE" | "CODEX";
1157
- id: string;
1158
1158
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
1159
+ id: string;
1159
1160
  kind: "TICKET" | "CHAT";
1160
1161
  model: string | null;
1161
1162
  effort: string | null;
@@ -1168,8 +1169,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1168
1169
  extraBudgetMinutes: number | null;
1169
1170
  workspace: {
1170
1171
  path: string;
1171
- trustMode: "STRICT" | "NORMAL" | "AUTO";
1172
1172
  id: string;
1173
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
1173
1174
  projectId: string;
1174
1175
  budgetUsd: number | null;
1175
1176
  budgetMinutes: number | null;
@@ -1205,15 +1206,15 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1205
1206
  }, {
1206
1207
  session: {
1207
1208
  agent: "CLAUDE" | "CODEX";
1208
- id: string;
1209
1209
  status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
1210
+ id: string;
1210
1211
  kind: "TICKET" | "CHAT";
1211
1212
  prompt: string;
1212
1213
  providerSessionId: string | null;
1213
1214
  workspace: {
1214
1215
  path: string;
1215
- trustMode: "STRICT" | "NORMAL" | "AUTO";
1216
1216
  id: string;
1217
+ trustMode: "STRICT" | "NORMAL" | "AUTO";
1217
1218
  projectId: string;
1218
1219
  budgetUsd: number | null;
1219
1220
  budgetMinutes: number | null;
@@ -1673,7 +1674,22 @@ export type RunnerFrame = {
1673
1674
  */
1674
1675
  | ({
1675
1676
  type: 'host_load';
1676
- } & HostLoadFrame) | {
1677
+ } & HostLoadFrame)
1678
+ /**
1679
+ * How this machine is dividing its memory between the sessions on it, right
1680
+ * now (#398 S4).
1681
+ *
1682
+ * Machine-scoped with a `sessions` array rather than one frame per session:
1683
+ * half of what has to be shown is about the MACHINE — what is free, what is
1684
+ * held by things that are not DevBridge, how many guarantees fit — and a
1685
+ * machine with no live sessions still has to be able to say it.
1686
+ *
1687
+ * Sent only when it moved, on the same tick that recomputes it. Fields mirror
1688
+ * `@devbridge/shared` — see `session-limits.ts`.
1689
+ */
1690
+ | ({
1691
+ type: 'session_limits';
1692
+ } & SessionLimitsFrame) | {
1677
1693
  type: 'pong';
1678
1694
  };
1679
1695
  //# sourceMappingURL=protocol.d.ts.map
@@ -134,17 +134,17 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
134
134
  env?: Record<string, string> | undefined;
135
135
  timeoutSec?: number | undefined;
136
136
  } | undefined;
137
- verify?: {
137
+ test?: {
138
138
  run: string;
139
139
  env?: Record<string, string> | undefined;
140
140
  timeoutSec?: number | undefined;
141
141
  } | undefined;
142
- build?: {
142
+ verify?: {
143
143
  run: string;
144
144
  env?: Record<string, string> | undefined;
145
145
  timeoutSec?: number | undefined;
146
146
  } | undefined;
147
- test?: {
147
+ build?: {
148
148
  run: string;
149
149
  env?: Record<string, string> | undefined;
150
150
  timeoutSec?: number | undefined;
@@ -160,17 +160,17 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
160
160
  env?: Record<string, string> | undefined;
161
161
  timeoutSec?: number | undefined;
162
162
  } | undefined;
163
- verify?: {
163
+ test?: {
164
164
  run: string;
165
165
  env?: Record<string, string> | undefined;
166
166
  timeoutSec?: number | undefined;
167
167
  } | undefined;
168
- build?: {
168
+ verify?: {
169
169
  run: string;
170
170
  env?: Record<string, string> | undefined;
171
171
  timeoutSec?: number | undefined;
172
172
  } | undefined;
173
- test?: {
173
+ build?: {
174
174
  run: string;
175
175
  env?: Record<string, string> | undefined;
176
176
  timeoutSec?: number | undefined;
@@ -219,17 +219,17 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
219
219
  env?: Record<string, string> | undefined;
220
220
  timeoutSec?: number | undefined;
221
221
  } | undefined;
222
- verify?: {
222
+ test?: {
223
223
  run: string;
224
224
  env?: Record<string, string> | undefined;
225
225
  timeoutSec?: number | undefined;
226
226
  } | undefined;
227
- build?: {
227
+ verify?: {
228
228
  run: string;
229
229
  env?: Record<string, string> | undefined;
230
230
  timeoutSec?: number | undefined;
231
231
  } | undefined;
232
- test?: {
232
+ build?: {
233
233
  run: string;
234
234
  env?: Record<string, string> | undefined;
235
235
  timeoutSec?: number | undefined;
@@ -268,17 +268,17 @@ export declare const ProjectRecipeSchema: z.ZodObject<{
268
268
  env?: Record<string, string> | undefined;
269
269
  timeoutSec?: number | undefined;
270
270
  } | undefined;
271
- verify?: {
271
+ test?: {
272
272
  run: string;
273
273
  env?: Record<string, string> | undefined;
274
274
  timeoutSec?: number | undefined;
275
275
  } | undefined;
276
- build?: {
276
+ verify?: {
277
277
  run: string;
278
278
  env?: Record<string, string> | undefined;
279
279
  timeoutSec?: number | undefined;
280
280
  } | undefined;
281
- test?: {
281
+ build?: {
282
282
  run: string;
283
283
  env?: Record<string, string> | undefined;
284
284
  timeoutSec?: number | undefined;
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { findClaudeCli, USE_BUNDLED_CLAUDE } from './agent-binary.js';
7
7
  import { systemdUserEnv } from './environment.js';
8
+ import { cageAuthority, CageAuthorityError } from './cage-authority.js';
8
9
  import { log } from './log.js';
9
10
  import { stateDir } from './paths.js';
10
11
  import { RUNNER_VERSION } from './version.js';
@@ -386,8 +387,28 @@ function systemctlEnv() {
386
387
  return env;
387
388
  }
388
389
  export async function selfUpdate(options) {
390
+ /**
391
+ * The default executor installs software on somebody's machine and reloads
392
+ * their systemd, so it asks the same door the cages ask (#403).
393
+ *
394
+ * The check is on the DEFAULT and not on the injected one: a test hands its
395
+ * own executor and is testing the sequence of commands, which is exactly what
396
+ * it should be able to do. What must not happen is a call with no executor
397
+ * from a process that has no business updating anything — `npm install -g`
398
+ * from a vitest worker would be the incident of 10.09.2026 with a much longer
399
+ * tail.
400
+ */
389
401
  const exec = options.exec ??
390
- ((file, args, opts) => execFileAsync(file, args, { timeout: opts.timeout, env: opts.env, maxBuffer: 4_000_000 }));
402
+ ((file, args, opts) => {
403
+ if (cageAuthority() === 'none') {
404
+ throw new CageAuthorityError(`this process may not run «${file}»: updating this machine's runner is the daemon's job`);
405
+ }
406
+ return execFileAsync(file, args, {
407
+ timeout: opts.timeout,
408
+ env: opts.env,
409
+ maxBuffer: 4_000_000,
410
+ });
411
+ });
391
412
  const fromVersion = RUNNER_VERSION;
392
413
  const fail = (detail, extra = {}) => ({
393
414
  ok: false,
@@ -57,7 +57,7 @@ export declare function buildUnit(execStart?: string, nodeBinary?: string): stri
57
57
  * which is the only way to fix the servers that already have the bad numbers
58
58
  * baked in — and it never overwrites a unit the operator edited by hand.
59
59
  */
60
- export declare const LIMITS_VERSION = 5;
60
+ export declare const LIMITS_VERSION = 6;
61
61
  /**
62
62
  * Where the agent sessions live once they have a cage of their own.
63
63
  *
@@ -239,6 +239,19 @@ export declare function managedUsageFloorBytes(facts: MemoryFacts): number;
239
239
  * - cap 85 % of total: on an idle dedicated box `MemAvailable` is nearly the whole
240
240
  * machine, and a ceiling of «everything» is the bug this function exists to fix.
241
241
  */
242
+ /**
243
+ * What the machine keeps for itself, whatever the agents are doing.
244
+ *
245
+ * Enough for sshd, journald, the kernel and some page cache to keep working
246
+ * while our cgroups sit at their ceiling. Proportional on a big machine,
247
+ * absolute on a small one, because 15 % of 4 GB is not enough to stay reachable.
248
+ *
249
+ * Hoisted out of {@link memoryPolicy} for #398 S3: the live allocator has to
250
+ * subtract exactly the same reserve when it decides how much of the machine is
251
+ * free right now, and a second copy of this number would drift from this one on
252
+ * the first machine where it mattered.
253
+ */
254
+ export declare function machineReserveBytes(totalBytes: number): number;
242
255
  export declare function memoryPolicy(facts: MemoryFacts, minCeilingBytes?: number): MemoryPolicy;
243
256
  /** `SwapTotal` of this machine, or null where `/proc/meminfo` will not say. */
244
257
  export declare function readSwapTotalBytes(): number | null;
@@ -418,7 +431,24 @@ export declare function buildLimitsOverride(cpuCount?: number, facts?: MemoryFac
418
431
  * can measure one (the drift check treats a file with no
419
432
  * `MemoryMax` as outdated).
420
433
  */
421
- export declare function buildSessionsSliceOverride(facts?: MemoryFacts | null, sessionsUsageBytes?: number | null): string;
434
+ export declare function buildSessionsSliceOverride(facts?: MemoryFacts | null, sessionsUsageBytes?: number | null, maxSessions?: number): string;
435
+ /**
436
+ * Where the collective brake sits under the collective ceiling.
437
+ *
438
+ * Nine tenths: close enough that the sessions get almost the whole pot before
439
+ * anything is throttled, far enough that the kernel has a tenth of the pot in
440
+ * which to slow everyone down and reclaim, instead of arriving at the hard
441
+ * ceiling and choosing a victim.
442
+ */
443
+ export declare const SLICE_BRAKE_OF_CEILING = 0.9;
444
+ /**
445
+ * Seats assumed when nobody has said otherwise.
446
+ *
447
+ * Three, which is what `DevServer.maxSessions` defaults to in the database. The
448
+ * runner's own config may say fewer; the slice's protection is sized for the
449
+ * seats the machine sells, not for the sessions that happen to be running.
450
+ */
451
+ export declare const DEFAULT_MAX_SESSIONS = 3;
422
452
  /**
423
453
  * The CPU share of everything the agents run, against the daemon's own.
424
454
  *
@@ -453,7 +483,9 @@ export declare function limitsOverrideIsOutdated(readFile?: (p: string) => strin
453
483
  * arrive at the same number — otherwise a write whose floor was binding would be
454
484
  * seen as drifted on the very next call and rewritten forever.
455
485
  */
456
- export declare function writeLimitsOverride(force?: boolean, home?: string, facts?: MemoryFacts | null, sessionsUsageBytes?: number | null): boolean;
486
+ export declare function writeLimitsOverride(force?: boolean, home?: string, facts?: MemoryFacts | null, sessionsUsageBytes?: number | null,
487
+ /** Seats this machine sells — sizes the slice's collective `MemoryLow` (#398 S3). */
488
+ maxSessions?: number): boolean;
457
489
  /**
458
490
  * Does the installed unit point at something that no longer exists?
459
491
  *
@@ -129,7 +129,7 @@ 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 = 5;
132
+ export const LIMITS_VERSION = 6;
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';
@@ -277,6 +277,21 @@ const CEILING_FLOOR_BYTES = 2 * GIB;
277
277
  * - cap 85 % of total: on an idle dedicated box `MemAvailable` is nearly the whole
278
278
  * machine, and a ceiling of «everything» is the bug this function exists to fix.
279
279
  */
280
+ /**
281
+ * What the machine keeps for itself, whatever the agents are doing.
282
+ *
283
+ * Enough for sshd, journald, the kernel and some page cache to keep working
284
+ * while our cgroups sit at their ceiling. Proportional on a big machine,
285
+ * absolute on a small one, because 15 % of 4 GB is not enough to stay reachable.
286
+ *
287
+ * Hoisted out of {@link memoryPolicy} for #398 S3: the live allocator has to
288
+ * subtract exactly the same reserve when it decides how much of the machine is
289
+ * free right now, and a second copy of this number would drift from this one on
290
+ * the first machine where it mattered.
291
+ */
292
+ export function machineReserveBytes(totalBytes) {
293
+ return Math.max(1.5 * GIB, totalBytes * 0.15);
294
+ }
280
295
  export function memoryPolicy(facts, minCeilingBytes = managedUsageFloorBytes(facts)) {
281
296
  const { totalBytes, availableBytes, ownUsageBytes, sessionsUsageBytes, uptimeSec } = facts;
282
297
  const swapMaxBytes = Math.floor(Math.max(0, facts.swapTotalBytes ?? 0) * SESSIONS_SWAP_SHARE);
@@ -307,7 +322,7 @@ export function memoryPolicy(facts, minCeilingBytes = managedUsageFloorBytes(fac
307
322
  // Enough for sshd, journald, the kernel and some page cache to keep working
308
323
  // while the cgroup sits at its ceiling. Proportional on a big machine, absolute
309
324
  // on a small one, because 15 % of 4 GB is not enough to stay reachable.
310
- const reserve = Math.max(1.5 * GIB, totalBytes * 0.15);
325
+ const reserve = machineReserveBytes(totalBytes);
311
326
  // Both cgroups are added back, and for the same reason ours always was: what
312
327
  // they hold is already OUT of `MemAvailable`, so leaving the sessions out
313
328
  // makes a loaded machine look starved and walks the ceiling down under the
@@ -682,7 +697,7 @@ const MANAGED_HEADER = [
682
697
  * can measure one (the drift check treats a file with no
683
698
  * `MemoryMax` as outdated).
684
699
  */
685
- export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUsageBytes = readSessionsSliceUsageOrNull()) {
700
+ export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUsageBytes = readSessionsSliceUsageOrNull(), maxSessions = DEFAULT_MAX_SESSIONS) {
686
701
  const memory = facts ? memoryPolicy(facts) : null;
687
702
  return ([
688
703
  ...MANAGED_HEADER,
@@ -691,6 +706,41 @@ export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUs
691
706
  ? [
692
707
  `# ${memory.measured ? 'measured headroom' : 'still booting — conservative fraction of total'}`,
693
708
  `MemoryMax=${asMiB(memory.maxBytes)}`,
709
+ /**
710
+ * The COLLECTIVE brake, and the thing that makes #398 S3 safe.
711
+ *
712
+ * Since S3 each session's own brake is deliberately oversold — every
713
+ * session may grow into everything that is free — so the sum of the
714
+ * brakes is larger than this slice. That is only safe while THIS
715
+ * line exists: as the sessions together approach the ceiling the
716
+ * kernel slows all of them and reclaims first from whoever is above
717
+ * their `MemoryLow`, and the hard ceiling above, where the OOM
718
+ * killer picks a victim across the whole slice, is never reached.
719
+ *
720
+ * Measured on this host before it existed: the slice read
721
+ * `MemoryHigh=infinity`, overflowing it ended in a kill rather than
722
+ * throttling, and the one killed was the neighbour sitting under its
723
+ * own limit (`memory.events` of the victim: `high 0, max 0, oom 0,
724
+ * oom_kill 1`). The allocator reads this property back and falls
725
+ * back to a non-oversold formula when it is not in force.
726
+ */
727
+ `MemoryHigh=${asMiB(Math.floor(memory.maxBytes * SLICE_BRAKE_OF_CEILING))}`,
728
+ /**
729
+ * …and the protection the guarantees are built on.
730
+ *
731
+ * `memory.low` of a child is bounded by its ancestors' protection:
732
+ * without this line a session's own `MemoryLow` protects nothing at
733
+ * all. Deliberately a CONSTANT (seats × one guarantee), not the sum
734
+ * of the guarantees of the sessions that happen to be live: the sum
735
+ * changes on every start and finish, the slice is never rewritten
736
+ * live (a blocker QA found on 07.09 — a rewritten slice kills the
737
+ * sessions under it), so a number that depended on the live roster
738
+ * would be true for the hour after this file is written and a lie
739
+ * for the rest of it. What separates one session from another lives
740
+ * on their own scopes; this line only gives them the right to be
741
+ * protected at all.
742
+ */
743
+ `MemoryLow=${asMiB(Math.min(Math.max(1, maxSessions) * SESSION_GUARANTEE_BYTES, memory.maxBytes))}`,
694
744
  ]
695
745
  : unmeasuredSliceCeiling(sessionsUsageBytes)),
696
746
  // The line the cage is built on. See `session-cage.ts` and the note above.
@@ -701,6 +751,31 @@ export function buildSessionsSliceOverride(facts = readMemoryFacts(), sessionsUs
701
751
  'CPUAccounting=yes',
702
752
  ].join('\n') + '\n');
703
753
  }
754
+ /**
755
+ * Where the collective brake sits under the collective ceiling.
756
+ *
757
+ * Nine tenths: close enough that the sessions get almost the whole pot before
758
+ * anything is throttled, far enough that the kernel has a tenth of the pot in
759
+ * which to slow everyone down and reclaim, instead of arriving at the hard
760
+ * ceiling and choosing a victim.
761
+ */
762
+ export const SLICE_BRAKE_OF_CEILING = 0.9;
763
+ /**
764
+ * The guarantee one session gets, in the one place the slice needs to know it.
765
+ *
766
+ * Copied rather than imported from `session-allocator.ts`: that module imports
767
+ * `session-cage.ts`, which imports this one, and the cycle would be real. The
768
+ * two are compared by a test (`service-unit.test.ts`) so they cannot drift.
769
+ */
770
+ const SESSION_GUARANTEE_BYTES = 2048 * MIB;
771
+ /**
772
+ * Seats assumed when nobody has said otherwise.
773
+ *
774
+ * Three, which is what `DevServer.maxSessions` defaults to in the database. The
775
+ * runner's own config may say fewer; the slice's protection is sized for the
776
+ * seats the machine sells, not for the sessions that happen to be running.
777
+ */
778
+ export const DEFAULT_MAX_SESSIONS = 3;
704
779
  /**
705
780
  * The `[Slice]` lines for a machine whose memory the policy could not measure.
706
781
  * See {@link buildSessionsSliceOverride} for why each of the three answers is
@@ -853,7 +928,9 @@ function swapShareHasDrifted(contents, facts) {
853
928
  * arrive at the same number — otherwise a write whose floor was binding would be
854
929
  * seen as drifted on the very next call and rewritten forever.
855
930
  */
856
- export function writeLimitsOverride(force = false, home = systemdUserHome(), facts = readMemoryFacts(), sessionsUsageBytes = readSessionsSliceUsageOrNull()) {
931
+ export function writeLimitsOverride(force = false, home = systemdUserHome(), facts = readMemoryFacts(), sessionsUsageBytes = readSessionsSliceUsageOrNull(),
932
+ /** Seats this machine sells — sizes the slice's collective `MemoryLow` (#398 S3). */
933
+ maxSessions = DEFAULT_MAX_SESSIONS) {
857
934
  // Unknown usage can be a busy service whose systemd query timed out. Do not
858
935
  // replace an existing drop-in with a guessed percentage, or remove a limit
859
936
  // by rewriting the file without its MemoryMax line. Defer the whole policy.
@@ -866,7 +943,7 @@ export function writeLimitsOverride(force = false, home = systemdUserHome(), fac
866
943
  // them is a machine whose sessions are capped but whose daemon is not
867
944
  // prioritised — the regression described on `buildDevbridgeSliceOverride`.
868
945
  write(limitsOverridePath(home), buildLimitsOverride(undefined, facts));
869
- write(sessionsSliceOverridePath(home), buildSessionsSliceOverride(facts, sessionsUsageBytes));
946
+ write(sessionsSliceOverridePath(home), buildSessionsSliceOverride(facts, sessionsUsageBytes, maxSessions));
870
947
  write(devbridgeSliceOverridePath(home), buildDevbridgeSliceOverride());
871
948
  return true;
872
949
  }