@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.
package/dist/config.d.ts CHANGED
@@ -41,11 +41,79 @@ declare const ConfigSchema: z.ZodObject<{
41
41
  auth?: "link" | "own" | undefined;
42
42
  }>>;
43
43
  limits: z.ZodOptional<z.ZodObject<{
44
- max_sessions: z.ZodNumber;
44
+ /**
45
+ * OPTIONAL since 0.58.0, and the change is a bug fix rather than a
46
+ * loosening.
47
+ *
48
+ * `loadConfig()` uses `.parse`, not `safeParse`, and a ZodError from it
49
+ * reaches `main().catch` and exits 1 — under systemd that is a restart
50
+ * loop. So while this field was required, a machine owner who opened
51
+ * `config.toml` to add any OTHER key under `[limits]` and did not happen
52
+ * to have this one would have taken their dev server down, and `doctor`
53
+ * could not have told them why: it dies on the same parse.
54
+ *
55
+ * The default is unchanged — absence means «the API's number decides»,
56
+ * which is what `get maxSessions()` already did.
57
+ */
58
+ max_sessions: z.ZodOptional<z.ZodNumber>;
59
+ /**
60
+ * #398 S6: the emergency switch back to the pre-0.58.0 behaviour.
61
+ *
62
+ * `false` puts every session on the fixed `max(pot / 3, 2 GiB)` share
63
+ * that shipped in 0.55.0, computed once at daemon start and never moved.
64
+ * It exists because this formula travels to dev servers nobody here has
65
+ * ever seen, and the two previous memory rules were both reasonable on
66
+ * the author's machine and destructive somewhere else. A machine owner
67
+ * who has to get work done tonight needs a way back that does not involve
68
+ * downgrading the runner.
69
+ */
70
+ adaptive_memory: z.ZodOptional<z.ZodBoolean>;
71
+ /**
72
+ * The guarantee, in megabytes — what a session on this machine will not
73
+ * have taken away.
74
+ *
75
+ * The default is 2048, sized over the 1571 MB peak measured for a
76
+ * workspace `pnpm typecheck`. Lower it on a machine that runs lighter
77
+ * work and wants more sessions; raise it on one that runs heavier.
78
+ */
79
+ session_memory_min: z.ZodOptional<z.ZodNumber>;
80
+ /**
81
+ * A ceiling on the ceiling, in megabytes — the most any single session
82
+ * may be allowed to grow to, whatever the machine has free.
83
+ *
84
+ * For the owner who wants agents to leave room for something the runner
85
+ * cannot see (a build they run by hand, a database that spikes).
86
+ */
87
+ session_memory_max: z.ZodOptional<z.ZodNumber>;
88
+ /**
89
+ * How long a session that has stopped moving is given before its biggest
90
+ * command is stopped, in seconds. Default 180 (decision D6).
91
+ */
92
+ memory_stall_grace_sec: z.ZodOptional<z.ZodNumber>;
93
+ /**
94
+ * What happens when that time runs out: `stop-command` (the default) or
95
+ * `report-only`.
96
+ *
97
+ * `report-only` is for the owner who would rather have a session stand
98
+ * still than have a command stopped under it. It changes nothing else —
99
+ * the deadline still runs and the strip still shows it, so the person can
100
+ * act; only DevBridge stops acting on their behalf.
101
+ */
102
+ memory_stall_action: z.ZodOptional<z.ZodEnum<["stop-command", "report-only"]>>;
45
103
  }, "strip", z.ZodTypeAny, {
46
- max_sessions: number;
104
+ max_sessions?: number | undefined;
105
+ adaptive_memory?: boolean | undefined;
106
+ session_memory_min?: number | undefined;
107
+ session_memory_max?: number | undefined;
108
+ memory_stall_grace_sec?: number | undefined;
109
+ memory_stall_action?: "stop-command" | "report-only" | undefined;
47
110
  }, {
48
- max_sessions: number;
111
+ max_sessions?: number | undefined;
112
+ adaptive_memory?: boolean | undefined;
113
+ session_memory_min?: number | undefined;
114
+ session_memory_max?: number | undefined;
115
+ memory_stall_grace_sec?: number | undefined;
116
+ memory_stall_action?: "stop-command" | "report-only" | undefined;
49
117
  }>>;
