pi-better-subagents 0.1.5 → 0.1.7
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/cleanup.ts +403 -0
- package/config.ts +10 -0
- package/health-observation.ts +168 -23
- package/health.ts +41 -0
- package/index.ts +158 -15
- package/log-cursor.ts +192 -0
- package/package.json +3 -1
- package/parse.ts +279 -161
- package/registry.ts +14 -0
- package/shared-log-utils.ts +104 -0
- package/shared-navigator.ts +16 -27
- package/widget.mjs +37 -5
- package/widget.ts +2 -0
package/cleanup.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join, relative } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
baseDir,
|
|
13
|
+
listMetas,
|
|
14
|
+
ownedByThisParent,
|
|
15
|
+
readMeta,
|
|
16
|
+
runDir,
|
|
17
|
+
sessionsDir,
|
|
18
|
+
type RunMeta,
|
|
19
|
+
type RunStatus,
|
|
20
|
+
} from "./registry.ts";
|
|
21
|
+
import type { SubagentConfig } from "./config.ts";
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_CLEANUP_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Total registry budget. Age alone cannot bound this directory: 63 GB
|
|
27
|
+
* accumulated inside five days, so a seven-day window never saw it. A
|
|
28
|
+
* `message_update` event re-serialises the whole accumulated message, so one
|
|
29
|
+
* long-running subagent can write gigabytes in an afternoon.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately tight. A single run's log has been observed past 3 GB, so a
|
|
32
|
+
* budget generous enough to hold several of those defeats the purpose: what a
|
|
33
|
+
* developer wants back is the disk, and what they lose is a log nothing reads
|
|
34
|
+
* once its result has been delivered.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_MAX_REGISTRY_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How often the size bound may be enforced. Independent of the daily marker:
|
|
40
|
+
* the point of a size cap is to react inside a day, and it only stats run
|
|
41
|
+
* directories rather than walking the session tree.
|
|
42
|
+
*/
|
|
43
|
+
export const SIZE_SWEEP_INTERVAL_MS = 10 * 60 * 1000; // 10min
|
|
44
|
+
|
|
45
|
+
interface CleanupState {
|
|
46
|
+
lastLocalDate?: string;
|
|
47
|
+
lastRunAt?: number;
|
|
48
|
+
lastSizeSweepAt?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DailyCleanupResult {
|
|
52
|
+
ran: boolean;
|
|
53
|
+
dateKey: string;
|
|
54
|
+
removedRunDirs: number;
|
|
55
|
+
removedSessionPaths: number;
|
|
56
|
+
errors: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DailyCleanupOptions {
|
|
60
|
+
now?: number;
|
|
61
|
+
config?: Pick<SubagentConfig, "cleanupTerminalRunRetentionMs" | "cleanupSessionRetentionMs">;
|
|
62
|
+
terminalRunRetentionMs?: number;
|
|
63
|
+
sessionRetentionMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TERMINAL_CLEANUP_STATUSES = new Set<RunStatus>(["completed", "failed", "killed", "lost"]);
|
|
67
|
+
|
|
68
|
+
export interface SizeCapEntry {
|
|
69
|
+
id: string;
|
|
70
|
+
status?: RunStatus;
|
|
71
|
+
/** End of the run, when recorded; oldest-first ordering uses it. */
|
|
72
|
+
endedAt?: number;
|
|
73
|
+
dirMtimeMs: number;
|
|
74
|
+
bytes: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SizeCapPlan {
|
|
78
|
+
remove: string[];
|
|
79
|
+
reclaimedBytes: number;
|
|
80
|
+
keptBytes: number;
|
|
81
|
+
/** The cap could not be met without deleting live or protected runs. */
|
|
82
|
+
overBudget: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SizeCapLimits {
|
|
86
|
+
maxBytes: number;
|
|
87
|
+
/**
|
|
88
|
+
* Runs the calling pi owns. Never removed: unlike the age bound, a size
|
|
89
|
+
* sweep goes after the NEWEST large runs once a cap is exceeded, which is
|
|
90
|
+
* exactly the live session's own work — the session can still deliver their
|
|
91
|
+
* callbacks and answer `subagent_result` for them.
|
|
92
|
+
*/
|
|
93
|
+
protectedIds?: ReadonlySet<string>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Choose run directories to retire so the registry fits `maxBytes`,
|
|
98
|
+
* oldest-terminal-first. Pure; the caller supplies the entries.
|
|
99
|
+
*/
|
|
100
|
+
export function planRegistrySizeCap(
|
|
101
|
+
entries: readonly SizeCapEntry[],
|
|
102
|
+
limits: SizeCapLimits,
|
|
103
|
+
): SizeCapPlan {
|
|
104
|
+
let kept = entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
105
|
+
const remove: string[] = [];
|
|
106
|
+
if (kept <= limits.maxBytes) {
|
|
107
|
+
return { remove, reclaimedBytes: 0, keptBytes: kept, overBudget: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const candidates = entries
|
|
111
|
+
.filter((entry) => entry.status !== undefined
|
|
112
|
+
&& TERMINAL_CLEANUP_STATUSES.has(entry.status)
|
|
113
|
+
&& !limits.protectedIds?.has(entry.id))
|
|
114
|
+
.sort((a, b) => (a.endedAt ?? a.dirMtimeMs) - (b.endedAt ?? b.dirMtimeMs));
|
|
115
|
+
|
|
116
|
+
let reclaimed = 0;
|
|
117
|
+
for (const candidate of candidates) {
|
|
118
|
+
if (kept <= limits.maxBytes) break;
|
|
119
|
+
remove.push(candidate.id);
|
|
120
|
+
kept -= candidate.bytes;
|
|
121
|
+
reclaimed += candidate.bytes;
|
|
122
|
+
}
|
|
123
|
+
return { remove, reclaimedBytes: reclaimed, keptBytes: kept, overBudget: kept > limits.maxBytes };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function cleanupStatePath(): string {
|
|
127
|
+
return join(baseDir(), "cleanup-state.json");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function localDateKey(now: number = Date.now()): string {
|
|
131
|
+
const d = new Date(now);
|
|
132
|
+
const year = d.getFullYear();
|
|
133
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
134
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
135
|
+
return `${year}-${month}-${day}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readCleanupState(): CleanupState {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(readFileSync(cleanupStatePath(), "utf8")) as CleanupState;
|
|
141
|
+
} catch {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function writeCleanupState(state: CleanupState): void {
|
|
147
|
+
mkdirSync(baseDir(), { recursive: true });
|
|
148
|
+
writeFileSync(cleanupStatePath(), JSON.stringify(state, null, 2));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function retentionMs(explicit: number | undefined, configured: number | null | undefined): number {
|
|
152
|
+
const raw = explicit ?? configured;
|
|
153
|
+
return Number.isFinite(raw) && raw! >= 0 ? raw! : DEFAULT_CLEANUP_RETENTION_MS;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function terminalTimestamp(meta: RunMeta): number {
|
|
157
|
+
return meta.endedAt ?? meta.lostAt ?? meta.startedAt;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function safeStatMtime(path: string): number | undefined {
|
|
161
|
+
try {
|
|
162
|
+
return statSync(path).mtimeMs;
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function removePath(path: string, errors: string[]): boolean {
|
|
169
|
+
try {
|
|
170
|
+
rmSync(path, { recursive: true, force: true });
|
|
171
|
+
return true;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
errors.push(`${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function cleanRunDirs(cutoff: number, errors: string[]): number {
|
|
179
|
+
let removed = 0;
|
|
180
|
+
let ids: string[];
|
|
181
|
+
try {
|
|
182
|
+
ids = readdirSync(join(baseDir(), "runs"));
|
|
183
|
+
} catch {
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const id of ids) {
|
|
188
|
+
const dir = runDir(id);
|
|
189
|
+
const meta = readMeta(id);
|
|
190
|
+
if (meta) {
|
|
191
|
+
if (!TERMINAL_CLEANUP_STATUSES.has(meta.status)) continue;
|
|
192
|
+
if (terminalTimestamp(meta) >= cutoff) continue;
|
|
193
|
+
if (removePath(dir, errors)) removed += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const mtimeMs = safeStatMtime(dir);
|
|
198
|
+
if (mtimeMs !== undefined && mtimeMs < cutoff && removePath(dir, errors)) removed += 1;
|
|
199
|
+
}
|
|
200
|
+
return removed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function activeSessionIds(metas: RunMeta[]): Set<string> {
|
|
204
|
+
const active = new Set<string>();
|
|
205
|
+
for (const meta of metas) {
|
|
206
|
+
if (meta.status === "running" || meta.status === "orphaned") active.add(meta.sessionId);
|
|
207
|
+
}
|
|
208
|
+
return active;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pathMentionsActiveSession(path: string, active: Set<string>): boolean {
|
|
212
|
+
if (active.size === 0) return false;
|
|
213
|
+
const rel = relative(sessionsDir(), path);
|
|
214
|
+
for (const sessionId of active) {
|
|
215
|
+
if (sessionId && rel.includes(sessionId)) return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function cleanSessionTree(cutoff: number, active: Set<string>, errors: string[]): number {
|
|
221
|
+
const root = sessionsDir();
|
|
222
|
+
if (!existsSync(root)) return 0;
|
|
223
|
+
let removed = 0;
|
|
224
|
+
|
|
225
|
+
const visit = (dir: string): boolean => {
|
|
226
|
+
let entries;
|
|
227
|
+
try {
|
|
228
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
229
|
+
} catch (err) {
|
|
230
|
+
errors.push(`${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let empty = true;
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
const path = join(dir, entry.name);
|
|
237
|
+
if (entry.isDirectory()) {
|
|
238
|
+
const childEmpty = visit(path);
|
|
239
|
+
if (childEmpty && !pathMentionsActiveSession(path, active)) {
|
|
240
|
+
const mtimeMs = safeStatMtime(path);
|
|
241
|
+
if (mtimeMs !== undefined && mtimeMs < cutoff && removePath(path, errors)) {
|
|
242
|
+
removed += 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
empty = false;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (pathMentionsActiveSession(path, active)) {
|
|
251
|
+
empty = false;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const mtimeMs = safeStatMtime(path);
|
|
255
|
+
if (mtimeMs !== undefined && mtimeMs < cutoff && removePath(path, errors)) {
|
|
256
|
+
removed += 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
empty = false;
|
|
260
|
+
}
|
|
261
|
+
return empty;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
visit(root);
|
|
265
|
+
return removed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function runDailyCleanupOnce(options: DailyCleanupOptions = {}): DailyCleanupResult {
|
|
269
|
+
const now = options.now ?? Date.now();
|
|
270
|
+
const dateKey = localDateKey(now);
|
|
271
|
+
const state = readCleanupState();
|
|
272
|
+
if (state.lastLocalDate === dateKey) {
|
|
273
|
+
return { ran: false, dateKey, removedRunDirs: 0, removedSessionPaths: 0, errors: [] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const errors: string[] = [];
|
|
277
|
+
const terminalRetention = retentionMs(options.terminalRunRetentionMs, options.config?.cleanupTerminalRunRetentionMs);
|
|
278
|
+
const sessionRetention = retentionMs(options.sessionRetentionMs, options.config?.cleanupSessionRetentionMs);
|
|
279
|
+
const metas = listMetas();
|
|
280
|
+
const removedRunDirs = cleanRunDirs(now - terminalRetention, errors);
|
|
281
|
+
const removedSessionPaths = cleanSessionTree(now - sessionRetention, activeSessionIds(metas), errors);
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
writeCleanupState({ lastLocalDate: dateKey, lastRunAt: now });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
errors.push(`${cleanupStatePath()}: ${err instanceof Error ? err.message : String(err)}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { ran: true, dateKey, removedRunDirs, removedSessionPaths, errors };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Bytes held under one run directory. Best-effort; unreadable entries count 0. */
|
|
293
|
+
function dirBytes(dir: string): number {
|
|
294
|
+
let bytes = 0;
|
|
295
|
+
let entries;
|
|
296
|
+
try {
|
|
297
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
298
|
+
} catch {
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
for (const entry of entries) {
|
|
302
|
+
const path = join(dir, entry.name);
|
|
303
|
+
try {
|
|
304
|
+
bytes += entry.isDirectory() ? dirBytes(path) : statSync(path).size;
|
|
305
|
+
} catch { /* vanished mid-scan */ }
|
|
306
|
+
}
|
|
307
|
+
return bytes;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Read the registry into size-cap entries. */
|
|
311
|
+
export function collectSizeCapEntries(): SizeCapEntry[] {
|
|
312
|
+
let ids: string[];
|
|
313
|
+
try {
|
|
314
|
+
ids = readdirSync(join(baseDir(), "runs"));
|
|
315
|
+
} catch {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
const entries: SizeCapEntry[] = [];
|
|
319
|
+
for (const id of ids) {
|
|
320
|
+
const dir = runDir(id);
|
|
321
|
+
const meta = readMeta(id);
|
|
322
|
+
entries.push({
|
|
323
|
+
id,
|
|
324
|
+
status: meta?.status,
|
|
325
|
+
endedAt: meta ? terminalTimestamp(meta) : undefined,
|
|
326
|
+
dirMtimeMs: safeStatMtime(dir) ?? 0,
|
|
327
|
+
bytes: dirBytes(dir),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return entries;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface SizeCapResult extends SizeCapPlan {
|
|
334
|
+
ran: boolean;
|
|
335
|
+
removed: string[];
|
|
336
|
+
errors: string[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface SizeCapOptions {
|
|
340
|
+
now?: number;
|
|
341
|
+
config?: Pick<SubagentConfig, "maxRegistryBytes">;
|
|
342
|
+
maxBytes?: number;
|
|
343
|
+
/** Bypass the interval marker (tests, explicit operator sweeps). */
|
|
344
|
+
force?: boolean;
|
|
345
|
+
protectedIds?: ReadonlySet<string>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Enforce the registry byte budget, at most once per SIZE_SWEEP_INTERVAL_MS.
|
|
350
|
+
* Complements the daily age sweep: age retires history, this bounds the peak a
|
|
351
|
+
* single busy day can reach. Best-effort; never throws into a caller.
|
|
352
|
+
*/
|
|
353
|
+
export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCapResult {
|
|
354
|
+
const now = options.now ?? Date.now();
|
|
355
|
+
const idle: SizeCapResult = {
|
|
356
|
+
ran: false,
|
|
357
|
+
remove: [],
|
|
358
|
+
removed: [],
|
|
359
|
+
reclaimedBytes: 0,
|
|
360
|
+
keptBytes: 0,
|
|
361
|
+
overBudget: false,
|
|
362
|
+
errors: [],
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const state = readCleanupState();
|
|
366
|
+
if (!options.force
|
|
367
|
+
&& typeof state.lastSizeSweepAt === "number"
|
|
368
|
+
&& now - state.lastSizeSweepAt >= 0
|
|
369
|
+
&& now - state.lastSizeSweepAt < SIZE_SWEEP_INTERVAL_MS) {
|
|
370
|
+
return idle;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const configured = options.config?.maxRegistryBytes;
|
|
374
|
+
const maxBytes = options.maxBytes
|
|
375
|
+
?? (Number.isFinite(configured) && configured! > 0 ? configured! : DEFAULT_MAX_REGISTRY_BYTES);
|
|
376
|
+
|
|
377
|
+
const errors: string[] = [];
|
|
378
|
+
const protectedIds = options.protectedIds ?? ownRunIds();
|
|
379
|
+
const plan = planRegistrySizeCap(collectSizeCapEntries(), { maxBytes, protectedIds });
|
|
380
|
+
const removed: string[] = [];
|
|
381
|
+
for (const id of plan.remove) {
|
|
382
|
+
if (removePath(runDir(id), errors)) removed.push(id);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
writeCleanupState({ ...state, lastSizeSweepAt: now });
|
|
387
|
+
} catch (err) {
|
|
388
|
+
errors.push(`${cleanupStatePath()}: ${err instanceof Error ? err.message : String(err)}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { ...plan, ran: true, removed, errors };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Run ids owned by this pi process, which a size sweep must never remove. */
|
|
395
|
+
function ownRunIds(): Set<string> {
|
|
396
|
+
const own = new Set<string>();
|
|
397
|
+
try {
|
|
398
|
+
for (const meta of listMetas()) {
|
|
399
|
+
if (ownedByThisParent(meta)) own.add(meta.id);
|
|
400
|
+
}
|
|
401
|
+
} catch { /* registry unreadable: protect nothing, remove nothing new */ }
|
|
402
|
+
return own;
|
|
403
|
+
}
|
package/config.ts
CHANGED
|
@@ -45,6 +45,16 @@ export interface SubagentConfig {
|
|
|
45
45
|
healthStaleMs?: number | null;
|
|
46
46
|
healthLongToolMs?: number | null;
|
|
47
47
|
healthLongCompactionMs?: number | null;
|
|
48
|
+
/** Retention for terminal run directories during once-daily cleanup. Default: 7 days. */
|
|
49
|
+
cleanupTerminalRunRetentionMs?: number | null;
|
|
50
|
+
/** Retention for child pi session files during once-daily cleanup. Default: 7 days. */
|
|
51
|
+
cleanupSessionRetentionMs?: number | null;
|
|
52
|
+
/**
|
|
53
|
+
* Total registry byte budget. Enforced between daily sweeps, oldest terminal
|
|
54
|
+
* run first — age alone cannot bound a directory that can grow by gigabytes
|
|
55
|
+
* in an afternoon. Default: 2 GiB. See cleanup.ts.
|
|
56
|
+
*/
|
|
57
|
+
maxRegistryBytes?: number | null;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
/** Concurrency cap when config.json sets none. */
|
package/health-observation.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* and must not collapse into stale. Raw log mtime/size is diagnostic only.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { statSync } from "node:fs";
|
|
14
|
+
import { DEFAULT_MAX_READ_BYTES, readAppendedLines, type LogCursor } from "./log-cursor.ts";
|
|
14
15
|
import { logPathFor } from "./registry.ts";
|
|
15
16
|
import type { RunStatus } from "./registry.ts";
|
|
16
17
|
import { loadConfig, type SubagentConfig } from "./config.ts";
|
|
@@ -119,6 +120,14 @@ export interface RawLogDiagnostic {
|
|
|
119
120
|
mtimeMs?: number;
|
|
120
121
|
sizeBytes?: number;
|
|
121
122
|
error?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Bytes of the log were never read: a bounded cold start on an already-huge
|
|
125
|
+
* log, or a log that outran the per-read budget. Diagnostic only — facts
|
|
126
|
+
* from a partial window are still facts, they are just not the whole run.
|
|
127
|
+
*/
|
|
128
|
+
windowTruncated?: boolean;
|
|
129
|
+
/** Retained fact-relevant events were dropped to stay inside the memory budget. */
|
|
130
|
+
eventsDropped?: boolean;
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
type LooseEvent = Record<string, unknown>;
|
|
@@ -184,6 +193,43 @@ function pushError(history: ModelErrorEntry[], entry: ModelErrorEntry, max = 8):
|
|
|
184
193
|
while (history.length > max) history.shift();
|
|
185
194
|
}
|
|
186
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Every event type `extractChildEventFacts` below branches on. The fold has no
|
|
198
|
+
* default branch — an event of any other type (`message_update` and friends,
|
|
199
|
+
* which are 98% of a log by volume) cannot move a single fact — so incremental
|
|
200
|
+
* readers may drop unlisted types before parsing them without changing any
|
|
201
|
+
* observation. Keep in step with the fold: `health_observation.test.mjs` fails
|
|
202
|
+
* if the fold branches on a type this set does not name, or names one the fold
|
|
203
|
+
* no longer reads.
|
|
204
|
+
*/
|
|
205
|
+
export const CHILD_EVENT_FACT_TYPES: ReadonlySet<string> = new Set([
|
|
206
|
+
"tool_execution_start",
|
|
207
|
+
"tool_execution_update",
|
|
208
|
+
"tool_execution_end",
|
|
209
|
+
"compaction_start",
|
|
210
|
+
"compaction_end",
|
|
211
|
+
"auto_retry_start",
|
|
212
|
+
"auto_retry_end",
|
|
213
|
+
"message_end",
|
|
214
|
+
"turn_end",
|
|
215
|
+
"agent_end",
|
|
216
|
+
"agent_settled",
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
/** Leading `{"type":"…"` of an NDJSON event line, read without parsing it. */
|
|
220
|
+
const LEADING_TYPE = /^\s*\{\s*"type"\s*:\s*"([A-Za-z0-9_.-]+)"/;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether a raw log line could move any fact. Lines whose type we can read
|
|
224
|
+
* cheaply and that the fold ignores are rejected without a `JSON.parse`; a line
|
|
225
|
+
* whose shape we cannot read cheaply is kept, so the fold stays authoritative.
|
|
226
|
+
*/
|
|
227
|
+
export function mayAffectChildEventFacts(line: string): boolean {
|
|
228
|
+
const match = LEADING_TYPE.exec(line);
|
|
229
|
+
if (!match) return true;
|
|
230
|
+
return CHILD_EVENT_FACT_TYPES.has(match[1]!);
|
|
231
|
+
}
|
|
232
|
+
|
|
187
233
|
/**
|
|
188
234
|
* Extract health-relevant facts from an in-memory event list (tests / callers
|
|
189
235
|
* that already parsed NDJSON). Skips non-objects; ignores raw non-JSON noise.
|
|
@@ -371,9 +417,99 @@ export function extractChildEventFacts(events: ReadonlyArray<unknown>): ChildEve
|
|
|
371
417
|
};
|
|
372
418
|
}
|
|
373
419
|
|
|
420
|
+
/** Parse the fact-relevant events out of raw NDJSON lines. */
|
|
421
|
+
function parseFactEvents(lines: Iterable<string>): { events: unknown[]; sizes: number[] } {
|
|
422
|
+
const events: unknown[] = [];
|
|
423
|
+
const sizes: number[] = [];
|
|
424
|
+
for (const line of lines) {
|
|
425
|
+
const s = line.trim();
|
|
426
|
+
if (!s || s[0] !== "{") continue;
|
|
427
|
+
if (!mayAffectChildEventFacts(s)) continue;
|
|
428
|
+
try {
|
|
429
|
+
events.push(JSON.parse(s));
|
|
430
|
+
sizes.push(s.length);
|
|
431
|
+
} catch {
|
|
432
|
+
// bad JSON — ignore (noise / partial line)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { events, sizes };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---- incremental log reading ----------------------------------------------
|
|
439
|
+
//
|
|
440
|
+
// Logs are append-only and reach gigabytes, so facts are folded from an
|
|
441
|
+
// accumulated event list that grows by the bytes appended since the last read
|
|
442
|
+
// (see log-cursor.ts). Only the types the fold consumes are retained, which is
|
|
443
|
+
// ~2% of a log's lines; `message_update` alone is 98% of the volume and cannot
|
|
444
|
+
// move a fact. The retained list is capped, oldest-first, and a drop is
|
|
445
|
+
// reported as a diagnostic rather than silently changing an observation.
|
|
446
|
+
|
|
447
|
+
/** Memory budget for one run's retained fact-relevant events. */
|
|
448
|
+
const MAX_RETAINED_EVENT_BYTES = 8 * 1024 * 1024; // 8 MiB
|
|
449
|
+
|
|
450
|
+
function maxHealthReadBytes(): number {
|
|
451
|
+
const raw = process.env.PI_SUBAGENT_MAX_HEALTH_READ_BYTES;
|
|
452
|
+
if (!raw) return DEFAULT_MAX_READ_BYTES;
|
|
453
|
+
const n = Number(raw);
|
|
454
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_READ_BYTES;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Runs tracked at once. A backstop, not a policy: callers are expected to reset
|
|
459
|
+
* a dismissed run, and this bounds the leak if one forgets.
|
|
460
|
+
*/
|
|
461
|
+
const MAX_TRACKED_RUNS = 64;
|
|
462
|
+
|
|
463
|
+
interface FactLogState {
|
|
464
|
+
cursor: LogCursor;
|
|
465
|
+
events: unknown[];
|
|
466
|
+
/** Retained source bytes per event, index-aligned with `events`. */
|
|
467
|
+
sizes: number[];
|
|
468
|
+
/** Sum of `sizes`, for the drop-oldest budget. */
|
|
469
|
+
bytes: number;
|
|
470
|
+
truncated: boolean;
|
|
471
|
+
dropped: boolean;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const factLogStates = new Map<string, FactLogState>();
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Forget the incremental read position for a run (or all runs). Call after a run
|
|
478
|
+
* is dismissed, and in tests that rewrite a log under a reused id.
|
|
479
|
+
*/
|
|
480
|
+
export function resetChildEventLogCursor(id?: string): void {
|
|
481
|
+
if (id === undefined) factLogStates.clear();
|
|
482
|
+
else factLogStates.delete(id);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function rememberState(id: string, state: FactLogState): void {
|
|
486
|
+
// Re-insert so the map orders least-recently-read first.
|
|
487
|
+
factLogStates.delete(id);
|
|
488
|
+
factLogStates.set(id, state);
|
|
489
|
+
while (factLogStates.size > MAX_TRACKED_RUNS) {
|
|
490
|
+
const oldest = factLogStates.keys().next();
|
|
491
|
+
if (oldest.done) break;
|
|
492
|
+
factLogStates.delete(oldest.value);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function trimToBudget(state: FactLogState): void {
|
|
497
|
+
while (state.bytes > MAX_RETAINED_EVENT_BYTES && state.events.length > 1) {
|
|
498
|
+
state.events.shift();
|
|
499
|
+
state.bytes -= state.sizes.shift() ?? 0;
|
|
500
|
+
state.dropped = true;
|
|
501
|
+
}
|
|
502
|
+
if (state.bytes < 0) state.bytes = 0;
|
|
503
|
+
}
|
|
504
|
+
|
|
374
505
|
/**
|
|
375
506
|
* Read a run log for event facts + raw mtime/size diagnostics.
|
|
376
507
|
* Raw log write time never promotes activity health by itself.
|
|
508
|
+
*
|
|
509
|
+
* Reads incrementally: only bytes appended since the previous call for this id
|
|
510
|
+
* are parsed, so a hot-path caller never re-reads a whole multi-gigabyte log.
|
|
511
|
+
* Pass `logText` to fold a caller-supplied log instead, which bypasses the
|
|
512
|
+
* cursor entirely.
|
|
377
513
|
*/
|
|
378
514
|
export function extractChildEventFactsFromLog(
|
|
379
515
|
id: string,
|
|
@@ -389,33 +525,42 @@ export function extractChildEventFactsFromLog(
|
|
|
389
525
|
rawLog.error = err instanceof Error ? err.message : String(err);
|
|
390
526
|
}
|
|
391
527
|
|
|
392
|
-
let text = opts.logText;
|
|
393
|
-
if (text === undefined) {
|
|
394
|
-
try {
|
|
395
|
-
text = readFileSync(path, "utf-8");
|
|
396
|
-
} catch (err) {
|
|
397
|
-
rawLog.error = rawLog.error ?? (err instanceof Error ? err.message : String(err));
|
|
398
|
-
return { facts: emptyFacts(), rawLog };
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const events: unknown[] = [];
|
|
403
|
-
for (const line of text.split("\n")) {
|
|
404
|
-
const s = line.trim();
|
|
405
|
-
if (!s || s[0] !== "{") continue;
|
|
406
|
-
try {
|
|
407
|
-
events.push(JSON.parse(s));
|
|
408
|
-
} catch {
|
|
409
|
-
// bad JSON — ignore (noise / partial line)
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
528
|
// Do not synthesise wall-clock timestamps from raw mtime / now. Untimestamped
|
|
414
529
|
// events still contribute structural facts (open tools, compacting, model
|
|
415
530
|
// phase); activity age only moves on parsed-event write provenance.
|
|
416
531
|
// `opts.now` is accepted for API symmetry with callers but must not mint times.
|
|
417
532
|
void opts.now;
|
|
418
|
-
|
|
533
|
+
|
|
534
|
+
if (opts.logText !== undefined) {
|
|
535
|
+
const { events } = parseFactEvents(opts.logText.split("\n"));
|
|
536
|
+
return { facts: extractChildEventFacts(events), rawLog };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const previous = factLogStates.get(id);
|
|
540
|
+
const read = readAppendedLines(path, previous?.cursor, maxHealthReadBytes());
|
|
541
|
+
if (read.error !== undefined) {
|
|
542
|
+
rawLog.error = rawLog.error ?? read.error;
|
|
543
|
+
if (!previous) return { facts: emptyFacts(), rawLog };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const state: FactLogState = read.restarted || !previous
|
|
547
|
+
? { cursor: read.cursor, events: [], sizes: [], bytes: 0, truncated: false, dropped: false }
|
|
548
|
+
: previous;
|
|
549
|
+
state.cursor = read.cursor;
|
|
550
|
+
state.truncated = state.truncated || read.truncated;
|
|
551
|
+
|
|
552
|
+
const parsed = parseFactEvents(read.lines);
|
|
553
|
+
if (parsed.events.length > 0) {
|
|
554
|
+
state.events.push(...parsed.events);
|
|
555
|
+
state.sizes.push(...parsed.sizes);
|
|
556
|
+
for (const size of parsed.sizes) state.bytes += size;
|
|
557
|
+
trimToBudget(state);
|
|
558
|
+
}
|
|
559
|
+
rememberState(id, state);
|
|
560
|
+
|
|
561
|
+
if (state.truncated) rawLog.windowTruncated = true;
|
|
562
|
+
if (state.dropped) rawLog.eventsDropped = true;
|
|
563
|
+
return { facts: extractChildEventFacts(state.events), rawLog };
|
|
419
564
|
}
|
|
420
565
|
|
|
421
566
|
// ---- observation ----------------------------------------------------------
|