omnirush 0.8.6 → 0.9.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/assets/CHANGELOG.md +120 -0
- package/assets/extensions/omnirush/agents-lib.ts +501 -63
- package/assets/extensions/omnirush/agents.ts +140 -115
- package/assets/extensions/omnirush/bgshell-lib.ts +432 -0
- package/assets/extensions/omnirush/bgshell.ts +392 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
- package/assets/extensions/omnirush/collector.ts +275 -43
- package/assets/extensions/omnirush/commands.ts +2 -0
- package/assets/extensions/omnirush/deliveries.ts +145 -0
- package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
- package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
- package/assets/extensions/omnirush/guard-lib.ts +230 -0
- package/assets/extensions/omnirush/guard.ts +340 -0
- package/assets/extensions/omnirush/index.ts +32 -6
- package/assets/extensions/omnirush/mcp.ts +103 -8
- package/assets/extensions/omnirush/memory-lib.ts +516 -0
- package/assets/extensions/omnirush/pi-engine.ts +115 -3
- package/assets/extensions/omnirush/plan-lib.ts +38 -9
- package/assets/extensions/omnirush/plan.ts +85 -30
- package/assets/extensions/omnirush/sota.ts +52 -4
- package/assets/extensions/omnirush/status-lib.ts +3 -0
- package/assets/extensions/omnirush/subagent-marker.ts +21 -0
- package/assets/extensions/omnirush/subagents-lib.ts +623 -0
- package/assets/extensions/omnirush/subagents.ts +305 -0
- package/assets/extensions/omnirush/swarm-lib.ts +142 -0
- package/assets/extensions/omnirush/swarm.ts +95 -0
- package/assets/extensions/omnirush/voice/capture.ts +502 -0
- package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
- package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
- package/assets/extensions/omnirush/voice/core/index.ts +21 -0
- package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
- package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
- package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
- package/assets/extensions/omnirush/voice/core/session.ts +403 -0
- package/assets/extensions/omnirush/voice/core/text.ts +81 -0
- package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
- package/assets/extensions/omnirush/voice/core/types.ts +102 -0
- package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
- package/assets/extensions/omnirush/voice/keys.ts +435 -0
- package/assets/extensions/omnirush/voice/kitty.ts +64 -0
- package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
- package/assets/extensions/omnirush/voice/settings.ts +67 -0
- package/assets/extensions/omnirush/voice.ts +838 -0
- package/assets/extensions/omnirush/yolo-lib.ts +80 -0
- package/assets/extensions/omnirush/yolo.ts +85 -0
- package/package.json +7 -3
- package/scripts/brand-engine.js +526 -0
- package/scripts/build-all-packages.py +29 -1
- package/scripts/smoke-packages.py +33 -1
- package/src/bin.js +232 -33
- package/src/compat.js +272 -0
- package/src/lib.js +64 -0
- package/src/sessions.js +222 -0
- package/scripts/patch-pi-branding.js +0 -251
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// memory-lib — memory-aware admission of sub-agents (spawn_agents).
|
|
2
|
+
//
|
|
3
|
+
// Every sub-agent is a separate agent process (its own runtime and engine,
|
|
4
|
+
// ~150-250 MB each), and spawn_agents has no count cap: 20 or 40 children at
|
|
5
|
+
// once used to start together whatever the machine had left, and machines
|
|
6
|
+
// ran out of memory. Admission keeps the "no fixed cap" rule but starts a
|
|
7
|
+
// child only while the memory allows it:
|
|
8
|
+
//
|
|
9
|
+
// - the system keeps at least MIN_FREE of its RAM available after the
|
|
10
|
+
// child's expected footprint (default 20% of RAM; inside a cgroup with a
|
|
11
|
+
// memory limit — a container, a systemd scope — the limit counts as RAM
|
|
12
|
+
// and what it has left as available);
|
|
13
|
+
// - this agent's whole process tree (the top agent, every sub-agent at
|
|
14
|
+
// every nesting layer, their tools) stays under BUDGET (default 50% of
|
|
15
|
+
// RAM);
|
|
16
|
+
// - critically low (available under CRITICAL_FREE, default 10% of RAM):
|
|
17
|
+
// nothing new starts, not even a first child, and the user is warned;
|
|
18
|
+
// running children go on. Otherwise one child may always run, so a
|
|
19
|
+
// batch makes progress on a small machine.
|
|
20
|
+
//
|
|
21
|
+
// The rest wait in the queue ("queued: waiting for memory") and start as
|
|
22
|
+
// memory frees up (a child finishing, or the next check, every second).
|
|
23
|
+
// Thresholds: OMNIRUSH_AGENT_MIN_FREE, OMNIRUSH_AGENT_MEMORY_BUDGET,
|
|
24
|
+
// OMNIRUSH_AGENT_CRITICAL_FREE and OMNIRUSH_AGENT_CHILD_ESTIMATE ("20%",
|
|
25
|
+
// "1.5G", "800M", a plain number is MiB), OMNIRUSH_AGENT_MEMORY=off turns
|
|
26
|
+
// admission off; the same keys (minFree, budget, criticalFree,
|
|
27
|
+
// childEstimate, enabled) under "subagentMemory" in <omnirush
|
|
28
|
+
// dir>/settings.json. The environment wins.
|
|
29
|
+
//
|
|
30
|
+
// Measuring: Linux reads /proc/meminfo (MemAvailable), the cgroup's
|
|
31
|
+
// memory.max / memory.current / memory.stat, and /proc/<pid>/statm of the
|
|
32
|
+
// process tree; macOS asks vm_stat and ps; elsewhere os.freemem() and an
|
|
33
|
+
// estimate per running child stand in for the tree.
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from "node:child_process";
|
|
36
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
37
|
+
import os from "node:os";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
|
|
40
|
+
const MIB = 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
/** Environment variable the top agent hands down: the pid whose process tree the budget covers. */
|
|
43
|
+
export const ENV_MEMORY_ROOT = "OMNIRUSH_AGENT_MEMORY_ROOT";
|
|
44
|
+
|
|
45
|
+
export interface MemoryPolicy {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
/** Keep at least this much of RAM available (bytes, resolved). */
|
|
48
|
+
minFreeBytes: number;
|
|
49
|
+
/** The whole process tree stays under this (bytes, resolved). */
|
|
50
|
+
budgetBytes: number;
|
|
51
|
+
/** Below this nothing new starts and the user is warned (bytes, resolved). */
|
|
52
|
+
criticalFreeBytes: number;
|
|
53
|
+
/** Footprint expected of a child before it is measured (bytes). */
|
|
54
|
+
childEstimateBytes: number;
|
|
55
|
+
/** Where each value came from ("env", "settings", "default"), for /agents memory. */
|
|
56
|
+
sources: Record<"minFree" | "budget" | "criticalFree" | "childEstimate" | "enabled", "env" | "settings" | "default">;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const DEFAULT_MIN_FREE = "20%";
|
|
60
|
+
export const DEFAULT_BUDGET = "50%";
|
|
61
|
+
export const DEFAULT_CRITICAL_FREE = "10%";
|
|
62
|
+
export const DEFAULT_CHILD_ESTIMATE = "256M";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* "20%" of `total`, "1.5G", "800M", "512K", or a plain number of MiB; null
|
|
66
|
+
* when unreadable. Percentages are clamped to 0-100.
|
|
67
|
+
*/
|
|
68
|
+
export function parseAmount(raw: unknown, total: number): number | null {
|
|
69
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.round(raw * MIB);
|
|
70
|
+
if (typeof raw !== "string") return null;
|
|
71
|
+
const text = raw.trim().toLowerCase();
|
|
72
|
+
if (!text) return null;
|
|
73
|
+
const match = /^(\d+(?:\.\d+)?)\s*(%|b|k|kb|kib|m|mb|mib|g|gb|gib|t|tb|tib)?$/.exec(text);
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
const value = Number(match[1]);
|
|
76
|
+
const unit = match[2] ?? "m";
|
|
77
|
+
if (unit === "%") return Math.round((Math.min(100, value) / 100) * total);
|
|
78
|
+
const scale = unit === "b" ? 1 : unit.startsWith("k") ? 1024 : unit.startsWith("m") ? MIB : unit.startsWith("g") ? 1024 * MIB : 1024 * 1024 * MIB;
|
|
79
|
+
return Math.round(value * scale);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The "subagentMemory" block of <dir>/settings.json (empty when absent). */
|
|
83
|
+
export function readMemorySettings(dir: string): Record<string, unknown> {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(path.join(dir, "settings.json"), "utf8"));
|
|
86
|
+
const block = parsed?.subagentMemory;
|
|
87
|
+
return block && typeof block === "object" && !Array.isArray(block) ? block : {};
|
|
88
|
+
} catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolve the thresholds for a machine with `totalBytes` of RAM (env over settings over defaults). */
|
|
94
|
+
export function resolveMemoryPolicy(totalBytes: number, env: NodeJS.ProcessEnv = process.env, settings: Record<string, unknown> = {}): MemoryPolicy {
|
|
95
|
+
const sources = {} as MemoryPolicy["sources"];
|
|
96
|
+
const pick = (key: keyof MemoryPolicy["sources"], envName: string, fallback: string): number => {
|
|
97
|
+
const fromEnv = parseAmount(env[envName], totalBytes);
|
|
98
|
+
if (fromEnv !== null) {
|
|
99
|
+
sources[key] = "env";
|
|
100
|
+
return fromEnv;
|
|
101
|
+
}
|
|
102
|
+
const fromSettings = parseAmount(settings[key], totalBytes);
|
|
103
|
+
if (fromSettings !== null) {
|
|
104
|
+
sources[key] = "settings";
|
|
105
|
+
return fromSettings;
|
|
106
|
+
}
|
|
107
|
+
sources[key] = "default";
|
|
108
|
+
return parseAmount(fallback, totalBytes) ?? 0;
|
|
109
|
+
};
|
|
110
|
+
const rawEnabled = String(env.OMNIRUSH_AGENT_MEMORY ?? "").trim().toLowerCase();
|
|
111
|
+
let enabled = true;
|
|
112
|
+
if (rawEnabled) {
|
|
113
|
+
sources.enabled = "env";
|
|
114
|
+
enabled = !["off", "0", "false", "no", "disabled"].includes(rawEnabled);
|
|
115
|
+
} else if (typeof settings.enabled === "boolean") {
|
|
116
|
+
sources.enabled = "settings";
|
|
117
|
+
enabled = settings.enabled;
|
|
118
|
+
} else {
|
|
119
|
+
sources.enabled = "default";
|
|
120
|
+
}
|
|
121
|
+
const minFreeBytes = pick("minFree", "OMNIRUSH_AGENT_MIN_FREE", DEFAULT_MIN_FREE);
|
|
122
|
+
const budgetBytes = pick("budget", "OMNIRUSH_AGENT_MEMORY_BUDGET", DEFAULT_BUDGET);
|
|
123
|
+
const criticalFreeBytes = Math.min(pick("criticalFree", "OMNIRUSH_AGENT_CRITICAL_FREE", DEFAULT_CRITICAL_FREE), minFreeBytes);
|
|
124
|
+
const childEstimateBytes = Math.max(32 * MIB, pick("childEstimate", "OMNIRUSH_AGENT_CHILD_ESTIMATE", DEFAULT_CHILD_ESTIMATE));
|
|
125
|
+
return { enabled, minFreeBytes, budgetBytes, criticalFreeBytes, childEstimateBytes, sources };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- measuring ---------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export interface SystemMemory {
|
|
131
|
+
/** RAM, or the cgroup's memory limit when that is smaller. */
|
|
132
|
+
totalBytes: number;
|
|
133
|
+
/** What can still be used before the system (or the cgroup) runs out. */
|
|
134
|
+
availableBytes: number;
|
|
135
|
+
source: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readText(file: string): string | null {
|
|
139
|
+
try {
|
|
140
|
+
return readFileSync(file, "utf8");
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** MemTotal / MemAvailable from /proc/meminfo (bytes), or null. */
|
|
147
|
+
export function parseMeminfo(text: string | null): { total: number; available: number } | null {
|
|
148
|
+
if (!text) return null;
|
|
149
|
+
const field = (name: string) => {
|
|
150
|
+
const match = new RegExp(`^${name}:\\s+(\\d+)\\s*kB`, "m").exec(text);
|
|
151
|
+
return match ? Number(match[1]) * 1024 : null;
|
|
152
|
+
};
|
|
153
|
+
const total = field("MemTotal");
|
|
154
|
+
let available = field("MemAvailable");
|
|
155
|
+
if (available === null) {
|
|
156
|
+
const free = field("MemFree");
|
|
157
|
+
const cached = field("Cached");
|
|
158
|
+
const buffers = field("Buffers");
|
|
159
|
+
available = free === null ? null : free + (cached ?? 0) + (buffers ?? 0);
|
|
160
|
+
}
|
|
161
|
+
return total !== null && available !== null ? { total, available } : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The tightest cgroup v2 memory limit on the way from this process's cgroup
|
|
166
|
+
* to the root: {limit, used}, `used` without the page cache the kernel can
|
|
167
|
+
* drop (file pages minus shmem). Null outside a limited cgroup.
|
|
168
|
+
*/
|
|
169
|
+
export function readCgroupLimit(root = "/sys/fs/cgroup", selfCgroup = readText("/proc/self/cgroup")): { limit: number; used: number; available: number } | null {
|
|
170
|
+
if (!selfCgroup) return null;
|
|
171
|
+
const line = selfCgroup.split("\n").find((candidate) => candidate.startsWith("0::"));
|
|
172
|
+
if (!line) return null;
|
|
173
|
+
let dir = line.slice(3).trim() || "/";
|
|
174
|
+
let best: { limit: number; used: number; available: number } | null = null;
|
|
175
|
+
for (let guard = 0; guard < 32; guard++) {
|
|
176
|
+
const base = path.join(root, dir);
|
|
177
|
+
const max = readText(path.join(base, "memory.max"))?.trim();
|
|
178
|
+
if (max && max !== "max" && /^\d+$/.test(max)) {
|
|
179
|
+
const limit = Number(max);
|
|
180
|
+
const current = Number(readText(path.join(base, "memory.current"))?.trim() ?? NaN);
|
|
181
|
+
const stat = readText(path.join(base, "memory.stat")) ?? "";
|
|
182
|
+
const statField = (name: string) => Number(new RegExp(`^${name} (\\d+)`, "m").exec(stat)?.[1] ?? 0);
|
|
183
|
+
const reclaimable = Math.max(0, statField("file") - statField("shmem"));
|
|
184
|
+
const used = Number.isFinite(current) ? Math.max(0, current - reclaimable) : 0;
|
|
185
|
+
const available = Math.max(0, limit - used);
|
|
186
|
+
if (!best || available < best.available) best = { limit, used, available };
|
|
187
|
+
}
|
|
188
|
+
if (dir === "/" || dir === "") break;
|
|
189
|
+
dir = path.dirname(dir);
|
|
190
|
+
}
|
|
191
|
+
return best;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** macOS: available ≈ free + inactive + speculative + purgeable pages (vm_stat). */
|
|
195
|
+
export function parseVmStat(text: string): number | null {
|
|
196
|
+
const pageSize = Number(/page size of (\d+) bytes/.exec(text)?.[1] ?? 4096);
|
|
197
|
+
const pages = (name: string) => Number(new RegExp(`^Pages ${name}:\\s+(\\d+)`, "m").exec(text)?.[1] ?? NaN);
|
|
198
|
+
const free = pages("free");
|
|
199
|
+
if (!Number.isFinite(free)) return null;
|
|
200
|
+
const extra = ["inactive", "speculative", "purgeable"].map(pages).filter(Number.isFinite).reduce((a, b) => a + b, 0);
|
|
201
|
+
return (free + extra) * pageSize;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** RAM and what is available of it right now (the cgroup's limit when tighter). */
|
|
205
|
+
export function readSystemMemory(): SystemMemory {
|
|
206
|
+
let total = os.totalmem();
|
|
207
|
+
let available = os.freemem();
|
|
208
|
+
let source = "os";
|
|
209
|
+
if (process.platform === "linux") {
|
|
210
|
+
const info = parseMeminfo(readText("/proc/meminfo"));
|
|
211
|
+
if (info) {
|
|
212
|
+
total = info.total;
|
|
213
|
+
available = info.available;
|
|
214
|
+
source = "meminfo";
|
|
215
|
+
}
|
|
216
|
+
const cgroup = readCgroupLimit();
|
|
217
|
+
if (cgroup && cgroup.limit < total) {
|
|
218
|
+
total = cgroup.limit;
|
|
219
|
+
source = "cgroup";
|
|
220
|
+
}
|
|
221
|
+
if (cgroup && cgroup.available < available) {
|
|
222
|
+
available = cgroup.available;
|
|
223
|
+
source = "cgroup";
|
|
224
|
+
}
|
|
225
|
+
} else if (process.platform === "darwin") {
|
|
226
|
+
try {
|
|
227
|
+
const parsed = parseVmStat(String(execFileSync("vm_stat", { timeout: 2_000, maxBuffer: 64 * 1024 })));
|
|
228
|
+
if (parsed !== null) {
|
|
229
|
+
available = Math.min(total, parsed);
|
|
230
|
+
source = "vm_stat";
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
/* os.freemem() stands */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { totalBytes: total, availableBytes: Math.max(0, available), source };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface ProcessSample {
|
|
240
|
+
pid: number;
|
|
241
|
+
ppid: number;
|
|
242
|
+
/** Private resident memory (bytes): resident minus file-backed shared pages. */
|
|
243
|
+
privateBytes: number;
|
|
244
|
+
/** File-backed shared resident pages (the runtime binary, mapped files). */
|
|
245
|
+
sharedBytes: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Every process's parent and memory (Linux /proc, macOS ps); null where neither works. */
|
|
249
|
+
export function listProcesses(): ProcessSample[] | null {
|
|
250
|
+
if (process.platform === "linux") {
|
|
251
|
+
const page = 4096;
|
|
252
|
+
const out: ProcessSample[] = [];
|
|
253
|
+
let names: string[];
|
|
254
|
+
try {
|
|
255
|
+
names = readdirSync("/proc");
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
for (const name of names) {
|
|
260
|
+
if (!/^\d+$/.test(name)) continue;
|
|
261
|
+
const stat = readText(`/proc/${name}/stat`);
|
|
262
|
+
const statm = readText(`/proc/${name}/statm`);
|
|
263
|
+
if (!stat || !statm) continue;
|
|
264
|
+
// Fields after the ")" of the command name: state ppid ...
|
|
265
|
+
const after = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
266
|
+
const ppid = Number(after[1]);
|
|
267
|
+
const [, resident, shared] = statm.trim().split(/\s+/).map(Number);
|
|
268
|
+
if (!Number.isFinite(ppid) || !Number.isFinite(resident)) continue;
|
|
269
|
+
out.push({ pid: Number(name), ppid, privateBytes: Math.max(0, resident - (shared || 0)) * page, sharedBytes: (shared || 0) * page });
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
if (process.platform === "darwin") {
|
|
274
|
+
try {
|
|
275
|
+
const text = String(execFileSync("ps", ["-A", "-o", "pid=,ppid=,rss="], { timeout: 3_000, maxBuffer: 8 * MIB }));
|
|
276
|
+
return text
|
|
277
|
+
.split("\n")
|
|
278
|
+
.map((line) => line.trim().split(/\s+/).map(Number))
|
|
279
|
+
.filter((parts) => parts.length === 3 && parts.every(Number.isFinite))
|
|
280
|
+
.map(([pid, ppid, rss]) => ({ pid, ppid, privateBytes: rss * 1024, sharedBytes: 0 }));
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Memory of `rootPid` and all its descendants: private memory summed, the
|
|
290
|
+
* shared file pages counted once (the largest; every agent process maps the
|
|
291
|
+
* same runtime binary). `byPid` has each member's private + shared bytes.
|
|
292
|
+
*/
|
|
293
|
+
export function treeMemory(processes: readonly ProcessSample[], rootPid: number): { bytes: number; count: number; byPid: Map<number, number> } {
|
|
294
|
+
const children = new Map<number, ProcessSample[]>();
|
|
295
|
+
const self = processes.find((sample) => sample.pid === rootPid);
|
|
296
|
+
for (const sample of processes) {
|
|
297
|
+
const list = children.get(sample.ppid);
|
|
298
|
+
if (list) list.push(sample);
|
|
299
|
+
else children.set(sample.ppid, [sample]);
|
|
300
|
+
}
|
|
301
|
+
const byPid = new Map<number, number>();
|
|
302
|
+
let privateTotal = 0;
|
|
303
|
+
let sharedMax = 0;
|
|
304
|
+
const stack: ProcessSample[] = self ? [self] : (children.get(rootPid) ?? []).slice();
|
|
305
|
+
const seen = new Set<number>();
|
|
306
|
+
while (stack.length > 0) {
|
|
307
|
+
const sample = stack.pop()!;
|
|
308
|
+
if (seen.has(sample.pid)) continue;
|
|
309
|
+
seen.add(sample.pid);
|
|
310
|
+
privateTotal += sample.privateBytes;
|
|
311
|
+
sharedMax = Math.max(sharedMax, sample.sharedBytes);
|
|
312
|
+
byPid.set(sample.pid, sample.privateBytes + sample.sharedBytes);
|
|
313
|
+
for (const child of children.get(sample.pid) ?? []) stack.push(child);
|
|
314
|
+
}
|
|
315
|
+
return { bytes: privateTotal + sharedMax, count: seen.size, byPid };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- the admission decision ---------------------------------------------------
|
|
319
|
+
|
|
320
|
+
export interface AdmissionInput {
|
|
321
|
+
policy: MemoryPolicy;
|
|
322
|
+
system: SystemMemory;
|
|
323
|
+
/** The process tree's memory now (null when it cannot be measured). */
|
|
324
|
+
treeBytes: number | null;
|
|
325
|
+
/** Children of this process running now. */
|
|
326
|
+
running: number;
|
|
327
|
+
/**
|
|
328
|
+
* Memory the running children have not taken yet but will (young children
|
|
329
|
+
* still starting up): counted as used already.
|
|
330
|
+
*/
|
|
331
|
+
reservedBytes: number;
|
|
332
|
+
/** What the next child is expected to take. */
|
|
333
|
+
estimateBytes: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export type AdmissionVerdict =
|
|
337
|
+
| { ok: true }
|
|
338
|
+
| { ok: false; reason: "critical" | "free" | "budget"; detail: string };
|
|
339
|
+
|
|
340
|
+
function mb(bytes: number): string {
|
|
341
|
+
return bytes >= 10 * 1024 * MIB ? `${(bytes / (1024 * MIB)).toFixed(1)} GB` : `${Math.round(bytes / MIB)} MB`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Whether one more child may start now. */
|
|
345
|
+
export function admit(input: AdmissionInput): AdmissionVerdict {
|
|
346
|
+
const { policy, system } = input;
|
|
347
|
+
if (!policy.enabled) return { ok: true };
|
|
348
|
+
const available = Math.max(0, system.availableBytes - input.reservedBytes);
|
|
349
|
+
if (available < policy.criticalFreeBytes) {
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
reason: "critical",
|
|
353
|
+
detail: `memory critically low: ${mb(available)} free of ${mb(system.totalBytes)} (the floor is ${mb(policy.criticalFreeBytes)})`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const after = available - input.estimateBytes;
|
|
357
|
+
if (input.running === 0) {
|
|
358
|
+
// Progress: one child may run whenever the machine is not critically low.
|
|
359
|
+
if (after >= policy.criticalFreeBytes) return { ok: true };
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
reason: "free",
|
|
363
|
+
detail: `${mb(available)} free, a sub-agent needs about ${mb(input.estimateBytes)} and ${mb(policy.criticalFreeBytes)} must stay free`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (after < policy.minFreeBytes) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
reason: "free",
|
|
370
|
+
detail: `${mb(available)} free of ${mb(system.totalBytes)}; ${mb(policy.minFreeBytes)} stays free, a sub-agent needs about ${mb(input.estimateBytes)}`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (input.treeBytes !== null && input.treeBytes + input.reservedBytes + input.estimateBytes > policy.budgetBytes) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
reason: "budget",
|
|
377
|
+
detail: `agents use ${mb(input.treeBytes + input.reservedBytes)} of their ${mb(policy.budgetBytes)} budget; a sub-agent needs about ${mb(input.estimateBytes)}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return { ok: true };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** A child this process started: its pid and when, for the reservation. */
|
|
384
|
+
interface Tracked {
|
|
385
|
+
pid: number | null;
|
|
386
|
+
startedAt: number;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** How long a new child counts at least its estimate (it is still loading). */
|
|
390
|
+
export const CHILD_WARMUP_MS = 15_000;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The admission state of one agent process: measures (at most every
|
|
394
|
+
* `sampleMs`), keeps track of its own running children, and answers
|
|
395
|
+
* `check()`. Injectable readers keep it testable.
|
|
396
|
+
*/
|
|
397
|
+
export class MemoryAdmission {
|
|
398
|
+
readonly policy: MemoryPolicy;
|
|
399
|
+
private readonly rootPid: number;
|
|
400
|
+
private readonly now: () => number;
|
|
401
|
+
private readonly readSystem: () => SystemMemory;
|
|
402
|
+
private readonly readProcesses: () => ProcessSample[] | null;
|
|
403
|
+
private readonly sampleMs: number;
|
|
404
|
+
private readonly tracked = new Map<object, Tracked>();
|
|
405
|
+
private sample: { at: number; system: SystemMemory; tree: ReturnType<typeof treeMemory> | null } | null = null;
|
|
406
|
+
/** Largest footprint measured of a child of this process (bytes). */
|
|
407
|
+
private observedChildBytes = 0;
|
|
408
|
+
|
|
409
|
+
constructor(options: {
|
|
410
|
+
policy?: MemoryPolicy;
|
|
411
|
+
env?: NodeJS.ProcessEnv;
|
|
412
|
+
settings?: Record<string, unknown>;
|
|
413
|
+
rootPid?: number;
|
|
414
|
+
now?: () => number;
|
|
415
|
+
readSystem?: () => SystemMemory;
|
|
416
|
+
readProcesses?: () => ProcessSample[] | null;
|
|
417
|
+
sampleMs?: number;
|
|
418
|
+
} = {}) {
|
|
419
|
+
const env = options.env ?? process.env;
|
|
420
|
+
this.now = options.now ?? Date.now;
|
|
421
|
+
this.readSystem = options.readSystem ?? readSystemMemory;
|
|
422
|
+
this.readProcesses = options.readProcesses ?? listProcesses;
|
|
423
|
+
this.sampleMs = options.sampleMs ?? 1_000;
|
|
424
|
+
const rootFromEnv = Number(env[ENV_MEMORY_ROOT]);
|
|
425
|
+
this.rootPid = options.rootPid ?? (Number.isInteger(rootFromEnv) && rootFromEnv > 0 ? rootFromEnv : process.pid);
|
|
426
|
+
this.policy = options.policy ?? resolveMemoryPolicy(this.readSystem().totalBytes, env, options.settings ?? {});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The environment a child gets: the tree root it counts against. */
|
|
430
|
+
childEnv(): Record<string, string> {
|
|
431
|
+
return { [ENV_MEMORY_ROOT]: String(this.rootPid) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private measure(force = false) {
|
|
435
|
+
const at = this.now();
|
|
436
|
+
if (!force && this.sample && at - this.sample.at < this.sampleMs) return this.sample;
|
|
437
|
+
const system = this.readSystem();
|
|
438
|
+
const processes = this.readProcesses();
|
|
439
|
+
const tree = processes ? treeMemory(processes, this.rootPid) : null;
|
|
440
|
+
if (tree) {
|
|
441
|
+
for (const entry of this.tracked.values()) {
|
|
442
|
+
const bytes = entry.pid !== null ? tree.byPid.get(entry.pid) : undefined;
|
|
443
|
+
if (bytes !== undefined && at - entry.startedAt >= CHILD_WARMUP_MS) this.observedChildBytes = Math.max(this.observedChildBytes, bytes);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
this.sample = { at, system, tree };
|
|
447
|
+
return this.sample;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** What the next child is expected to take: the estimate, or more once children were measured bigger. */
|
|
451
|
+
estimate(): number {
|
|
452
|
+
return Math.max(this.policy.childEstimateBytes, Math.min(this.observedChildBytes, 4 * this.policy.childEstimateBytes));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Memory young children will still take (estimate minus what they use now). */
|
|
456
|
+
private reserved(tree: ReturnType<typeof treeMemory> | null): number {
|
|
457
|
+
const at = this.now();
|
|
458
|
+
let total = 0;
|
|
459
|
+
for (const entry of this.tracked.values()) {
|
|
460
|
+
if (at - entry.startedAt >= CHILD_WARMUP_MS) continue;
|
|
461
|
+
const used = entry.pid !== null ? tree?.byPid.get(entry.pid) ?? 0 : 0;
|
|
462
|
+
total += Math.max(0, this.estimate() - used);
|
|
463
|
+
}
|
|
464
|
+
return total;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** May one more child start now? */
|
|
468
|
+
check(): AdmissionVerdict & { system: SystemMemory; treeBytes: number | null } {
|
|
469
|
+
const sample = this.measure();
|
|
470
|
+
const treeBytes = sample.tree ? sample.tree.bytes : null;
|
|
471
|
+
// Without a process list (Windows), the children's estimates stand in for the tree.
|
|
472
|
+
const tree = treeBytes ?? (this.tracked.size > 0 ? this.tracked.size * this.estimate() : null);
|
|
473
|
+
const verdict = admit({
|
|
474
|
+
policy: this.policy,
|
|
475
|
+
system: sample.system,
|
|
476
|
+
treeBytes: tree,
|
|
477
|
+
running: this.tracked.size,
|
|
478
|
+
reservedBytes: this.reserved(sample.tree),
|
|
479
|
+
estimateBytes: this.estimate(),
|
|
480
|
+
});
|
|
481
|
+
return { ...verdict, system: sample.system, treeBytes: tree };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** A child started (its pid once known): it counts from now on. */
|
|
485
|
+
started(key: object, pid: number | null = null): void {
|
|
486
|
+
// No new measurement for it: until the next sample (and while it warms
|
|
487
|
+
// up) the reservation counts its estimate.
|
|
488
|
+
this.tracked.set(key, { pid, startedAt: this.now() });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The child's pid arrived after it was started. */
|
|
492
|
+
spawned(key: object, pid: number | null | undefined): void {
|
|
493
|
+
const entry = this.tracked.get(key);
|
|
494
|
+
if (entry && typeof pid === "number") entry.pid = pid;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** A child ended. */
|
|
498
|
+
finished(key: object): void {
|
|
499
|
+
this.tracked.delete(key);
|
|
500
|
+
if (this.sample) this.sample = { ...this.sample, at: -Infinity };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Children of this process that count now. */
|
|
504
|
+
get running(): number {
|
|
505
|
+
return this.tracked.size;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** One line for /agents and status: RAM, available, the tree, the thresholds. */
|
|
509
|
+
describe(): string {
|
|
510
|
+
const sample = this.measure(true);
|
|
511
|
+
const tree = sample.tree ? mb(sample.tree.bytes) : "unknown";
|
|
512
|
+
const p = this.policy;
|
|
513
|
+
if (!p.enabled) return `memory admission off (${mb(sample.system.availableBytes)} free of ${mb(sample.system.totalBytes)})`;
|
|
514
|
+
return `memory: ${mb(sample.system.availableBytes)} free of ${mb(sample.system.totalBytes)} (${sample.system.source}); agents ${tree} of ${mb(p.budgetBytes)} budget; keeps ${mb(p.minFreeBytes)} free, stops at ${mb(p.criticalFreeBytes)}; ~${mb(this.estimate())} per sub-agent`;
|
|
515
|
+
}
|
|
516
|
+
}
|