omnirush 0.9.0 → 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 +35 -0
- package/assets/extensions/omnirush/agents-lib.ts +370 -53
- package/assets/extensions/omnirush/agents.ts +92 -11
- package/assets/extensions/omnirush/bgshell-lib.ts +32 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
- package/assets/extensions/omnirush/collector.ts +203 -32
- package/assets/extensions/omnirush/index.ts +22 -8
- package/assets/extensions/omnirush/mcp.ts +103 -8
- package/assets/extensions/omnirush/memory-lib.ts +516 -0
- package/assets/extensions/omnirush/pi-engine.ts +50 -2
- package/assets/extensions/omnirush/plan-lib.ts +38 -9
- package/assets/extensions/omnirush/plan.ts +85 -30
- package/assets/extensions/omnirush/subagent-marker.ts +21 -0
- package/package.json +1 -1
- package/scripts/smoke-packages.py +1 -0
- package/src/bin.js +28 -1
- package/src/sessions.js +222 -0
|
@@ -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
|
+
}
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
//
|
|
18
18
|
// user message -> user message: text parts, image file parts
|
|
19
19
|
// (bytes not embedded, as the desktop's
|
|
20
|
-
// engine.request records attachments)
|
|
20
|
+
// engine.request records attachments); one
|
|
21
|
+
// the user did not submit (a sub-agent's task,
|
|
22
|
+
// an extension's sendUserMessage: the caller's
|
|
23
|
+
// `injectedPrompts`) has synthetic text parts,
|
|
24
|
+
// so it never becomes a turn's prompt
|
|
21
25
|
// assistant message -> assistant message: step-start, reasoning
|
|
22
26
|
// (the model's thinking), text and tool parts
|
|
23
27
|
// in the model's order, a patch part for the
|
|
@@ -55,6 +59,14 @@ import { isAbsolute, resolve } from "node:path";
|
|
|
55
59
|
|
|
56
60
|
/** customType of bgshell.ts's finished-background-command message. */
|
|
57
61
|
export const BACKGROUND_BASH_TYPE = "omnirush-bash-result";
|
|
62
|
+
/**
|
|
63
|
+
* customType of the entry a sub-agent process writes at the start of its
|
|
64
|
+
* own session (data: `{parent_session_id}`): the session is a sub-agent's,
|
|
65
|
+
* and its first user message is the task the parent agent wrote.
|
|
66
|
+
*/
|
|
67
|
+
export const SUBAGENT_ENTRY_TYPE = "omnirush-subagent";
|
|
68
|
+
/** A sub-agent's prompt: the task the parent agent wrote, after this prefix (agents-lib.ts buildChildArgs). */
|
|
69
|
+
export const SUBAGENT_TASK_PREFIX = "Task: ";
|
|
58
70
|
|
|
59
71
|
export type EngineMessage = { info: Record<string, unknown>; parts: Array<Record<string, unknown>> };
|
|
60
72
|
|
|
@@ -87,6 +99,13 @@ export type EngineConvertOptions = {
|
|
|
87
99
|
* `--model <id>:<effort>` flag of a resumed session.
|
|
88
100
|
*/
|
|
89
101
|
promptSettings?: ReadonlyMap<string, { model?: { providerID: string; modelID: string } | null; variant?: string | null }>;
|
|
102
|
+
/**
|
|
103
|
+
* User entries (by id) whose text the user did not submit, with the
|
|
104
|
+
* metadata their synthetic text parts carry: the task a parent agent gave
|
|
105
|
+
* a sub-agent whose session is continued as a main session, a prompt an
|
|
106
|
+
* extension sent. The viewer never takes them for a prompt.
|
|
107
|
+
*/
|
|
108
|
+
injectedPrompts?: ReadonlyMap<string, Record<string, unknown>>;
|
|
90
109
|
};
|
|
91
110
|
|
|
92
111
|
/** The shell-command marker the engine writes on a user message holding a command the user ran (public_trace.py `_USER_SHELL_TEXT`). */
|
|
@@ -412,7 +431,8 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
|
|
|
412
431
|
const created = epochMs(message.timestamp) ?? at;
|
|
413
432
|
switch (message.role) {
|
|
414
433
|
case "user": {
|
|
415
|
-
const
|
|
434
|
+
const injected = options.injectedPrompts?.get(entryId);
|
|
435
|
+
const parts = contentParts(message.content, injected ? { synthetic: true, metadata: { ...injected } } : {});
|
|
416
436
|
if (parts.length > 0) turnVariant = userMessage(id, created, parts, index);
|
|
417
437
|
return;
|
|
418
438
|
}
|
|
@@ -573,6 +593,34 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
|
|
|
573
593
|
return out;
|
|
574
594
|
}
|
|
575
595
|
|
|
596
|
+
/** The text of a pi user message (its text blocks). */
|
|
597
|
+
export function userText(message: Record<string, any> | undefined): string {
|
|
598
|
+
return message ? contentText(message.content) : "";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The first user message of a branch when it may be a sub-agent's task
|
|
603
|
+
* (the session is a sub-agent's, opened as a main session by `--continue`,
|
|
604
|
+
* `--resume` or `--session`): `marked` when the sub-agent's own marker
|
|
605
|
+
* entry comes before it (then `parentSessionId` is known), else when its
|
|
606
|
+
* text has the task prefix, for the caller to confirm (sessions of
|
|
607
|
+
* sub-agents that wrote no marker). Null for any other session.
|
|
608
|
+
*/
|
|
609
|
+
export function subagentTask(entries: readonly PiEntry[]): { entryId: string; marked: boolean; parentSessionId: string | null } | null {
|
|
610
|
+
let marker: PiEntry | null = null;
|
|
611
|
+
for (const entry of entries) {
|
|
612
|
+
if (entry.type === "custom" && entry.customType === SUBAGENT_ENTRY_TYPE) marker ??= entry;
|
|
613
|
+
if (entry.type !== "message" || !isRecord(entry.message) || entry.message.role !== "user") continue;
|
|
614
|
+
if (typeof entry.id !== "string" || !entry.id) return null;
|
|
615
|
+
if (marker) {
|
|
616
|
+
const parent = isRecord(marker.data) && typeof marker.data.parent_session_id === "string" ? marker.data.parent_session_id : null;
|
|
617
|
+
return { entryId: entry.id, marked: true, parentSessionId: parent };
|
|
618
|
+
}
|
|
619
|
+
return userText(entry.message).startsWith(SUBAGENT_TASK_PREFIX) ? { entryId: entry.id, marked: false, parentSessionId: null } : null;
|
|
620
|
+
}
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
|
|
576
624
|
/** The active branch of a pi session file's entries: from the last entry up its parent chain. */
|
|
577
625
|
export function branchOfEntries(entries: readonly PiEntry[]): PiEntry[] {
|
|
578
626
|
const withIds = entries.filter((entry) => entry.type !== "session" && typeof entry.id === "string");
|
|
@@ -16,6 +16,9 @@ export interface PlanFile {
|
|
|
16
16
|
schema_version: 1;
|
|
17
17
|
items: PlanItem[];
|
|
18
18
|
updated_at: string;
|
|
19
|
+
/** The session that wrote it (a new session in the same folder does
|
|
20
|
+
* not pick up another session's list). Absent in older files. */
|
|
21
|
+
session_id?: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export const PLAN_MAX_ITEMS = 50;
|
|
@@ -66,19 +69,44 @@ function clip(text: string, max: number): string {
|
|
|
66
69
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/** Non-plan tool calls after which an untouched list reads as stale. */
|
|
73
|
+
export const PLAN_STALE_TOOL_CALLS = 25;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How current the list is, for the footer:
|
|
77
|
+
* - "active": updated during the running request,
|
|
78
|
+
* - "idle": no request running, or the list is left over from an
|
|
79
|
+
* earlier request (restored on resume / not touched since).
|
|
80
|
+
*/
|
|
81
|
+
export type PlanFooterPhase = "active" | "idle";
|
|
82
|
+
|
|
83
|
+
export interface PlanFooterOptions {
|
|
84
|
+
phase?: PlanFooterPhase;
|
|
85
|
+
/** Non-plan tool calls since the last plan update. */
|
|
86
|
+
toolCallsSinceUpdate?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
/**
|
|
70
|
-
* One-line footer for ctx.ui.setStatus
|
|
71
|
-
*
|
|
72
|
-
*
|
|
90
|
+
* One-line footer for ctx.ui.setStatus. It reads as a checklist (never
|
|
91
|
+
* as a mode — the footer's status line also carries "guarded"/"yolo"
|
|
92
|
+
* right before it), e.g.
|
|
93
|
+
* "tasks 2/5 · ▸ wire tests" (being worked on)
|
|
94
|
+
* "tasks 2/5 · not updated for 30 tool calls" (stale while working)
|
|
95
|
+
* "tasks 2/5 · idle" (between requests)
|
|
96
|
+
* Null when there is nothing to show — including a finished list.
|
|
73
97
|
*/
|
|
74
|
-
export function renderPlanFooter(items: PlanItem[]): string | null {
|
|
98
|
+
export function renderPlanFooter(items: PlanItem[] | null, options: PlanFooterOptions = {}): string | null {
|
|
75
99
|
if (!Array.isArray(items) || items.length === 0) return null;
|
|
76
100
|
const { done, total, current } = planProgress(items);
|
|
101
|
+
if (done === total) return null;
|
|
102
|
+
const head = `tasks ${done}/${total}`;
|
|
103
|
+
if ((options.phase ?? "active") === "idle") return `${head} · idle`;
|
|
104
|
+
const since = options.toolCallsSinceUpdate ?? 0;
|
|
105
|
+
if (since >= PLAN_STALE_TOOL_CALLS) return `${head} · not updated for ${since} tool calls`;
|
|
77
106
|
// The ▸ task is the in-progress one, falling back to the first
|
|
78
107
|
// not-done item (what is next up).
|
|
79
108
|
const pointed = current ?? items.find((item) => item.status !== "done")?.title ?? null;
|
|
80
|
-
|
|
81
|
-
return `plan ${done}/${total} done`;
|
|
109
|
+
return pointed ? `${head} · ▸ ${clip(pointed, 60)}` : head;
|
|
82
110
|
}
|
|
83
111
|
|
|
84
112
|
/**
|
|
@@ -94,8 +122,8 @@ export function renderPlanItems(items: PlanItem[]): string {
|
|
|
94
122
|
return `${lines.join("\n")}\n\n${done}/${total} done`;
|
|
95
123
|
}
|
|
96
124
|
|
|
97
|
-
export function buildPlanFile(items: PlanItem[], updatedAt: string): PlanFile {
|
|
98
|
-
return { schema_version: 1, items, updated_at: updatedAt };
|
|
125
|
+
export function buildPlanFile(items: PlanItem[], updatedAt: string, sessionId?: string): PlanFile {
|
|
126
|
+
return { schema_version: 1, items, updated_at: updatedAt, ...(sessionId ? { session_id: sessionId } : {}) };
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
/** Parse a persisted plan.json, returning null on anything unusable. */
|
|
@@ -106,7 +134,8 @@ export function parsePlanFile(text: string): PlanFile | null {
|
|
|
106
134
|
const items = normalizePlanItems(parsed.items);
|
|
107
135
|
if (!items) return null;
|
|
108
136
|
const updatedAt = typeof parsed.updated_at === "string" ? parsed.updated_at : "";
|
|
109
|
-
|
|
137
|
+
const sessionId = typeof parsed.session_id === "string" && parsed.session_id ? parsed.session_id : undefined;
|
|
138
|
+
return { schema_version: 1, items, updated_at: updatedAt, ...(sessionId ? { session_id: sessionId } : {}) };
|
|
110
139
|
} catch {
|
|
111
140
|
return null;
|
|
112
141
|
}
|