50
118
  /**
51
119
  * Session 14: the machine owner's veto over running project recipes.
@@ -126,7 +194,12 @@ declare const ConfigSchema: z.ZodObject<{
126
194
  auth: "link" | "own";
127
195
  } | undefined;
128
196
  limits?: {
129
- max_sessions: number;
197
+ max_sessions?: number | undefined;
198
+ adaptive_memory?: boolean | undefined;
199
+ session_memory_min?: number | undefined;
200
+ session_memory_max?: number | undefined;
201
+ memory_stall_grace_sec?: number | undefined;
202
+ memory_stall_action?: "stop-command" | "report-only" | undefined;
130
203
  } | undefined;
131
204
  verify?: {
132
205
  enabled: boolean;
@@ -155,7 +228,12 @@ declare const ConfigSchema: z.ZodObject<{
155
228
  auth?: "link" | "own" | undefined;
156
229
  } | undefined;
157
230
  limits?: {
158
- max_sessions: number;
231
+ max_sessions?: number | undefined;
232
+ adaptive_memory?: boolean | undefined;
233
+ session_memory_min?: number | undefined;
234
+ session_memory_max?: number | undefined;
235
+ memory_stall_grace_sec?: number | undefined;
236
+ memory_stall_action?: "stop-command" | "report-only" | undefined;
159
237
  } | undefined;
160
238
  verify?: {
161
239
  enabled?: boolean | undefined;
package/dist/config.js CHANGED
@@ -41,7 +41,65 @@ const ConfigSchema = z.object({
41
41
  // processes and worktrees may exist at once.
42
42
  limits: z
43
43
  .object({
44
- max_sessions: z.number().int().min(1).max(64),
44
+ /**
45
+ * OPTIONAL since 0.58.0, and the change is a bug fix rather than a
46
+ * loosening.
47
+ *
48
+ * `loadConfig()` uses `.parse`, not `safeParse`, and a ZodError from it
49
+ * reaches `main().catch` and exits 1 — under systemd that is a restart
50
+ * loop. So while this field was required, a machine owner who opened
51
+ * `config.toml` to add any OTHER key under `[limits]` and did not happen
52
+ * to have this one would have taken their dev server down, and `doctor`
53
+ * could not have told them why: it dies on the same parse.
54
+ *
55
+ * The default is unchanged — absence means «the API's number decides»,
56
+ * which is what `get maxSessions()` already did.
57
+ */
58
+ max_sessions: z.number().int().min(1).max(64).optional(),
59
+ /**
60
+ * #398 S6: the emergency switch back to the pre-0.58.0 behaviour.
61
+ *
62
+ * `false` puts every session on the fixed `max(pot / 3, 2 GiB)` share
63
+ * that shipped in 0.55.0, computed once at daemon start and never moved.
64
+ * It exists because this formula travels to dev servers nobody here has
65
+ * ever seen, and the two previous memory rules were both reasonable on
66
+ * the author's machine and destructive somewhere else. A machine owner
67
+ * who has to get work done tonight needs a way back that does not involve
68
+ * downgrading the runner.
69
+ */
70
+ adaptive_memory: z.boolean().optional(),
71
+ /**
72
+ * The guarantee, in megabytes — what a session on this machine will not
73
+ * have taken away.
74
+ *
75
+ * The default is 2048, sized over the 1571 MB peak measured for a
76
+ * workspace `pnpm typecheck`. Lower it on a machine that runs lighter
77
+ * work and wants more sessions; raise it on one that runs heavier.
78
+ */
79
+ session_memory_min: z.number().int().min(256).max(262144).optional(),
80
+ /**
81
+ * A ceiling on the ceiling, in megabytes — the most any single session
82
+ * may be allowed to grow to, whatever the machine has free.
83
+ *
84
+ * For the owner who wants agents to leave room for something the runner
85
+ * cannot see (a build they run by hand, a database that spikes).
86
+ */
87
+ session_memory_max: z.number().int().min(256).max(1048576).optional(),
88
+ /**
89
+ * How long a session that has stopped moving is given before its biggest
90
+ * command is stopped, in seconds. Default 180 (decision D6).
91
+ */
92
+ memory_stall_grace_sec: z.number().int().min(30).max(3600).optional(),
93
+ /**
94
+ * What happens when that time runs out: `stop-command` (the default) or
95
+ * `report-only`.
96
+ *
97
+ * `report-only` is for the owner who would rather have a session stand
98
+ * still than have a command stopped under it. It changes nothing else —
99
+ * the deadline still runs and the strip still shows it, so the person can
100
+ * act; only DevBridge stops acting on their behalf.
101
+ */
102
+ memory_stall_action: z.enum(['stop-command', 'report-only']).optional(),
45
103
  })
