@kienntpixon/dieu-phoi 0.7.2 → 0.7.3
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/CHANGELOG.md +18 -0
- package/bin/dieu-phoi.cjs +21 -5
- package/hook.cjs +659 -26
- package/lib/args.cjs +1 -1
- package/lib/cbm.cjs +8 -2
- package/lib/commands.cjs +255 -7
- package/lib/deps.cjs +162 -0
- package/lib/load.cjs +332 -0
- package/lib/procs.cjs +200 -1
- package/lib/prune.cjs +10 -1
- package/lib/setup.cjs +28 -5
- package/lib/worktree.cjs +250 -19
- package/package.json +1 -1
- package/profile/lessons.md +2 -0
package/lib/load.cjs
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// core 0.7.3 (s5): đo tải để kiểm giả thuyết (contract jiptvo5g, mục 1) — số agent đồng thời và IO đĩa.
|
|
3
|
+
// dieu-phoi load sample [--interval 10] [--count 12] [--repo R] [--json] → data/load.jsonl, một mẫu một dòng
|
|
4
|
+
// dieu-phoi load report [--days 21] [--json] → phút ở mỗi mức đồng thời + tương quan IO
|
|
5
|
+
// Mẫu chỉ chứa số đếm và tốc độ IO: không có đường dẫn, prompt hay nội dung task (log không lộ nội dung).
|
|
6
|
+
// Hook SubagentStart ghi thêm mẫu chỉ-số-đếm (không IO) qua hook.cjs appendLoadSample.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const cp = require('child_process');
|
|
10
|
+
const core = require('../hook.cjs');
|
|
11
|
+
const args = require('./args.cjs');
|
|
12
|
+
const { mainCheckoutOf, markedWorktrees } = require('./worktree.cjs');
|
|
13
|
+
|
|
14
|
+
const DISK_READ = '\\PhysicalDisk(_Total)\\Disk Read Bytes/sec';
|
|
15
|
+
const DISK_WRITE = '\\PhysicalDisk(_Total)\\Disk Write Bytes/sec';
|
|
16
|
+
const PROC_DISKSTATS = '/proc/diskstats';
|
|
17
|
+
const SECTOR_BYTES = 512;
|
|
18
|
+
const DISK_WINDOW_MS = 1000;
|
|
19
|
+
/** Dispatch chưa có complete: tính tối đa bằng này (= stuck_hard_min mặc định của s6). */
|
|
20
|
+
const OPEN_CAP_MS = 120 * 60 * 1000;
|
|
21
|
+
const MIN_MS = 60 * 1000;
|
|
22
|
+
const WHOLE_DISK_RE = /^(sd[a-z]+|vd[a-z]+|xvd[a-z]+|hd[a-z]+|nvme\d+n\d+|mmcblk\d+)$/;
|
|
23
|
+
const MB = 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
function sleepSync(ms) {
|
|
26
|
+
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function num(s) {
|
|
30
|
+
const t = String(s === undefined || s === null ? '' : s).trim();
|
|
31
|
+
return t === '' ? NaN : Number(t);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** typeperf CSV: the last data row ("<date>","<read>","<write>") → {read, write}, or null. */
|
|
35
|
+
function parseTypeperf(text) {
|
|
36
|
+
let row = null;
|
|
37
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
38
|
+
const m = /^"(\d[^"]*)","([^"]*)","([^"]*)"$/.exec(line.trim());
|
|
39
|
+
if (m) row = m;
|
|
40
|
+
}
|
|
41
|
+
if (!row) return null;
|
|
42
|
+
const read = num(row[2]);
|
|
43
|
+
const write = num(row[3]);
|
|
44
|
+
if (!Number.isFinite(read) || !Number.isFinite(write)) return null;
|
|
45
|
+
return { read, write };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** /proc/diskstats → byte totals over whole disks only (partitions, loop and ram devices skipped), or null. */
|
|
49
|
+
function parseDiskstats(text) {
|
|
50
|
+
let read = 0;
|
|
51
|
+
let write = 0;
|
|
52
|
+
let any = false;
|
|
53
|
+
for (const line of String(text || '').split('\n')) {
|
|
54
|
+
const p = line.trim().split(/\s+/);
|
|
55
|
+
if (p.length < 10 || !WHOLE_DISK_RE.test(p[2])) continue;
|
|
56
|
+
read += Number(p[5]) * SECTOR_BYTES;
|
|
57
|
+
write += Number(p[9]) * SECTOR_BYTES;
|
|
58
|
+
any = true;
|
|
59
|
+
}
|
|
60
|
+
return any ? { read, write } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function typeperfRates() {
|
|
64
|
+
const out = cp.execFileSync('typeperf', [DISK_READ, DISK_WRITE, '-sc', '1'], {
|
|
65
|
+
encoding: 'utf8', windowsHide: true, timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
66
|
+
});
|
|
67
|
+
return parseTypeperf(out);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readDiskstats() {
|
|
71
|
+
try { return parseDiskstats(fs.readFileSync(PROC_DISKSTATS, 'utf8')); } catch (_) { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bytes/s over one window. Windows: typeperf samples the window itself. POSIX: /proc/diskstats read twice around a
|
|
76
|
+
* DISK_WINDOW_MS sleep. Anything missing → null (never throws). deps lets the tests inject the platform and readers.
|
|
77
|
+
*/
|
|
78
|
+
function diskRates(deps) {
|
|
79
|
+
const d = deps || {};
|
|
80
|
+
const platform = d.platform || process.platform;
|
|
81
|
+
let r = null;
|
|
82
|
+
try {
|
|
83
|
+
if (platform === 'win32') {
|
|
84
|
+
r = (d.typeperf || typeperfRates)();
|
|
85
|
+
} else {
|
|
86
|
+
const read = d.readDiskstats || readDiskstats;
|
|
87
|
+
const a = read();
|
|
88
|
+
(d.sleep || sleepSync)(DISK_WINDOW_MS);
|
|
89
|
+
const b = read();
|
|
90
|
+
if (a && b) {
|
|
91
|
+
const secs = DISK_WINDOW_MS / 1000;
|
|
92
|
+
r = { read: (b.read - a.read) / secs, write: (b.write - a.write) / secs };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch (_) {
|
|
96
|
+
r = null;
|
|
97
|
+
}
|
|
98
|
+
if (!r) return { disk_read_Bps: null, disk_write_Bps: null };
|
|
99
|
+
return {
|
|
100
|
+
disk_read_Bps: Math.max(0, Math.round(r.read)),
|
|
101
|
+
disk_write_Bps: Math.max(0, Math.round(r.write)),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Marked worktrees that still exist in the repo at `repoDir`, or null when it is not a git checkout. */
|
|
106
|
+
function markedCount(repoDir) {
|
|
107
|
+
try {
|
|
108
|
+
const { common } = mainCheckoutOf(repoDir);
|
|
109
|
+
return markedWorktrees(common).filter((w) => w.exists).length;
|
|
110
|
+
} catch (_) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One sample: counts (liveLoadCounts) + IO rates + marked worktrees. */
|
|
116
|
+
function collectSample(ctx, o) {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const counts = core.liveLoadCounts(ctx, now);
|
|
119
|
+
const io = diskRates(o.deps);
|
|
120
|
+
return Object.assign({ ts: new Date(now).toISOString(), src: 'sample' }, counts,
|
|
121
|
+
{ worktrees_marked: markedCount(o.repo) }, io);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Takes `count` samples `interval` seconds apart (start to start) and appends each to data/load.jsonl. */
|
|
125
|
+
function sampleLoop(ctx, o) {
|
|
126
|
+
const file = core.loadFile(ctx);
|
|
127
|
+
fs.mkdirSync(ctx.paths.data, { recursive: true });
|
|
128
|
+
const recs = [];
|
|
129
|
+
for (let i = 0; i < o.count; i++) {
|
|
130
|
+
const t0 = Date.now();
|
|
131
|
+
const rec = collectSample(ctx, o);
|
|
132
|
+
fs.appendFileSync(file, `${JSON.stringify(rec)}\n`);
|
|
133
|
+
recs.push(rec);
|
|
134
|
+
if (i < o.count - 1) sleepSync(Math.max(0, o.interval * 1000 - (Date.now() - t0)));
|
|
135
|
+
}
|
|
136
|
+
return recs;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function numArg(v, dflt, name, min, max) {
|
|
140
|
+
if (v === undefined) return dflt;
|
|
141
|
+
const n = num(v);
|
|
142
|
+
if (!Number.isFinite(n) || n < min || n > max) throw new Error(`--${name} phải là số trong [${min}, ${max}]`);
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function mbps(v) {
|
|
147
|
+
return Number.isFinite(v) ? `${(v / MB).toFixed(2)} MB/s` : 'n/a';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cmdLoadSample(opts, deps) {
|
|
151
|
+
const ctx = args.ctxOf(opts);
|
|
152
|
+
const count = Math.floor(numArg(opts.count, 12, 'count', 1, 100000));
|
|
153
|
+
const interval = numArg(opts.interval, 10, 'interval', 0, 86400);
|
|
154
|
+
const repo = args.repoOf(opts);
|
|
155
|
+
const recs = sampleLoop(ctx, { count, interval, repo, deps });
|
|
156
|
+
if (opts.json) return { code: 0, out: JSON.stringify(recs, null, 2) };
|
|
157
|
+
const lines = recs.map((r) => ` ${r.ts} · máy ${r.live_machine} · hàng đợi ${r.queue} · đọc ${mbps(r.disk_read_Bps)} · ghi ${mbps(r.disk_write_Bps)}`);
|
|
158
|
+
return { code: 0, out: `dieu-phoi load sample: ${recs.length} mẫu → ${core.loadFile(ctx)}\n${lines.join('\n')}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Dispatch/complete events → intervals. A complete closes the oldest open dispatch of the same session+task+role. */
|
|
162
|
+
function pairIntervals(events, nowMs) {
|
|
163
|
+
const sorted = events.slice().sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
|
|
164
|
+
const open = new Map();
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const e of sorted) {
|
|
167
|
+
const t = Date.parse(e.ts);
|
|
168
|
+
const key = `${e.session || ''}|${e.task_id || ''}|${e.role || ''}`;
|
|
169
|
+
if (e.kind === 'dispatch') {
|
|
170
|
+
if (!open.has(key)) open.set(key, []);
|
|
171
|
+
open.get(key).push({ t, session: e.session || '-' });
|
|
172
|
+
} else {
|
|
173
|
+
const q = open.get(key);
|
|
174
|
+
if (q && q.length) {
|
|
175
|
+
const s = q.shift();
|
|
176
|
+
out.push({ s: s.t, e: t, session: s.session, paired: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let unpaired = 0;
|
|
181
|
+
for (const q of open.values()) {
|
|
182
|
+
for (const s of q) {
|
|
183
|
+
unpaired += 1;
|
|
184
|
+
out.push({ s: s.t, e: Math.min(s.t + OPEN_CAP_MS, nowMs), session: s.session, paired: false });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { intervals: out, unpaired };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function addMs(map, level, ms) {
|
|
191
|
+
map.set(level, (map.get(level) || 0) + ms);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Milliseconds at each concurrency level: machine (every interval) and busiest session (max over sessions). */
|
|
195
|
+
function levelTimes(intervals) {
|
|
196
|
+
const marks = [];
|
|
197
|
+
for (const iv of intervals) {
|
|
198
|
+
marks.push({ t: iv.s, d: 1, sess: iv.session });
|
|
199
|
+
marks.push({ t: iv.e, d: -1, sess: iv.session });
|
|
200
|
+
}
|
|
201
|
+
marks.sort((a, b) => (a.t - b.t) || (a.d - b.d)); // ends before starts at the same instant
|
|
202
|
+
const machine = new Map();
|
|
203
|
+
const busiest = new Map();
|
|
204
|
+
const perSession = new Map();
|
|
205
|
+
let cur = 0;
|
|
206
|
+
let maxS = 0;
|
|
207
|
+
let last = null;
|
|
208
|
+
for (const m of marks) {
|
|
209
|
+
if (last !== null && m.t > last) {
|
|
210
|
+
if (cur > 0) addMs(machine, cur, m.t - last);
|
|
211
|
+
if (maxS > 0) addMs(busiest, maxS, m.t - last);
|
|
212
|
+
}
|
|
213
|
+
last = m.t;
|
|
214
|
+
cur += m.d;
|
|
215
|
+
perSession.set(m.sess, (perSession.get(m.sess) || 0) + m.d);
|
|
216
|
+
maxS = 0;
|
|
217
|
+
for (const v of perSession.values()) if (v > maxS) maxS = v;
|
|
218
|
+
}
|
|
219
|
+
return { machine, busiest };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function pearson(xs, ys) {
|
|
223
|
+
const n = xs.length;
|
|
224
|
+
if (n < 3) return null;
|
|
225
|
+
const mx = xs.reduce((a, b) => a + b, 0) / n;
|
|
226
|
+
const my = ys.reduce((a, b) => a + b, 0) / n;
|
|
227
|
+
let cov = 0;
|
|
228
|
+
let vx = 0;
|
|
229
|
+
let vy = 0;
|
|
230
|
+
for (let i = 0; i < n; i++) {
|
|
231
|
+
cov += (xs[i] - mx) * (ys[i] - my);
|
|
232
|
+
vx += (xs[i] - mx) ** 2;
|
|
233
|
+
vy += (ys[i] - my) ** 2;
|
|
234
|
+
}
|
|
235
|
+
if (vx === 0 || vy === 0) return null;
|
|
236
|
+
return cov / Math.sqrt(vx * vy);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function readLoadSamples(ctx, sinceMs) {
|
|
240
|
+
let text = '';
|
|
241
|
+
try { text = fs.readFileSync(core.loadFile(ctx), 'utf8'); } catch (_) { return []; }
|
|
242
|
+
const out = [];
|
|
243
|
+
for (const line of text.split('\n')) {
|
|
244
|
+
if (!line.trim()) continue;
|
|
245
|
+
let r = null;
|
|
246
|
+
try { r = JSON.parse(line); } catch (_) { continue; }
|
|
247
|
+
if (r && Date.parse(r.ts) >= sinceMs) out.push(r);
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Correlation of disk read with the machine's live agent count, on samples that carry both. */
|
|
253
|
+
function ioCorrelation(samples) {
|
|
254
|
+
const rows = samples.filter((r) => Number.isFinite(r.live_machine) && Number.isFinite(r.disk_read_Bps));
|
|
255
|
+
const byLevel = new Map();
|
|
256
|
+
for (const r of rows) {
|
|
257
|
+
const g = byLevel.get(r.live_machine) || { n: 0, read: 0, write: 0, writeN: 0 };
|
|
258
|
+
g.n += 1;
|
|
259
|
+
g.read += r.disk_read_Bps;
|
|
260
|
+
if (Number.isFinite(r.disk_write_Bps)) { g.write += r.disk_write_Bps; g.writeN += 1; }
|
|
261
|
+
byLevel.set(r.live_machine, g);
|
|
262
|
+
}
|
|
263
|
+
const levels = [...byLevel.entries()].sort((a, b) => a[0] - b[0]).map(([level, g]) => ({
|
|
264
|
+
live_machine: level,
|
|
265
|
+
samples: g.n,
|
|
266
|
+
mean_read_Bps: Math.round(g.read / g.n),
|
|
267
|
+
mean_write_Bps: g.writeN ? Math.round(g.write / g.writeN) : null,
|
|
268
|
+
}));
|
|
269
|
+
return {
|
|
270
|
+
samples: rows.length,
|
|
271
|
+
r: pearson(rows.map((r) => r.live_machine), rows.map((r) => r.disk_read_Bps)),
|
|
272
|
+
levels,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function cmdLoadReport(opts) {
|
|
277
|
+
const ctx = args.ctxOf(opts);
|
|
278
|
+
const days = numArg(opts.days, 21, 'days', 0.01, 3650);
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const since = now - days * 86400000;
|
|
281
|
+
const events = core.readEvents(ctx).filter((e) => e && (e.kind === 'dispatch' || e.kind === 'complete')
|
|
282
|
+
&& Date.parse(e.ts) >= since);
|
|
283
|
+
const { intervals, unpaired } = pairIntervals(events, now);
|
|
284
|
+
const { machine, busiest } = levelTimes(intervals);
|
|
285
|
+
const toMin = (ms) => Math.round(ms / MIN_MS * 10) / 10;
|
|
286
|
+
const levels = new Map();
|
|
287
|
+
for (const k of new Set([...machine.keys(), ...busiest.keys()])) {
|
|
288
|
+
levels.set(k, { machine_min: toMin(machine.get(k) || 0), busiest_session_min: toMin(busiest.get(k) || 0) });
|
|
289
|
+
}
|
|
290
|
+
const atLeast = (m, n) => toMin([...m.entries()].filter(([k]) => k >= n).reduce((a, [, v]) => a + v, 0));
|
|
291
|
+
const samples = readLoadSamples(ctx, since);
|
|
292
|
+
const corr = ioCorrelation(samples);
|
|
293
|
+
const summary = {
|
|
294
|
+
days,
|
|
295
|
+
pairs: intervals.filter((i) => i.paired).length,
|
|
296
|
+
unpaired,
|
|
297
|
+
machine_min_at_least: { 7: atLeast(machine, 7), 9: atLeast(machine, 9) },
|
|
298
|
+
session_min_at_least: { 4: atLeast(busiest, 4) },
|
|
299
|
+
levels: [...levels.entries()].sort((a, b) => a[0] - b[0]).map(([level, v]) => Object.assign({ level }, v)),
|
|
300
|
+
load: { samples: samples.length, io_samples: corr.samples, r: corr.r === null ? null : Math.round(corr.r * 100) / 100, by_machine: corr.levels },
|
|
301
|
+
};
|
|
302
|
+
if (opts.json) return { code: 0, out: JSON.stringify(summary, null, 2) };
|
|
303
|
+
return { code: 0, out: formatReport(summary) };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function formatReport(s) {
|
|
307
|
+
const head = `dieu-phoi load report — ${s.days} ngày · ${s.pairs} agent có cả dispatch và complete · ${s.unpaired} dispatch chưa đóng (tính tối đa 120 phút)`;
|
|
308
|
+
const rows = ['Đồng thời | toàn máy (phút) | phiên lớn nhất (phút)', '---|---|---'];
|
|
309
|
+
for (const l of s.levels) rows.push(`${l.level} | ${l.machine_min} | ${l.busiest_session_min}`);
|
|
310
|
+
if (!s.levels.length) rows.push('(không có agent trong khoảng này)');
|
|
311
|
+
const mark = `Mốc: toàn máy ≥7 agent ${s.machine_min_at_least[7]} phút · toàn máy ≥9 ${s.machine_min_at_least[9]} phút · một phiên ≥4 ${s.session_min_at_least[4]} phút`;
|
|
312
|
+
const ld = s.load;
|
|
313
|
+
const corrLine = ld.io_samples < 3
|
|
314
|
+
? `Tải IO: ${ld.samples} mẫu trong data/load.jsonl, ${ld.io_samples} có IO đĩa — chưa đủ để tương quan (chạy \`dieu-phoi load sample\`).`
|
|
315
|
+
: `Tải IO: ${ld.io_samples} mẫu có IO · tương quan r(agent toàn máy, đọc đĩa) = ${ld.r === null ? 'n/a' : ld.r} (chỉ báo: IO là của cả máy, không riêng agent).`;
|
|
316
|
+
const lvl = ld.by_machine.map((g) => `${g.live_machine} | ${g.samples} | ${(g.mean_read_Bps / MB).toFixed(2)} | ${g.mean_write_Bps === null ? 'n/a' : (g.mean_write_Bps / MB).toFixed(2)}`);
|
|
317
|
+
const lvlTable = lvl.length ? ['Agent toàn máy | mẫu | đọc TB (MB/s) | ghi TB (MB/s)', '---|---|---|---', ...lvl] : [];
|
|
318
|
+
return [head, '', ...rows, '', mark, '', corrLine, ...lvlTable].join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function cmdLoad(opts) {
|
|
322
|
+
const sub = opts._[0];
|
|
323
|
+
if (sub === 'sample') return cmdLoadSample(opts);
|
|
324
|
+
if (sub === 'report') return cmdLoadReport(opts);
|
|
325
|
+
throw new Error('Cú pháp: dieu-phoi load sample [--interval 10] [--count 12] [--repo R] [--json]\n'
|
|
326
|
+
+ ' dieu-phoi load report [--days 21] [--json]');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
cmdLoad, cmdLoadSample, cmdLoadReport, parseTypeperf, parseDiskstats, diskRates, collectSample, sampleLoop,
|
|
331
|
+
pairIntervals, levelTimes, pearson, ioCorrelation, readLoadSamples, DISK_READ, DISK_WRITE, OPEN_CAP_MS,
|
|
332
|
+
};
|
package/lib/procs.cjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// s16 (core 0.7.2): read-only look at the process table. Everything here that decides something is a pure function over a
|
|
3
3
|
// plain list [{pid, ppid, name, created (epoch ms | null), ws (bytes | null), cmd}], so tests feed fixture lists and never
|
|
4
4
|
// touch the system. Only listProcesses() and killTree() reach the OS; listProcesses never throws (null on any failure),
|
|
5
|
-
// killTree
|
|
5
|
+
// killTree (kill_leftovers:true, the 0.7.2 behaviour) and killVerified (s7, default 'guarded') are the only kill paths.
|
|
6
6
|
|
|
7
7
|
const cp = require('child_process');
|
|
8
8
|
|
|
@@ -216,8 +216,207 @@ function killTree(pids, o) {
|
|
|
216
216
|
return done;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// ------------------------------------------------------------------ guarded kill (core 0.7.3 s7)
|
|
220
|
+
|
|
221
|
+
// Never killed by any dieu-phoi path, whatever the attribution says: Claude Code itself, the shared code graph, console
|
|
222
|
+
// hosts, Docker / WSL / Hyper-V, Unity, the user's apps (Orca, PixChat, editors, terminals, Explorer) and Windows itself.
|
|
223
|
+
const NEVER_KILL_RE = [
|
|
224
|
+
/^claude(\.exe)?$/, /^codebase-memory-mcp/, /^conhost(\.exe)?$/, /^openconsole(\.exe)?$/, /^windowsterminal(\.exe)?$/,
|
|
225
|
+
/^com\.docker\./, /^docker/, /^wsl/, /^vmmem/, /^vmwp(\.exe)?$/, /^vmcompute(\.exe)?$/, /^unity/,
|
|
226
|
+
/^orca/, /^pixchat/, /^explorer(\.exe)?$/, /^code(\.exe)?$/, /^cursor(\.exe)?$/, /^devenv(\.exe)?$/, /^rider/,
|
|
227
|
+
/^(system|idle|registry|smss|csrss|wininit|winlogon|services|lsass|svchost|dwm|sihost|taskhostw|fontdrvhost|ctfmon|runtimebroker|searchhost|shellexperiencehost|startmenuexperiencehost|memory compression)(\.exe)?$/,
|
|
228
|
+
];
|
|
229
|
+
const isNeverKill = (p) => NEVER_KILL_RE.some((re) => re.test(lower(p && p.name)));
|
|
230
|
+
// What Claude Code's Bash / PowerShell tools start under claude.exe. Other direct children of claude.exe are MCP servers
|
|
231
|
+
// (node, npx, cmd /c, uvx, python...): the session's own, never an agent's leftover.
|
|
232
|
+
const AGENT_SHELL_RE = /^(bash|sh|zsh|dash|powershell|pwsh)(\.exe)?$/;
|
|
233
|
+
// Orphans `reap --orphans` may clean: keep-alive / watcher commands, and Claude Code shells (the snapshot path is in their cmd).
|
|
234
|
+
const ORPHAN_KILL_NAMES = ['tail.exe', 'tail', 'sleep.exe', 'sleep'];
|
|
235
|
+
const isClaudeShell = (p) => AGENT_SHELL_RE.test(lower(p.name)) && /shell-snapshots/i.test(p.cmd || '');
|
|
236
|
+
const normPath = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `kill_leftovers` mode: env DIEU_PHOI_KILL_LEFTOVERS → config → 'guarded'. true/'true'/'1' = 'tree' (0.7.2: kill the tree
|
|
240
|
+
* roots with taskkill /T), false/'false'/'0'/'off' = 'off', anything else = 'guarded'.
|
|
241
|
+
*/
|
|
242
|
+
function killMode(config, env) {
|
|
243
|
+
const e = (env || process.env).DIEU_PHOI_KILL_LEFTOVERS;
|
|
244
|
+
for (const v of [e, config && config.kill_leftovers]) {
|
|
245
|
+
if (v === undefined || v === null || v === '') continue;
|
|
246
|
+
if (v === true || /^(true|1|yes|tree)$/i.test(String(v))) return 'tree';
|
|
247
|
+
if (v === false || /^(false|0|no|off)$/i.test(String(v))) return 'off';
|
|
248
|
+
return 'guarded';
|
|
249
|
+
}
|
|
250
|
+
return 'guarded';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The value written as `kill_leftovers` in events: true / false / 'guarded' (what the user would put in config). */
|
|
254
|
+
const killModeValue = (mode) => (mode === 'tree' ? true : mode === 'off' ? false : 'guarded');
|
|
255
|
+
|
|
256
|
+
/** Has p a claude.exe below it (another session / a nested Claude Code)? Killing p would take that session down. */
|
|
257
|
+
function hostsClaude(list, pid) {
|
|
258
|
+
return descendants(list, pid).some((c) => /^claude(\.exe)?$/.test(lower(c.name)));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Guarded selection over leftover candidates (descendants of the session's claude.exe created since the agent started, see
|
|
263
|
+
* leftoverProcesses). Keeps only processes attributable to that agent:
|
|
264
|
+
* - not on the never-kill list (nor any ancestor below the root), no claude.exe below it;
|
|
265
|
+
* - reached through an agent shell (bash / powershell) that claude.exe started, not through an MCP server;
|
|
266
|
+
* - its command line (or an ancestor's, up to the root) is inside the agent's own worktree (`worktree`; the caller passes
|
|
267
|
+
* it only when no other agent shares it), OR `exclusive` (no other dp agent of this home ran meanwhile).
|
|
268
|
+
* Returns { kill: [p], skip: [{pid, name, reason}] }. Pure.
|
|
269
|
+
*/
|
|
270
|
+
function guardedSelect(list, cands, o) {
|
|
271
|
+
const opt = o || {};
|
|
272
|
+
const idx = byPid(list);
|
|
273
|
+
const wt = opt.worktree ? normPath(opt.worktree) : '';
|
|
274
|
+
const kill = [];
|
|
275
|
+
const skip = [];
|
|
276
|
+
for (const p of cands || []) {
|
|
277
|
+
let reason = null;
|
|
278
|
+
if (isNeverKill(p)) reason = 'never-kill';
|
|
279
|
+
else if (hostsClaude(list, p.pid)) reason = 'hosts-claude';
|
|
280
|
+
else {
|
|
281
|
+
const chain = [p]; // p up to (not including) the root
|
|
282
|
+
let cur = p;
|
|
283
|
+
for (let i = 0; i < 64 && cur && cur.ppid !== opt.rootPid; i++) { cur = idx.get(cur.ppid); if (cur) chain.push(cur); }
|
|
284
|
+
const top = chain[chain.length - 1];
|
|
285
|
+
if (!cur || !opt.rootPid || top.ppid !== opt.rootPid) reason = 'not-under-session';
|
|
286
|
+
else if (!AGENT_SHELL_RE.test(lower(top.name))) reason = 'not-via-agent-shell';
|
|
287
|
+
else if (chain.some(isNeverKill)) reason = 'never-kill-ancestor';
|
|
288
|
+
else if (!opt.exclusive && !(wt && chain.some((c) => normPath(c.cmd).includes(wt)))) reason = 'not-attributable';
|
|
289
|
+
}
|
|
290
|
+
if (reason) skip.push({ pid: p.pid, name: p.name, reason });
|
|
291
|
+
else kill.push(p);
|
|
292
|
+
}
|
|
293
|
+
return { kill, skip };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Orphans `reap --orphans` may kill: tail / sleep (any cmd) and Claude Code shells (cmd has shell-snapshots) whose parent
|
|
298
|
+
* is gone (or whose parent pid was reused later), older than minAgeMs, not never-kill, no claude.exe below. Oldest first.
|
|
299
|
+
*/
|
|
300
|
+
function killableOrphans(list, o) {
|
|
301
|
+
const opt = o || {};
|
|
302
|
+
const now = typeof opt.now === 'number' ? opt.now : Date.now();
|
|
303
|
+
const names = new Set(ORPHAN_KILL_NAMES);
|
|
304
|
+
const idx = byPid(list);
|
|
305
|
+
return (list || []).filter((p) => (names.has(lower(p.name)) || isClaudeShell(p)) && isOrphan(p, idx)
|
|
306
|
+
&& p.created && now - p.created >= (opt.minAgeMs || 0) && !isNeverKill(p) && !hostsClaude(list, p.pid))
|
|
307
|
+
.sort((a, b) => a.created - b.created);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Git-bash (MSYS2) trap: when bash runs its last command it execs it, and on Windows that means a NEW process whose
|
|
311
|
+
// Windows parent (the bash.exe stub) exits at once. A live `bash -c "tail -f x"` / Monitor therefore shows a dead PPID from
|
|
312
|
+
// the first second. Only the MSYS process table (`ps -al`: PID PPID PGID WINPID ...) keeps the logical parent, so on
|
|
313
|
+
// Windows an orphan counts only if MSYS also says so: MSYS parent 1 or no longer in the table.
|
|
314
|
+
const MSYS_PS_CANDIDATES = ['C:\\Program Files\\Git\\usr\\bin\\ps.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\ps.exe', 'C:\\msys64\\usr\\bin\\ps.exe'];
|
|
315
|
+
|
|
316
|
+
/** `ps -al` of MSYS -> { byWin: Map(winpid -> {pid, ppid}), pids: Set(msys pid) }, or null when nothing parses. */
|
|
317
|
+
function parseMsysPs(text) {
|
|
318
|
+
const byWin = new Map();
|
|
319
|
+
const pids = new Set();
|
|
320
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
321
|
+
const m = /^[ A-Z]\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s/.exec(line);
|
|
322
|
+
if (!m) continue;
|
|
323
|
+
const pid = Number(m[1]);
|
|
324
|
+
pids.add(pid);
|
|
325
|
+
byWin.set(Number(m[4]), { pid, ppid: Number(m[2]) });
|
|
326
|
+
}
|
|
327
|
+
return pids.size ? { byWin, pids } : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* MSYS process table of this machine, or null (not Windows, no Git-bash ps.exe, error). `hints` = command lines of the
|
|
332
|
+
* candidates (the ps.exe next to their tail.exe is the right runtime); `exec(file, args, {timeout})` injectable. Never throws.
|
|
333
|
+
*/
|
|
334
|
+
function msysTable(o) {
|
|
335
|
+
const opt = o || {};
|
|
336
|
+
const exec = typeof opt.exec === 'function' ? opt.exec : defaultExec;
|
|
337
|
+
if (process.env.DIEU_PHOI_NO_PROCS === '1' && typeof opt.exec !== 'function') return null;
|
|
338
|
+
const dirs = [];
|
|
339
|
+
for (const c of opt.hints || []) {
|
|
340
|
+
const m = /^"?([A-Za-z]:\\[^"]*?\\usr\\bin)\\[^\\"]+\.exe/i.exec(String(c || ''));
|
|
341
|
+
if (m && !dirs.includes(m[1])) dirs.push(m[1]);
|
|
342
|
+
}
|
|
343
|
+
const files = dirs.map((d) => `${d}\\ps.exe`).concat(MSYS_PS_CANDIDATES, ['ps']);
|
|
344
|
+
for (const f of files) {
|
|
345
|
+
try {
|
|
346
|
+
const out = exec(f, ['-al'], { timeout: opt.timeoutMs || DEFAULT_TIMEOUT_MS });
|
|
347
|
+
const t = typeof out === 'string' ? parseMsysPs(out) : null;
|
|
348
|
+
if (t) return t;
|
|
349
|
+
} catch (_) { /* next */ }
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Orphan candidates that the MSYS table confirms (MSYS parent 1 or gone). msys null = unreadable: nothing is confirmed.
|
|
356
|
+
* Returns { keep: [p], skip: [{pid, name, reason: 'msys-parent-alive'|'msys-unknown'|'msys-unverifiable'}] }. Pure.
|
|
357
|
+
*/
|
|
358
|
+
function msysConfirm(cands, msys) {
|
|
359
|
+
const keep = [];
|
|
360
|
+
const skip = [];
|
|
361
|
+
for (const p of cands || []) {
|
|
362
|
+
const m = msys && msys.byWin.get(p.pid);
|
|
363
|
+
if (!msys) skip.push({ pid: p.pid, name: p.name, reason: 'msys-unverifiable' });
|
|
364
|
+
else if (!m) skip.push({ pid: p.pid, name: p.name, reason: 'msys-unknown' });
|
|
365
|
+
else if (m.ppid !== 1 && msys.pids.has(m.ppid)) skip.push({ pid: p.pid, name: p.name, reason: 'msys-parent-alive' });
|
|
366
|
+
else keep.push(p);
|
|
367
|
+
}
|
|
368
|
+
return { keep, skip };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Kill single processes (no /T: what is below stays unless it is a target too). Returns the pids stopped; never throws. */
|
|
372
|
+
function killPids(pids, o) {
|
|
373
|
+
const opt = o || {};
|
|
374
|
+
const done = [];
|
|
375
|
+
for (const pid of pids || []) {
|
|
376
|
+
const left = opt.deadline ? opt.deadline - Date.now() : 3000;
|
|
377
|
+
if (left <= 0) break;
|
|
378
|
+
try {
|
|
379
|
+
if ((opt.platform || process.platform) === 'win32') {
|
|
380
|
+
const r = cp.spawnSync('taskkill', ['/PID', String(pid), '/F'], { timeout: Math.min(3000, left), windowsHide: true });
|
|
381
|
+
if (!r.error && r.status === 0) done.push(pid);
|
|
382
|
+
} else {
|
|
383
|
+
process.kill(pid, 'SIGTERM');
|
|
384
|
+
done.push(pid);
|
|
385
|
+
}
|
|
386
|
+
} catch (_) { /* gone already or not ours */ }
|
|
387
|
+
}
|
|
388
|
+
return done;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Re-read the process table right before killing; kill only targets still there with the same creation time and name
|
|
393
|
+
* (a pid reused by an unrelated process is never killed). `relist()` -> list|null, `kill(pids)` -> pids (default killPids).
|
|
394
|
+
* Returns { killed: [pid], skipped: [{pid, name, reason: 'gone'|'pid-reused'|'no-created'|'unverifiable'}] }. Never throws.
|
|
395
|
+
*/
|
|
396
|
+
function killVerified(targets, o) {
|
|
397
|
+
const opt = o || {};
|
|
398
|
+
let fresh = null;
|
|
399
|
+
try { fresh = typeof opt.relist === 'function' ? opt.relist() : null; } catch (_) { fresh = null; }
|
|
400
|
+
if (!Array.isArray(fresh)) return { killed: [], skipped: (targets || []).map((t) => ({ pid: t.pid, name: t.name, reason: 'unverifiable' })) };
|
|
401
|
+
const idx = byPid(fresh);
|
|
402
|
+
const ok = [];
|
|
403
|
+
const skipped = [];
|
|
404
|
+
for (const t of targets || []) {
|
|
405
|
+
const cur = idx.get(t.pid);
|
|
406
|
+
if (!t.created) skipped.push({ pid: t.pid, name: t.name, reason: 'no-created' });
|
|
407
|
+
else if (!cur) skipped.push({ pid: t.pid, name: t.name, reason: 'gone' });
|
|
408
|
+
else if (cur.created !== t.created || lower(cur.name) !== lower(t.name)) skipped.push({ pid: t.pid, name: t.name, reason: 'pid-reused' });
|
|
409
|
+
else ok.push(t.pid);
|
|
410
|
+
}
|
|
411
|
+
let killed = [];
|
|
412
|
+
try { killed = ok.length ? ((typeof opt.kill === 'function' ? opt.kill(ok) : killPids(ok, { deadline: opt.deadline })) || []) : []; } catch (_) { killed = []; }
|
|
413
|
+
return { killed, skipped };
|
|
414
|
+
}
|
|
415
|
+
|
|
219
416
|
module.exports = {
|
|
220
417
|
PS_WIN, ORPHAN_NAMES, LEFTOVER_SKIP_NAMES,
|
|
221
418
|
parseWindowsJson, parsePs, listProcesses,
|
|
222
419
|
descendants, ancestors, claudeAncestor, orphans, staleOrphans, isCbmDaemon, leftoverProcesses, killTree,
|
|
420
|
+
NEVER_KILL_RE, ORPHAN_KILL_NAMES, AGENT_SHELL_RE, isNeverKill, isClaudeShell, isOrphan, killMode, killModeValue, hostsClaude,
|
|
421
|
+
guardedSelect, killableOrphans, killPids, killVerified, MSYS_PS_CANDIDATES, parseMsysPs, msysTable, msysConfirm,
|
|
223
422
|
};
|
package/lib/prune.cjs
CHANGED
|
@@ -321,6 +321,8 @@ function cmdPrune(opts) {
|
|
|
321
321
|
bytes: rp.prune.reduce((n, r) => n + r.delete_bytes, 0) + plans.reduce((n, p) => n + p.bytes, 0),
|
|
322
322
|
};
|
|
323
323
|
|
|
324
|
+
let removedLines = 0; // lines actually dropped from events.jsonl under the lock (what the message reports)
|
|
325
|
+
let freshCount = 0; // of those, lines newly written into the archive (the rest were there from an earlier run)
|
|
324
326
|
if (yes) {
|
|
325
327
|
// 0. a session other than this one touched the log recently => refuse (a live hook could be mid-append)
|
|
326
328
|
const busy = opts.force === true ? null : otherSessionActive(ctx, opts, allEvents);
|
|
@@ -335,6 +337,7 @@ function cmdPrune(opts) {
|
|
|
335
337
|
// 1. events → archive (archive first + verified, then rewrite events.jsonl via temp + rename)
|
|
336
338
|
if (moving.length) {
|
|
337
339
|
const fresh = moving.filter((l) => !archived.has(l.text));
|
|
340
|
+
freshCount = fresh.length;
|
|
338
341
|
const lock = core.acquireLock(ctx.paths.lock);
|
|
339
342
|
if (!lock) return { code: 1, out: 'prune: sync đang giữ khoá — thử lại sau' };
|
|
340
343
|
try {
|
|
@@ -351,6 +354,7 @@ function cmdPrune(opts) {
|
|
|
351
354
|
for (const l of core.eventLinesFrom(buf, 0)) {
|
|
352
355
|
parts.push(buf.subarray(pos, l.start)); // blank lines between
|
|
353
356
|
if (drop.get(l.start) === l.text) {
|
|
357
|
+
removedLines++;
|
|
354
358
|
if (l.end <= off) removedBeforeOffset += l.end - l.start;
|
|
355
359
|
} else parts.push(buf.subarray(l.start, l.end));
|
|
356
360
|
pos = l.end;
|
|
@@ -367,6 +371,8 @@ function cmdPrune(opts) {
|
|
|
367
371
|
try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
|
|
368
372
|
}
|
|
369
373
|
}
|
|
374
|
+
report.events.moved = removedLines;
|
|
375
|
+
report.events.archived_new = freshCount;
|
|
370
376
|
// 2a. unmerged deltas older than 30 d without a user verdict -> runs/_archive/deltas/<run>/ (moved, not deleted)
|
|
371
377
|
for (const r of rp.prune) {
|
|
372
378
|
for (const n of r.archive) {
|
|
@@ -398,7 +404,10 @@ function cmdPrune(opts) {
|
|
|
398
404
|
L.push(`Run dir: ${rp.prune.length} sẽ dọn, ${rp.skipped.length} giữ nguyên (còn mở / sau mốc)`);
|
|
399
405
|
for (const r of rp.prune) L.push(` ${r.id}: xoá ${r.del.length} mục (${human(r.delete_bytes)}), giữ ${r.keep.length} (${human(r.keep_bytes)}), trong đó delta chưa gộp ${r.keep_deltas}${r.archive.length ? `; chuyển ${r.archive.length} delta >30 ngày chưa có verdict sang _archive/deltas/` : ''}`);
|
|
400
406
|
L.push(`Plan hỏng: ${plans.length} (${human(plans.reduce((n, p) => n + p.bytes, 0))})${plans.length ? ` — ${plans.map((p) => p.name).join(', ')}` : ''}`);
|
|
401
|
-
|
|
407
|
+
const evTxt = yes
|
|
408
|
+
? `đã chuyển ${removedLines}/${lines.length} dòng vào archive và xoá khỏi events.jsonl (${freshCount} dòng ghi mới${freshCount ? ` vào archive/events-${archiveToday()}.jsonl.gz` : ''}; ${human(evBytes)})`
|
|
409
|
+
: `sẽ chuyển ${moving.length}/${lines.length} dòng vào archive/events-${archiveToday()}.jsonl.gz và xoá khỏi events.jsonl (${human(evBytes)})`;
|
|
410
|
+
L.push(`events.jsonl: ${evTxt}${heldBack ? `; ${heldBack} dòng chưa đẩy lên sink nên giữ lại` : ''}`);
|
|
402
411
|
L.push(`Tổng xoá khỏi đĩa: ${report.totals.files} mục run dir + ${plans.length} plan = ${human(report.totals.bytes)}`);
|
|
403
412
|
return { code: 0, out: L.join('\n') };
|
|
404
413
|
}
|