@kienntpixon/dieu-phoi 0.7.1 → 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 +39 -0
- package/README.md +3 -0
- package/agents/dp-exec.md +2 -0
- package/agents/dp-helper.md +2 -0
- package/agents/dp-scout.md +2 -0
- package/agents/dp-verify.md +2 -0
- package/bin/dieu-phoi.cjs +30 -2
- package/hook.cjs +1218 -53
- package/lib/aggregate.cjs +5 -4
- package/lib/args.cjs +1 -1
- package/lib/cbm.cjs +8 -2
- package/lib/commands.cjs +417 -36
- package/lib/deps.cjs +162 -0
- package/lib/knowledge.cjs +3 -4
- package/lib/load.cjs +332 -0
- package/lib/procs.cjs +422 -0
- package/lib/profile-scan.cjs +85 -0
- package/lib/profile-seed.cjs +147 -0
- package/lib/profile.cjs +141 -1
- package/lib/prune.cjs +66 -9
- package/lib/setup.cjs +59 -12
- package/lib/worktree.cjs +417 -0
- package/package.json +3 -1
- package/profile/lessons.md +85 -0
- package/profile/unity-lanes.md +316 -0
package/lib/deps.cjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// core 0.7.3 (s3): node_modules dùng chung cho worktree của dieu-phoi. OPT-IN: mặc định `none`.
|
|
3
|
+
// - `--deps auto|link|install|none` (hoặc env DIEU_PHOI_WT_DEPS → config.json `wt_deps` → none).
|
|
4
|
+
// - install (và auto khi có pnpm-lock.yaml): `pnpm install --frozen-lockfile --offline` (store hardlink, không tải mạng).
|
|
5
|
+
// - link (và auto khi không có pnpm-lock.yaml): junction <worktree>/<deps_path>/node_modules → node_modules của checkout
|
|
6
|
+
// chính, CHỈ khi package-lock.json giống hệt từng byte, package.json không có `workspaces`, và checkout chính có
|
|
7
|
+
// node_modules là thư mục thật. Còn lại → none + gợi ý. Không bao giờ ghi vào node_modules của checkout chính.
|
|
8
|
+
// - Gỡ an toàn: unlinkDepLinks gỡ junction (không đi xuyên) TRƯỚC `git worktree remove`; removeShell xoá vỏ còn sót
|
|
9
|
+
// bằng cách duyệt lstat, link nào cũng chỉ unlink, không bao giờ đi vào đích.
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const cp = require('child_process');
|
|
13
|
+
|
|
14
|
+
const MODES = ['auto', 'link', 'install', 'none'];
|
|
15
|
+
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function isLink(p) {
|
|
18
|
+
try { return fs.lstatSync(p).isSymbolicLink(); } catch (_) { return false; }
|
|
19
|
+
}
|
|
20
|
+
function isRealDir(p) {
|
|
21
|
+
try { const st = fs.lstatSync(p); return st.isDirectory() && !st.isSymbolicLink(); } catch (_) { return false; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** deps_path tương đối, nằm trong worktree ('.' = gốc). Ném nếu tuyệt đối hoặc thoát ra ngoài. */
|
|
25
|
+
function normDepsPath(p) {
|
|
26
|
+
if (p === undefined || p === null || p === true || p === '') return '.';
|
|
27
|
+
if (typeof p !== 'string' || path.isAbsolute(p)) throw new Error('--deps-path cần đường dẫn tương đối trong repo (vd. cli)');
|
|
28
|
+
const n = path.normalize(p).replace(/\\/g, '/').replace(/\/+$/, '') || '.';
|
|
29
|
+
if (n === '..' || n.startsWith('../')) throw new Error('--deps-path không được ra ngoài repo');
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Chế độ: --deps → env DIEU_PHOI_WT_DEPS → config `wt_deps` → none. Giá trị hỏng từ cờ thì ném; từ env/config thì none. */
|
|
34
|
+
function depsModeOf(opts, ctx) {
|
|
35
|
+
if (opts && opts.deps !== undefined) {
|
|
36
|
+
if (typeof opts.deps !== 'string' || !MODES.includes(opts.deps)) throw new Error(`--deps cần một trong: ${MODES.join('|')}`);
|
|
37
|
+
return opts.deps;
|
|
38
|
+
}
|
|
39
|
+
const env = process.env.DIEU_PHOI_WT_DEPS;
|
|
40
|
+
const raw = env !== undefined && env !== '' ? env : (ctx && ctx.config ? ctx.config.wt_deps : undefined);
|
|
41
|
+
return typeof raw === 'string' && MODES.includes(raw) ? raw : 'none';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readFileOrNull(p) {
|
|
45
|
+
try { return fs.readFileSync(p); } catch (_) { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Lý do không được junction (null = được). */
|
|
49
|
+
function linkBlocker(mainDir, wtDir) {
|
|
50
|
+
const wtLock = readFileOrNull(path.join(wtDir, 'package-lock.json'));
|
|
51
|
+
if (!wtLock) return 'worktree không có package-lock.json';
|
|
52
|
+
const mainLock = readFileOrNull(path.join(mainDir, 'package-lock.json'));
|
|
53
|
+
if (!mainLock) return 'checkout chính không có package-lock.json';
|
|
54
|
+
// Giống hệt từng byte, chỉ bỏ qua khác biệt CRLF/LF (core.autocrlf trên Windows checkout worktree ra CRLF trong khi
|
|
55
|
+
// npm ghi LF vào checkout chính; nội dung lock vẫn là một).
|
|
56
|
+
const lf = (b) => b.toString('utf8').replace(/\r\n/g, '\n');
|
|
57
|
+
if (!wtLock.equals(mainLock) && lf(wtLock) !== lf(mainLock)) return 'package-lock.json khác checkout chính';
|
|
58
|
+
let pkg;
|
|
59
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(wtDir, 'package.json'), 'utf8')); } catch (_) { return 'không đọc được package.json'; }
|
|
60
|
+
if (pkg && pkg.workspaces) return 'package.json có workspaces';
|
|
61
|
+
if (!isRealDir(path.join(mainDir, 'node_modules'))) return 'checkout chính chưa có node_modules (thư mục thật)';
|
|
62
|
+
if (fs.existsSync(path.join(wtDir, 'node_modules')) || isLink(path.join(wtDir, 'node_modules'))) return 'worktree đã có node_modules';
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Lệnh pnpm: env DIEU_PHOI_PNPM_BIN (đường dẫn) hoặc `pnpm` trên PATH. .cmd/.bat (và `pnpm` trên Windows) cần shell. */
|
|
67
|
+
function pnpmSpawn(args, cwd, timeout) {
|
|
68
|
+
const bin = process.env.DIEU_PHOI_PNPM_BIN || 'pnpm';
|
|
69
|
+
const shell = process.platform === 'win32' && (!process.env.DIEU_PHOI_PNPM_BIN || /\.(cmd|bat)$/i.test(bin));
|
|
70
|
+
const cmd = shell && /\s/.test(bin) ? `"${bin}"` : bin;
|
|
71
|
+
return cp.spawnSync(cmd, args, { cwd, encoding: 'utf8', windowsHide: true, timeout, shell });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pnpmInstall(wtDir) {
|
|
75
|
+
const v = pnpmSpawn(['--version'], wtDir, 30000);
|
|
76
|
+
if (v.error || v.status !== 0) return { deps: 'none', note: 'deps none: repo dùng pnpm nhưng không chạy được pnpm (vắng trên PATH?) — cài pnpm rồi chạy `pnpm install --offline` trong worktree' };
|
|
77
|
+
const r = pnpmSpawn(['install', '--frozen-lockfile', '--offline'], wtDir, INSTALL_TIMEOUT_MS);
|
|
78
|
+
if (r.error || r.status !== 0) {
|
|
79
|
+
const tail = String((r.stderr || '') + (r.stdout || '')).trim().split(/\r?\n/).slice(-3).join(' | ');
|
|
80
|
+
return { deps: 'none', note: `deps none: pnpm install --frozen-lockfile --offline lỗi (${r.error ? r.error.message : `exit ${r.status}`}${tail ? `: ${tail}` : ''}) — store thiếu gói? chạy pnpm install có mạng trong worktree` };
|
|
81
|
+
}
|
|
82
|
+
return { deps: 'pnpm', note: 'deps pnpm: đã chạy pnpm install --frozen-lockfile --offline (hardlink từ store)' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Chuẩn bị node_modules cho worktree vừa tạo. Không ném: lỗi nào cũng thành deps none + ghi chú.
|
|
87
|
+
* Trả { deps: 'link'|'pnpm'|'none', note|null }.
|
|
88
|
+
*/
|
|
89
|
+
function setupDeps({ main, wtPath, mode, depsPath }) {
|
|
90
|
+
if (mode === 'none') return { deps: 'none', note: null };
|
|
91
|
+
const rel = depsPath || '.';
|
|
92
|
+
const mainDir = path.join(main, rel);
|
|
93
|
+
const wtDir = path.join(wtPath, rel);
|
|
94
|
+
if (!fs.existsSync(path.join(wtDir, 'package.json'))) return { deps: 'none', note: `deps none: không có package.json ở ${rel} (dùng --deps-path <thư mục con>)` };
|
|
95
|
+
const pnpm = fs.existsSync(path.join(wtDir, 'pnpm-lock.yaml'));
|
|
96
|
+
if (pnpm && (mode === 'auto' || mode === 'install')) return pnpmInstall(wtDir);
|
|
97
|
+
if (pnpm) return { deps: 'none', note: 'deps none: repo dùng pnpm — dùng --deps install (hoặc auto) thay cho link' };
|
|
98
|
+
if (mode === 'install') return { deps: 'none', note: 'deps none: --deps install chỉ hỗ trợ pnpm (không có pnpm-lock.yaml); repo npm dùng --deps link hoặc auto' };
|
|
99
|
+
const why = linkBlocker(mainDir, wtDir);
|
|
100
|
+
if (why) return { deps: 'none', note: `deps none: không junction được (${why}) — chạy npm ci trong worktree nếu cần` };
|
|
101
|
+
try {
|
|
102
|
+
fs.symlinkSync(path.join(mainDir, 'node_modules'), path.join(wtDir, 'node_modules'), 'junction');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { deps: 'none', note: `deps none: tạo junction lỗi (${e.message})` };
|
|
105
|
+
}
|
|
106
|
+
return { deps: 'link', note: `deps link: ${path.join(wtDir, 'node_modules')} → ${path.join(mainDir, 'node_modules')} (junction, dùng chung, không ghi vào)` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Bỏ một link (symlink/junction) mà không đi xuyên. Ném nếu p không phải link. */
|
|
110
|
+
function unlinkOnly(p) {
|
|
111
|
+
if (!isLink(p)) throw new Error(`không phải link: ${p}`);
|
|
112
|
+
try { fs.unlinkSync(p); } catch (e) {
|
|
113
|
+
// Windows: junction/symlink thư mục đôi khi cần rmdir (RemoveDirectory chỉ bỏ reparse point, không chạm đích).
|
|
114
|
+
if (process.platform === 'win32') fs.rmdirSync(p); else throw e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Gỡ mọi link node_modules của worktree (ở gốc và ở các deps_path) TRƯỚC `git worktree remove`.
|
|
120
|
+
* Trả [{ path, target }] để khôi phục nếu gỡ worktree thất bại. Ném nếu không gỡ được (khi đó KHÔNG được gỡ worktree).
|
|
121
|
+
*/
|
|
122
|
+
function unlinkDepLinks(wtPath, depsPaths) {
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const rel of ['.', ...(depsPaths || [])]) {
|
|
126
|
+
const p = path.join(wtPath, rel || '.', 'node_modules');
|
|
127
|
+
const key = path.resolve(p);
|
|
128
|
+
if (seen.has(key)) continue;
|
|
129
|
+
seen.add(key);
|
|
130
|
+
if (!isLink(p)) continue;
|
|
131
|
+
let target = null;
|
|
132
|
+
try { target = fs.readlinkSync(p); } catch (_) { /* vẫn gỡ */ }
|
|
133
|
+
unlinkOnly(p);
|
|
134
|
+
if (isLink(p) || fs.existsSync(p)) throw new Error(`gỡ link ${p} không xong`);
|
|
135
|
+
out.push({ path: p, target });
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Khôi phục link đã gỡ (best effort) khi `git worktree remove` thất bại. */
|
|
141
|
+
function relink(links) {
|
|
142
|
+
for (const l of links || []) {
|
|
143
|
+
if (!l.target || fs.existsSync(l.path) || isLink(l.path)) continue;
|
|
144
|
+
try { fs.symlinkSync(l.target, l.path, 'junction'); } catch (_) { /* best effort */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Xoá vỏ thư mục worktree còn sót sau `git worktree remove` (git để lại junction/thư mục rỗng).
|
|
150
|
+
* Duyệt bằng lstat: link → chỉ unlink (không vào đích), file → xoá, thư mục thật → đệ quy rồi rmdir.
|
|
151
|
+
* Không bao giờ dùng rmSync recursive trên cây có thể chứa link.
|
|
152
|
+
*/
|
|
153
|
+
function removeShell(dir) {
|
|
154
|
+
if (isLink(dir)) { unlinkOnly(dir); return; }
|
|
155
|
+
let st;
|
|
156
|
+
try { st = fs.lstatSync(dir); } catch (_) { return; }
|
|
157
|
+
if (!st.isDirectory()) { fs.unlinkSync(dir); return; }
|
|
158
|
+
for (const name of fs.readdirSync(dir)) removeShell(path.join(dir, name));
|
|
159
|
+
fs.rmdirSync(dir);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { MODES, depsModeOf, normDepsPath, setupDeps, unlinkDepLinks, relink, removeShell, isLink };
|
package/lib/knowledge.cjs
CHANGED
|
@@ -574,10 +574,9 @@ function runDirOf(v) {
|
|
|
574
574
|
return isDir(p) ? { dir: p } : { error: `Không thấy run ${s} (${slash(p)}).` };
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
-
/** Task id a delta file belongs to: knowledge-delta-<task_id>[-r<n>].md (8 chars a-z0-9); null = untagged. */
|
|
577
|
+
/** Task id a delta file belongs to: knowledge-delta-<task_id>[-r<n>|-h<n>].md (8 chars a-z0-9; -h<n> = handoff); null = untagged. */
|
|
578
578
|
function deltaTaskOf(file) {
|
|
579
|
-
|
|
580
|
-
return m ? m[1].toLowerCase() : null;
|
|
579
|
+
return core.deltaTagOf(file);
|
|
581
580
|
}
|
|
582
581
|
|
|
583
582
|
/**
|
|
@@ -585,7 +584,7 @@ function deltaTaskOf(file) {
|
|
|
585
584
|
* tagged with one of them (plus untagged ones when untagged is true).
|
|
586
585
|
*/
|
|
587
586
|
function deltaFiles(runDir, filter) {
|
|
588
|
-
const num = (f) => { const m = /-
|
|
587
|
+
const num = (f) => { const m = /-[rh](\d+)\.md$/i.exec(f); return m ? Number(m[1]) : 0; };
|
|
589
588
|
return fs.readdirSync(runDir)
|
|
590
589
|
.filter((f) => /^knowledge-delta.*\.md$/i.test(f))
|
|
591
590
|
.filter((f) => {
|
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
|
+
};
|