46
104
  .optional(),
47
105
  /**
@@ -0,0 +1,43 @@
1
+ /**
2
+ * One daemon per user manager, and the reason is the sweep (#403).
3
+ *
4
+ * `cmdDaemon` stops every session scope it does not know about, and at start-up
5
+ * it knows about none — that is the point: a scope outliving a killed daemon
6
+ * carries a whole process tree with it. But the same line, run by a SECOND
7
+ * daemon on a machine where the first one is working, ends every live session
8
+ * the first one is supervising. `pnpm --filter @bridge4dev/runner dev` beside a
9
+ * paired runner is enough, and no amount of care inside the test suite touches
10
+ * that path.
11
+ *
12
+ * The lock is in `/run/user/<uid>` and not in the state directory on purpose:
13
+ * `DEVBRIDGE_RUNNER_HOME` moves the state directory (the test contour uses it),
14
+ * and two daemons with different state directories still share one systemd user
15
+ * bus — which is the thing being protected.
16
+ */
17
+ export interface DaemonLock {
18
+ /** Where the lock lives, for the message a person reads. */
19
+ path: string;
20
+ /** Give it up — on shutdown, or when the daemon is done with it. */
21
+ release: () => void;
22
+ }
23
+ export interface HeldByAnother {
24
+ heldBy: number;
25
+ path: string;
26
+ }
27
+ /** The lock file for this user's manager. */
28
+ export declare function daemonLockPath(): string;
29
+ /**
30
+ * Take the lock, or say who has it.
31
+ *
32
+ * A stale lock — the pid in it is gone — is taken over silently: a daemon that
33
+ * was killed with `SIGKILL` cannot clean up after itself, and refusing to start
34
+ * after a crash would be a worse failure than the one being prevented.
35
+ */
36
+ export declare function acquireDaemonLock(io?: {
37
+ lockPath?: () => string;
38
+ isAlive?: (pid: number) => boolean;
39
+ now?: () => number;
40
+ }): DaemonLock | HeldByAnother;
41
+ /** True when this is a refusal rather than a lock. */
42
+ export declare function isHeldByAnother(result: DaemonLock | HeldByAnother): result is HeldByAnother;
43
+ //# sourceMappingURL=daemon-lock.d.ts.map
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { runnerIdentity, systemdUserEnv } from './environment.js';
4
+ import { log } from './log.js';
5
+ import { isPidAlive } from './status-file.js';
6
+ /** The lock file for this user's manager. */
7
+ export function daemonLockPath() {
8
+ const runtimeDir = systemdUserEnv()['XDG_RUNTIME_DIR'];
9
+ const uid = runnerIdentity().uid;
10
+ const dir = runtimeDir ?? path.join('/run/user', String(uid));
11
+ return path.join(dir, 'devbridge-runner.daemon.lock');
12
+ }
13
+ /**
14
+ * Take the lock, or say who has it.
15
+ *
16
+ * A stale lock — the pid in it is gone — is taken over silently: a daemon that
17
+ * was killed with `SIGKILL` cannot clean up after itself, and refusing to start
18
+ * after a crash would be a worse failure than the one being prevented.
19
+ */
20
+ export function acquireDaemonLock(io = {}) {
21
+ const file = (io.lockPath ?? daemonLockPath)();
22
+ const alive = io.isAlive ?? isPidAlive;
23
+ const write = () => {
24
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, at: (io.now ?? Date.now)() }), {
25
+ flag: 'w',
26
+ });
27
+ };
28
+ /**
29
+ * The directory first and on its own, because its `EEXIST` means the opposite
30
+ * of the one below: «the folder is already there», which is the ordinary
31
+ * case. Mixed into the same `try`, it was read as «somebody holds the lock».
32
+ */
33
+ try {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ }
36
+ catch (error) {
37
+ if (error.code !== 'EEXIST') {
38
+ log.warn('daemon: could not take the single-daemon lock, starting without it', {
39
+ path: file,
40
+ error: String(error instanceof Error ? error.message : error),
41
+ });
42
+ return { path: file, release: () => { } };
43
+ }
44
+ }
45
+ for (let attempt = 0; attempt < 2; attempt += 1) {
46
+ try {
47
+ // `wx` fails when the file exists — that is the whole exclusion.
48
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, at: (io.now ?? Date.now)() }), {
49
+ flag: 'wx',
50
+ });
51
+ return { path: file, release: () => releaseLockFile(file) };
52
+ }
53
+ catch (error) {
54
+ if (error.code !== 'EEXIST') {
55
+ /**
56
+ * The runtime directory is not there, or is not writable. Not a reason
57
+ * to refuse to start: a machine with no `/run/user/<uid>` has no user
58
+ * manager either, so it has no session scopes to protect.
59
+ */
60
+ log.warn('daemon: could not take the single-daemon lock, starting without it', {
61
+ path: file,
62
+ error: String(error instanceof Error ? error.message : error),
63
+ });
64
+ return { path: file, release: () => { } };
65
+ }
66
+ const holder = readHolder(file);
67
+ if (holder !== null && holder !== process.pid && alive(holder)) {
68
+ return { heldBy: holder, path: file };
69
+ }
70
+ // Stale (the holder is gone) or ours from a previous life: take it over.
71
+ try {
72
+ fs.rmSync(file, { force: true });
73
+ }
74
+ catch {
75
+ // Somebody else got there first; the next attempt will find out whose.
76
+ }
77
+ if (attempt === 1) {
78
+ write();
79
+ return { path: file, release: () => releaseLockFile(file) };
80
+ }
81
+ }
82
+ }
83
+ return { path: file, release: () => releaseLockFile(file) };
84
+ }
85
+ /** True when this is a refusal rather than a lock. */
86
+ export function isHeldByAnother(result) {
87
+ return 'heldBy' in result;
88
+ }
89
+ function readHolder(file) {
90
+ try {
91
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
92
+ return typeof raw.pid === 'number' && raw.pid > 0 ? raw.pid : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ function releaseLockFile(file) {
99
+ try {
100
+ if (readHolder(file) === process.pid)
101
+ fs.rmSync(file, { force: true });
102
+ }
103
+ catch {
104
+ // Gone already, or never ours. Nothing to undo.
105
+ }
106
+ }
107
+ //# sourceMappingURL=daemon-lock.js.map
@@ -12,6 +12,15 @@
12
12
  * both world-readable, neither containing a path, a command line, an
13
13
  * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
14
14
  *
15
+ * **One module is allowed past that line, and it is named here so the rule and
16
+ * the code cannot drift apart:** `session-stall.ts` reads `/proc/<pid>` for the
17
+ * processes of ONE session's own cgroup, in order to choose which command to
18
+ * stop when that session has run out of memory (#398 S2). It is not telemetry —
19
+ * nothing it reads is reported anywhere, and the single thing that leaves the
20
+ * machine is a command NAME with no arguments, because arguments carry keys and
21
+ * tokens. Choosing a victim cannot be done from two summary files, and the
22
+ * alternative was killing the session instead of its command.
23
+ *
15
24
  * Mirror of `packages/shared/src/schemas/host-load.ts` and the `HOST_LOAD_*`
16
25
  * block of `packages/shared/src/constants/runner.ts` — the DevBridge side is
17
26
  * the source of truth, exactly like `levels.ts` mirrors the level thresholds
package/dist/host-load.js CHANGED
@@ -15,6 +15,15 @@ import path from 'node:path';
15
15
  * both world-readable, neither containing a path, a command line, an
16
16
  * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
17
17
  *
18
+ * **One module is allowed past that line, and it is named here so the rule and
19
+ * the code cannot drift apart:** `session-stall.ts` reads `/proc/<pid>` for the
20
+ * processes of ONE session's own cgroup, in order to choose which command to
21
+ * stop when that session has run out of memory (#398 S2). It is not telemetry —
22
+ * nothing it reads is reported anywhere, and the single thing that leaves the
23
+ * machine is a command NAME with no arguments, because arguments carry keys and
24
+ * tokens. Choosing a victim cannot be done from two summary files, and the
25
+ * alternative was killing the session instead of its command.
26
+ *
18
27
  * Mirror of `packages/shared/src/schemas/host-load.ts` and the `HOST_LOAD_*`
19
28
  * block of `packages/shared/src/constants/runner.ts` — the DevBridge side is
20
29
  * the source of truth, exactly like `levels.ts` mirrors the level thresholds