@bridge4dev/runner 0.70.0 → 0.71.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.
@@ -6,11 +6,13 @@
6
6
  * is on its knees», so it said nothing, and the first symptom a user saw was a
7
7
  * session that had gone quiet (plan `agent-sessions-host-resources.md` §5.3).
8
8
  *
9
- * Two files and nothing else. That is a security property, not an
10
- * implementation detail: this runner reports to a server, so what it may read
11
- * for telemetry is listed here in full — `/proc/loadavg` and `/proc/meminfo`,
12
- * both world-readable, neither containing a path, a command line, an
13
- * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
9
+ * Two files and one filesystem call, and nothing else. That is a security
10
+ * property, not an implementation detail: this runner reports to a server, so
11
+ * what it may read for telemetry is listed here in full — `/proc/loadavg` and
12
+ * `/proc/meminfo`, both world-readable, neither containing a path, a command
13
+ * line, an environment variable or a process name; and `statfs('/')` (0.71.0,
14
+ * #485), which answers four sizes of the root filesystem and nothing about what
15
+ * is on it. No `/proc/<pid>` walk, no `ps`, no directory listing, no `du`.
14
16
  *
15
17
  * **One module is allowed past that line, and it is named here so the rule and
16
18
  * the code cannot drift apart:** `session-stall.ts` reads `/proc/<pid>` for the
@@ -89,6 +91,13 @@ export interface HostLoadFrame {
89
91
  /** Zero on a machine with no swap; consumers must not divide blindly. */
90
92
  swapTotalBytes: number;
91
93
  swapFreeBytes: number;
94
+ /**
95
+ * The root filesystem (0.71.0, #485) – or `null` when it could not be read,
96
+ * which is «disk unknown» and never a reason to hold the rest of the frame
97
+ * back. It rides the same minute-long heartbeat as everything else here: a
98
+ * disk fills over hours, and a trigger of its own would buy nothing.
99
+ */
100
+ disk: HostDiskFrame | null;
92
101
  /**
93
102
  * When THIS MACHINE measured it, ISO.
94
103
  *
@@ -98,6 +107,31 @@ export interface HostLoadFrame {
98
107
  */
99
108
  at: string;
100
109
  }
110
+ /**
111
+ * Free space on the root filesystem – what a full disk stops the machine at.
112
+ *
113
+ * Mirror of `HostDiskFrameSchema` in `@devbridge/shared`. The same arithmetic as
114
+ * the DevBridge host's own reading (`admin-host.service.ts`), so «free» means one
115
+ * thing on the Fleet screen whichever machine it describes: `freeBytes` is
116
+ * `bavail` (what an unprivileged writer can still use, not `bfree`), `usedBytes`
117
+ * is total − `bfree` (what `df` calls used).
118
+ */
119
+ export interface HostDiskFrame {
120
+ /** Always {@link HOST_DISK_PATH} today; carried so the reading describes itself. */
121
+ path: string;
122
+ totalBytes: number;
123
+ freeBytes: number;
124
+ usedBytes: number;
125
+ }
126
+ /** The filesystem measured: the root, where running out stops everything. */
127
+ export declare const HOST_DISK_PATH = "/";
128
+ /** What `fs.statfsSync` answers, as far as a size is concerned. */
129
+ export interface DiskStats {
130
+ bsize: number;
131
+ blocks: number;
132
+ bfree: number;
133
+ bavail: number;
134
+ }
101
135
  /** Test seams. Production calls `readHostLoad()` with nothing. */
102
136
  export interface HostLoadSources {
103
137
  /**
@@ -111,7 +145,21 @@ export interface HostLoadSources {
111
145
  cpuCount?: number;
112
146
  /** The clock behind `at`. */
113
147
  now?: () => Date;
148
+ /**
149
+ * `statfs`, for the same reason as `procDir`: a full disk, a filesystem that
150
+ * refuses the call and a reading that does not add up are exactly the states a
151
+ * test machine is not in.
152
+ */
153
+ statfs?: (target: string) => DiskStats;
114
154
  }
155
+ /**
156
+ * Measure the root filesystem, or say honestly that we cannot.
157
+ *
158
+ * Every number has to be a whole, non-negative, safely representable count of
159
+ * bytes that fits inside the total – the API's schema holds the same line, and a
160
+ * reading it would refuse is better not sent than sent. Any doubt is `null`.
161
+ */
162
+ export declare function readRootDisk(statfs?: (target: string) => DiskStats): HostDiskFrame | null;
115
163
  /**
116
164
  * Measure this machine, or say honestly that we cannot.
117
165
  *
package/dist/host-load.js CHANGED
@@ -9,11 +9,13 @@ import path from 'node:path';
9
9
  * is on its knees», so it said nothing, and the first symptom a user saw was a
10
10
  * session that had gone quiet (plan `agent-sessions-host-resources.md` §5.3).
11
11
  *
12
- * Two files and nothing else. That is a security property, not an
13
- * implementation detail: this runner reports to a server, so what it may read
14
- * for telemetry is listed here in full — `/proc/loadavg` and `/proc/meminfo`,
15
- * both world-readable, neither containing a path, a command line, an
16
- * environment variable or a process name. No `/proc/<pid>` walk, no `ps`.
12
+ * Two files and one filesystem call, and nothing else. That is a security
13
+ * property, not an implementation detail: this runner reports to a server, so
14
+ * what it may read for telemetry is listed here in full — `/proc/loadavg` and
15
+ * `/proc/meminfo`, both world-readable, neither containing a path, a command
16
+ * line, an environment variable or a process name; and `statfs('/')` (0.71.0,
17
+ * #485), which answers four sizes of the root filesystem and nothing about what
18
+ * is on it. No `/proc/<pid>` walk, no `ps`, no directory listing, no `du`.
17
19
  *
18
20
  * **One module is allowed past that line, and it is named here so the rule and
19
21
  * the code cannot drift apart:** `session-stall.ts` reads `/proc/<pid>` for the
@@ -66,7 +68,36 @@ export const HOST_LOAD_HEARTBEAT_MS = 60_000;
66
68
  export const HOST_LOAD_MIN_DELTA_LOAD1 = 0.5;
67
69
  /** The same idea for memory, as a fraction — see `hostLoadChangedEnough`. */
68
70
  export const HOST_LOAD_MIN_DELTA_MEM_RATIO = 0.05;
71
+ /** The filesystem measured: the root, where running out stops everything. */
72
+ export const HOST_DISK_PATH = '/';
69
73
  const PROC_DIR = '/proc';
74
+ /**
75
+ * Measure the root filesystem, or say honestly that we cannot.
76
+ *
77
+ * Every number has to be a whole, non-negative, safely representable count of
78
+ * bytes that fits inside the total – the API's schema holds the same line, and a
79
+ * reading it would refuse is better not sent than sent. Any doubt is `null`.
80
+ */
81
+ export function readRootDisk(statfs = fs.statfsSync) {
82
+ try {
83
+ const stat = statfs(HOST_DISK_PATH);
84
+ const totalBytes = stat.blocks * stat.bsize;
85
+ const freeBytes = stat.bavail * stat.bsize;
86
+ const usedBytes = Math.max(0, totalBytes - stat.bfree * stat.bsize);
87
+ const whole = (value) => Number.isSafeInteger(value) && value >= 0;
88
+ if (!whole(totalBytes) || totalBytes === 0 || !whole(freeBytes) || !whole(usedBytes)) {
89
+ return null;
90
+ }
91
+ if (freeBytes > totalBytes || usedBytes > totalBytes)
92
+ return null;
93
+ return { path: HOST_DISK_PATH, totalBytes, freeBytes, usedBytes };
94
+ }
95
+ catch {
96
+ // ENOSYS in an odd container, EACCES under a locked-down profile: the disk
97
+ // is unknown, and the load beside it is still worth sending.
98
+ return null;
99
+ }
100
+ }
70
101
  /** kB in `/proc/meminfo` means kibibytes, and has since the field was added. */
71
102
  const MEMINFO_UNIT_BYTES = 1024;
72
103
  /**
@@ -158,6 +189,9 @@ export function readHostLoad(sources = {}) {
158
189
  memAvailableBytes,
159
190
  swapTotalBytes,
160
191
  swapFreeBytes,
192
+ // Its own failure is `null` inside the frame, never a missing frame: a
193
+ // machine whose filesystem refuses `statfs` still has a load worth showing.
194
+ disk: readRootDisk(sources.statfs),
161
195
  at,
162
196
  };
163
197
  }
@@ -1915,6 +1915,16 @@ export class Supervisor {
1915
1915
  existing.stopRequested = true;
1916
1916
  existing.session?.stop('session_stopped');
1917
1917
  }
1918
+ // The descriptor also knows the feed better than this life does (#472).
1919
+ // The API writes lines of its own into a feed this runner numbers: the
1920
+ // refusal service puts «Continuing the session by itself» at the next
1921
+ // free seq and resumes a moment later, while this life is still going
1922
+ // down. The words that come with the resume are echoed by THIS life, and
1923
+ // its counter knew nothing of that line – the echo reused its number and
1924
+ // the API dropped it as a duplicate. Before the `await` below: the words
1925
+ // arrive right behind this frame. A no-op whenever this runner wrote the
1926
+ // last row itself.
1927
+ existing.journal.ensureSeqAbove(descriptor.lastSeq);
1918
1928
  // The descriptor is newer than what this session was built from, so its
1919
1929
  // clock is too (QA-149 MAJOR-1). Applied on the way out of every early
1920
1930
  // return, not just the reconnect one — a descriptor re-sent for any
@@ -2936,9 +2946,18 @@ export class Supervisor {
2936
2946
  }
2937
2947
  // Keep the journal while offline: the recorded terminal status is what
2938
2948
  // reconcile replays after the next reconnect (QA-96 F1).
2949
+ //
2950
+ // …and while a resume is queued behind this life (#472). The next life
2951
+ // opens this same journal, and two things it needs live only here: the
2952
+ // words parked for it (`acceptUserMessage` holds a message that reached a
2953
+ // life already on its way out) and the seq counter. Deleted, the resume
2954
+ // started from an empty file – the platform's «continue» after a refusal
2955
+ // was gone, and «Session resumed» reused a seq the old life had already
2956
+ // sent, so the API dropped it. The session sat idle with nothing on screen.
2939
2957
  if (this.ws.connected &&
2940
2958
  running.journal.unacked().length === 0 &&
2941
- isTerminal(running.lastReported)) {
2959
+ isTerminal(running.lastReported) &&
2960
+ !running.pendingRestart) {
2942
2961
  this.journals.closeAndDelete(descriptor.id);
2943
2962
  }
2944
2963
  // The map just lost an entry, and this is the one place where that happens
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.70.0";
1
+ export declare const RUNNER_VERSION = "0.71.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.70.0';
2
+ export const RUNNER_VERSION = '0.71.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",