@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/args.cjs
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const core = require('../hook.cjs');
|
|
6
6
|
|
|
7
|
-
const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve', 'yes', 'force', 'follow', 'remove', 'dirty', 'no-cache', 'untagged', 'no-knowledge', 'net', 'with-codebase-memory', 'cbm', 'allow-custom-source', 'auto', 'include-local']);
|
|
7
|
+
const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve', 'yes', 'force', 'follow', 'remove', 'dirty', 'no-cache', 'untagged', 'no-knowledge', 'net', 'with-codebase-memory', 'cbm', 'allow-custom-source', 'auto', 'include-local', 'orphans']);
|
|
8
8
|
|
|
9
9
|
/** Minimal argv parser: positionals, --k v, --k=v, boolean flags. */
|
|
10
10
|
function parseArgs(argv) {
|
package/lib/cbm.cjs
CHANGED
|
@@ -117,9 +117,15 @@ function binArgv(bin, args) {
|
|
|
117
117
|
return /\.c?js$/i.test(bin) ? [process.execPath, [bin, ...args]] : [bin, args];
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** Timeout (ms) of a cbm sub-process; env override DIEU_PHOI_CBM_<name>_TIMEOUT_MS (tests under heavy load), default unchanged. */
|
|
121
|
+
function cbmTimeout(name, dflt) {
|
|
122
|
+
const n = Number(process.env[`DIEU_PHOI_CBM_${name}_TIMEOUT_MS`]);
|
|
123
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
124
|
+
}
|
|
125
|
+
|
|
120
126
|
function probeVersion(bin, timeoutMs) {
|
|
121
127
|
const [cmd, args] = binArgv(bin, ['--version']);
|
|
122
|
-
const r = cp.spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs || 3000, windowsHide: true });
|
|
128
|
+
const r = cp.spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs || cbmTimeout('PROBE', 3000), windowsHide: true });
|
|
123
129
|
if (r.error || r.status !== 0) return null;
|
|
124
130
|
const m = /(\d+\.\d+\.\d+)/.exec(`${r.stdout || ''}`);
|
|
125
131
|
return m ? m[1] : null;
|
|
@@ -275,7 +281,7 @@ function doctorChecks({ claudeDir, repo, net, deep, cacheFile, env, now }) {
|
|
|
275
281
|
const version = probeVersion(found.path);
|
|
276
282
|
const wantDaemon = !!(deep || net);
|
|
277
283
|
const cached = wantDaemon ? null : readDaemonCache(cacheFile, found.path, version, nowMs);
|
|
278
|
-
const daemonP = wantDaemon ? daemonStatus(found.path, 5000) : Promise.resolve(null);
|
|
284
|
+
const daemonP = wantDaemon ? daemonStatus(found.path, cbmTimeout('DAEMON', 5000)) : Promise.resolve(null);
|
|
279
285
|
const latestP = net ? latestVersion(e).then((v) => ({ v }), (err) => ({ err: err.message })) : Promise.resolve(null);
|
|
280
286
|
return (async () => {
|
|
281
287
|
add(!!version && regs.length > 0, 'codebase-memory', `${version ? `v${version}` : 'không chạy được `--version`'} tại ${toSlash(found.path)} (${found.source})${regs.length
|
package/lib/commands.cjs
CHANGED
|
@@ -77,7 +77,7 @@ function cmdSetup(opts) {
|
|
|
77
77
|
const lines = [
|
|
78
78
|
`Đã cài hook: ${res.hook}`,
|
|
79
79
|
res.settings_changed
|
|
80
|
-
? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) + UserPromptSubmit + PreToolUse(Agent) trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
|
|
80
|
+
? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SubagentStart + SessionStart(compact) + UserPromptSubmit + PreToolUse(Agent) trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
|
|
81
81
|
: `settings.json đã có hook, không đổi gì (${res.settings})`,
|
|
82
82
|
`Agent dp-*: ${res.agents.map((a) => `${a.name}=${a.action}`).join(', ')}${res.agents.some((a) => a.action === 'skipped-foreign') ? ' (file trùng tên không phải của dieu-phoi: giữ nguyên, đổi tên rồi chạy lại setup)' : ''}`,
|
|
83
83
|
`Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
|
|
@@ -144,6 +144,7 @@ async function cmdDoctor(opts) {
|
|
|
144
144
|
add(reg.PreToolUse, 'chain-gate', reg.PreToolUse
|
|
145
145
|
? 'PreToolUse(Agent) có — chặn exec tiếp nối từ handoff thứ 3 (trừ thẻ chain_ok)'
|
|
146
146
|
: 'thiếu hook PreToolUse(Agent) (core 0.7.1) — chuỗi handoff dài không bị chặn; chạy lại `dieu-phoi setup`', 'CẢNH BÁO');
|
|
147
|
+
add(...liveCheck(ctx, reg));
|
|
147
148
|
const permOk = reg.permissions && reg.runs_dir_exists && !reg.legacy_permissions;
|
|
148
149
|
add(permOk, 'run-permissions', permOk
|
|
149
150
|
? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
|
|
@@ -245,22 +246,51 @@ function orphanScan(list, now) {
|
|
|
245
246
|
return Array.isArray(list) ? procs.staleOrphans(list, { minAgeMs: ORPHAN_MIN_AGE_MS, now: now === undefined ? Date.now() : now }) : null;
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
/**
|
|
250
|
+
* doctor item `live-agents` (core 0.7.3 s1): live register data/live/ + caps. Warns when the SubagentStart hook is missing
|
|
251
|
+
* (the register then only fills from --watch) or the machine count reached warn_parallel_machine. "Toàn máy" = this
|
|
252
|
+
* DIEU_PHOI_HOME: sessions with another home have their own register.
|
|
253
|
+
*/
|
|
254
|
+
function liveCheck(ctx, reg) {
|
|
255
|
+
const s = core.liveSummary(ctx, null);
|
|
256
|
+
const lim = s.limits;
|
|
257
|
+
const caps = `trần phiên ${lim.session || 'tắt'}, toàn máy ${lim.machine || 'tắt'}, cảnh báo ${lim.warn || 'tắt'}`;
|
|
258
|
+
const scope = `"toàn máy" = mọi phiên dùng ${ctx.home}`;
|
|
259
|
+
if (reg && !reg.SubagentStart) {
|
|
260
|
+
return [false, 'live-agents', `thiếu hook SubagentStart (core 0.7.3) — sổ agent sống chỉ cập nhật từ --watch; chạy lại dieu-phoi setup (${s.machine} agent sống; ${caps})`, 'CẢNH BÁO'];
|
|
261
|
+
}
|
|
262
|
+
const warn = lim.warn > 0 && s.machine >= lim.warn;
|
|
263
|
+
const list = s.labels.length ? `: ${s.labels.slice(0, 6).join('; ')}${s.labels.length > 6 ? ` (+${s.labels.length - 6})` : ''}` : '';
|
|
264
|
+
return [!warn, 'live-agents', `${s.machine} agent dieu-phoi đang chạy${list} — ${caps}; ${scope}`, 'CẢNH BÁO'];
|
|
265
|
+
}
|
|
266
|
+
|
|
248
267
|
/** doctor item `orphan-processes`: warns with pid + age + working set and a Stop-Process hint; never kills. Returns the args of add(). */
|
|
249
268
|
function orphanCheck(ctx, deps) {
|
|
250
269
|
const d = deps || {};
|
|
251
270
|
const now = d.now === undefined ? Date.now() : d.now;
|
|
252
|
-
const
|
|
271
|
+
const list = (d.listProcesses || procs.listProcesses)({ timeoutMs: 4000 });
|
|
272
|
+
const found = orphanScan(list, now);
|
|
253
273
|
if (found === null) return [true, 'orphan-processes', 'không đọc được danh sách process (bỏ qua)'];
|
|
254
274
|
try {
|
|
255
275
|
core.writeFileAtomic(path.join(ctx.paths.data, 'orphan-procs.json'), `${JSON.stringify({ ts: new Date(now).toISOString(), n: found.length })}\n`);
|
|
256
276
|
} catch (_) { /* the cache is optional */ }
|
|
257
|
-
|
|
277
|
+
// s7: tail / sleep / Claude Code shells left orphaned (Monitor, run_in_background): reported only; `reap --orphans` cleans
|
|
278
|
+
const minK = orphanKillMin(ctx);
|
|
279
|
+
const watchers = Array.isArray(list) ? orphanTargets(list, now, minK, d).found : [];
|
|
280
|
+
const wMb = watchers.reduce((n, p) => n + (p.ws ? Math.round(p.ws / 1048576) : 0), 0);
|
|
281
|
+
const wTxt = watchers.length
|
|
282
|
+
? `${watchers.length} tail/sleep/shell mồ côi quá ${minK} phút (${wMb} MB) — xem rồi dọn: \`dieu-phoi reap --orphans\` (dry-run) rồi thêm --yes (doctor không tự kill)`
|
|
283
|
+
: '';
|
|
284
|
+
if (!found.length) {
|
|
285
|
+
return wTxt ? [false, 'orphan-processes', `không có claude.exe / codebase-memory-mcp mồ côi quá 6 giờ; ${wTxt}`, 'CẢNH BÁO']
|
|
286
|
+
: [true, 'orphan-processes', 'không có claude.exe / codebase-memory-mcp mồ côi quá 6 giờ'];
|
|
287
|
+
}
|
|
258
288
|
const mb = found.reduce((n, p) => n + (p.ws_mb || 0), 0);
|
|
259
289
|
const shown = found.slice(0, 8);
|
|
260
290
|
const items = shown.map((p) => `${p.name} pid ${p.pid} (${p.age_h} giờ${p.ws_mb ? `, ${p.ws_mb} MB` : ''})`).join('; ');
|
|
261
291
|
return [false, 'orphan-processes',
|
|
262
292
|
`${found.length} tiến trình mồ côi (cha đã thoát) quá 6 giờ, ${mb} MB: ${items}${found.length > shown.length ? '; …' : ''}`
|
|
263
|
-
+ ` — kiểm tra rồi dọn bằng \`Stop-Process -Id ${shown.map((p) => p.pid).join(',')}\` (doctor không tự kill)`, 'CẢNH BÁO'];
|
|
293
|
+
+ ` — kiểm tra rồi dọn bằng \`Stop-Process -Id ${shown.map((p) => p.pid).join(',')}\` (doctor không tự kill)${wTxt ? `; ${wTxt}` : ''}`, 'CẢNH BÁO'];
|
|
264
294
|
}
|
|
265
295
|
|
|
266
296
|
/** `state` line: orphan count from a 10-minute cache (data/orphan-procs.json), else one live scan. null = unknown (skip the line). */
|
|
@@ -937,6 +967,19 @@ function orchLine(o) {
|
|
|
937
967
|
* Orchestration state of one session rebuilt purely from events.jsonl (same builder the SessionStart(compact)
|
|
938
968
|
* hook uses). Session = --session, else env CLAUDE_CODE_SESSION_ID. Run dirs are pointed to, never read.
|
|
939
969
|
*/
|
|
970
|
+
/** Free slots under a cap (null = cap off). */
|
|
971
|
+
function freeSlots(cap, n) {
|
|
972
|
+
return cap > 0 ? Math.max(0, cap - (Number(n) || 0)) : null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** `state` line: live agents of this session / machine and free slots under the parallel caps. */
|
|
976
|
+
function liveLine(live) {
|
|
977
|
+
const lim = live.limits;
|
|
978
|
+
const part = (n, cap) => `${n}/${cap > 0 ? cap : 'tắt'}${cap > 0 ? ` (trống ${freeSlots(cap, n)})` : ''}`;
|
|
979
|
+
const tasks = live.agents.filter((a) => a.task_id).slice(0, 6).map((a) => `${a.task_id}:${a.role}`).join(', ');
|
|
980
|
+
return `live: phiên ${part(live.session || 0, lim.session)} · máy ${part(live.machine, lim.machine)}${tasks ? ` — ${tasks}` : ''}`;
|
|
981
|
+
}
|
|
982
|
+
|
|
940
983
|
function cmdState(opts) {
|
|
941
984
|
const ctx = ctxOf(opts);
|
|
942
985
|
const sid = typeof opts.session === 'string' && opts.session ? opts.session
|
|
@@ -953,10 +996,215 @@ function cmdState(opts) {
|
|
|
953
996
|
}
|
|
954
997
|
const state = core.buildSessionState(core.readEvents(ctx), sid, { plansDir: ctx.paths.plans });
|
|
955
998
|
const orphans = orphanCount(ctx); // s16: null (unknown) leaves the line out
|
|
999
|
+
const live = core.liveSummary(ctx, sid); // core 0.7.3 (s1)
|
|
1000
|
+
const stuck = core.stuckAgents(ctx, { sessionId: sid }); // s6
|
|
1001
|
+
if (opts.json) {
|
|
1002
|
+
const liveJ = {
|
|
1003
|
+
session: live.session, machine: live.machine, limits: live.limits, free_session: freeSlots(live.limits.session, live.session), free_machine: freeSlots(live.limits.machine, live.machine), agents: live.agents,
|
|
1004
|
+
stuck: stuck.map((a) => ({ agent_id: a.agent_id, task_id: a.task_id, role: a.role, gen: a.gen, state: a.state, reasons: a.reasons, idle_min: a.idle_min, age_min: a.age_min })),
|
|
1005
|
+
};
|
|
1006
|
+
return { code: 0, out: JSON.stringify(Object.assign({}, state, hasDeltas ? { unmerged_deltas: deltas } : {}, orphans === null ? {} : { orphan_procs: orphans }, { live: liveJ }), null, 2) };
|
|
1007
|
+
}
|
|
1008
|
+
const stuckLines = stuck.length
|
|
1009
|
+
? [`stuck: ${stuck.slice(0, 6).map((a) => `${a.task_id || a.agent_id}:${a.role} ${a.state === 'orphan' ? 'mồ côi' : 'kẹt'} ${a.idle_min}'`).join(', ')} → TaskStop rồi \`dieu-phoi reap --yes\``]
|
|
1010
|
+
: [];
|
|
1011
|
+
return { code: 0, out: [core.formatSessionState(state, core.STATE_TEXT_CAP)].concat(deltaLines, orphans === null ? [] : [`orphan_procs: ${orphans}`], [liveLine(live)], stuckLines).join('\n') };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ------------------------------------------------------------------ reap (core 0.7.3 s6)
|
|
1015
|
+
|
|
1016
|
+
const posixPath = (p) => String(p || '').replace(/\\/g, '/');
|
|
1017
|
+
const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Child processes attributable to a stuck agent: created after it started, command line inside its cwd (worktree), not a
|
|
1021
|
+
* Claude Code / code-graph / conhost process, not this process' own chain. Only when no other live agent shares that cwd —
|
|
1022
|
+
* otherwise attribution is a guess. s7: `targets` / `skips` = what the guarded kill may take / keeps and why. Pure.
|
|
1023
|
+
*/
|
|
1024
|
+
function reapProcsOf(a, list, sharedCwds, selfPid) {
|
|
1025
|
+
if (!Array.isArray(list) || !a.cwd) return { procs: [], attributable: false };
|
|
1026
|
+
const cwd = normPath(a.cwd);
|
|
1027
|
+
if (!cwd || sharedCwds.has(cwd)) return { procs: [], attributable: false };
|
|
1028
|
+
const own = new Set([selfPid, ...procs.ancestors(list, selfPid).map((p) => p.pid)]);
|
|
1029
|
+
const skip = new Set(procs.LEFTOVER_SKIP_NAMES.map((n) => n.toLowerCase()));
|
|
1030
|
+
const hits = list.filter((p) => !own.has(p.pid) && p.created && p.created >= a.start_ms && !skip.has(String(p.name || '').toLowerCase())
|
|
1031
|
+
&& normPath(p.cmd).includes(cwd));
|
|
1032
|
+
const ids = new Set(hits.map((p) => p.pid));
|
|
1033
|
+
// s7 guarded: of those, what may be killed — never-kill names, nothing hosting a claude.exe, and under a live claude.exe
|
|
1034
|
+
// only through an agent shell (bash / powershell), never through an MCP server. Below no claude.exe = the agent's shell
|
|
1035
|
+
// died (TaskStop) and left it; the worktree cmd already ties it to this agent.
|
|
1036
|
+
const targets = [];
|
|
1037
|
+
const skips = [];
|
|
1038
|
+
for (const p of hits) {
|
|
1039
|
+
const c = procs.claudeAncestor(list, p.pid);
|
|
1040
|
+
if (c) {
|
|
1041
|
+
const sel = procs.guardedSelect(list, [p], { rootPid: c.pid, exclusive: true });
|
|
1042
|
+
if (sel.kill.length) targets.push(p); else skips.push(sel.skip[0]);
|
|
1043
|
+
} else if (procs.isNeverKill(p)) skips.push({ pid: p.pid, name: p.name, reason: 'never-kill' });
|
|
1044
|
+
else if (procs.hostsClaude(list, p.pid)) skips.push({ pid: p.pid, name: p.name, reason: 'hosts-claude' });
|
|
1045
|
+
else targets.push(p);
|
|
1046
|
+
}
|
|
1047
|
+
return { procs: hits, roots: hits.filter((p) => !ids.has(p.ppid)), attributable: true, targets, skips };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Orphans that `reap --orphans` may kill (procs.killableOrphans), and on Windows only those the MSYS table also calls
|
|
1052
|
+
* orphaned (Git-bash exec makes live `bash -c "tail -f"` look parentless). deps.msys = table|null for tests; otherwise
|
|
1053
|
+
* read on win32 only. Returns { found, skipped: [{pid, name, reason}] }.
|
|
1054
|
+
*/
|
|
1055
|
+
function orphanTargets(list, now, minMin, d) {
|
|
1056
|
+
const cands = procs.killableOrphans(list, { now, minAgeMs: minMin * 60000 });
|
|
1057
|
+
const useMsys = Object.prototype.hasOwnProperty.call(d || {}, 'msys') || process.platform === 'win32';
|
|
1058
|
+
if (!useMsys || !cands.length) return { found: cands, skipped: [] };
|
|
1059
|
+
const msys = Object.prototype.hasOwnProperty.call(d || {}, 'msys') ? d.msys : procs.msysTable({ hints: cands.map((p) => p.cmd) });
|
|
1060
|
+
const r = procs.msysConfirm(cands, msys);
|
|
1061
|
+
return { found: r.keep, skipped: r.skip };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** `orphan_kill_min` (s7): env DIEU_PHOI_ORPHAN_KILL_MIN → config → 60. Minutes ≥ 1 (0 / junk = the default). */
|
|
1065
|
+
const ORPHAN_KILL_MIN_DEFAULT = 60;
|
|
1066
|
+
function orphanKillMin(ctx) {
|
|
1067
|
+
for (const v of [process.env.DIEU_PHOI_ORPHAN_KILL_MIN, ctx.config && ctx.config.orphan_kill_min]) {
|
|
1068
|
+
if (v === undefined || v === null || v === '') continue;
|
|
1069
|
+
const n = Number(v);
|
|
1070
|
+
if (Number.isFinite(n) && n >= 1) return n;
|
|
1071
|
+
}
|
|
1072
|
+
return ORPHAN_KILL_MIN_DEFAULT;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* `dieu-phoi reap [--yes] [--session <id>] [--json]`: stuck / orphaned live agents (data/live) of every session on this
|
|
1077
|
+
* DIEU_PHOI_HOME, or one session. Dry-run by default. --yes deletes their live files (frees the parallel-cap slot; a live
|
|
1078
|
+
* agent's next --watch re-registers it), writes one `reap` event each (local only), and kills their child processes only
|
|
1079
|
+
* when attributable to that agent alone: kill_leftovers 'guarded' (default, s7) = reapProcsOf targets, each re-checked
|
|
1080
|
+
* (pid + creation time) right before; true = the 0.7.2 tree kill; false = never. Worktrees are never removed.
|
|
1081
|
+
* --orphans adds tail / sleep / Claude Code shells orphaned for over orphan_kill_min (60) minutes (killed with --yes).
|
|
1082
|
+
* deps (tests): { procs: list|null, relist() -> list (default: procs again), kill(pids) -> pids, now }.
|
|
1083
|
+
*/
|
|
1084
|
+
function cmdReap(opts, deps) {
|
|
1085
|
+
const d = deps || {};
|
|
1086
|
+
const ctx = ctxOf(opts);
|
|
1087
|
+
const yes = opts.yes === true || opts.yes === 'y' || opts.yes === 'yes';
|
|
1088
|
+
const sid = typeof opts.session === 'string' && opts.session ? opts.session : null;
|
|
1089
|
+
const now = typeof d.now === 'number' ? d.now : Date.now();
|
|
1090
|
+
const lim = core.stuckLimits(ctx);
|
|
1091
|
+
const all = core.readLive(ctx, now).agents;
|
|
1092
|
+
const cands = core.stuckAgents(ctx, { now, limits: lim, sessionId: sid });
|
|
1093
|
+
const candIds = new Set(cands.map((a) => a.agent_id));
|
|
1094
|
+
// cwd shared with another agent (live and not reaped, or a second candidate) = processes cannot be told apart
|
|
1095
|
+
const cwdCount = new Map();
|
|
1096
|
+
for (const r of all) { const c = normPath(r.cwd); if (c) cwdCount.set(c, (cwdCount.get(c) || 0) + 1); }
|
|
1097
|
+
const shared = new Set(Array.from(cwdCount).filter(([, n]) => n > 1).map(([c]) => c));
|
|
1098
|
+
const wantOrphans = opts.orphans === true;
|
|
1099
|
+
const readList = () => (Object.prototype.hasOwnProperty.call(d, 'procs') ? d.procs : procs.listProcesses({ timeoutMs: 5000 }));
|
|
1100
|
+
const list = cands.length || wantOrphans ? readList() : null;
|
|
1101
|
+
// re-read right before killing (pid reuse); tests pass d.relist (a later process table) or reuse d.procs
|
|
1102
|
+
const relist = typeof d.relist === 'function' ? d.relist : readList;
|
|
1103
|
+
const mode = procs.killMode(ctx.config);
|
|
1104
|
+
const killOn = mode !== 'off';
|
|
1105
|
+
const killVal = procs.killModeValue(mode);
|
|
1106
|
+
const kill = typeof d.kill === 'function' ? d.kill : null;
|
|
1107
|
+
const rows = [];
|
|
1108
|
+
for (const a of cands) {
|
|
1109
|
+
const pr = reapProcsOf(a, list, shared, process.pid);
|
|
1110
|
+
const row = Object.assign({}, a, {
|
|
1111
|
+
procs: pr.procs.map((p) => ({ pid: p.pid, name: p.name })), procs_attributable: pr.attributable,
|
|
1112
|
+
kill_targets: mode === 'guarded' && pr.targets ? pr.targets.map((p) => p.pid) : [],
|
|
1113
|
+
kill_skips: mode === 'guarded' && pr.skips ? pr.skips : [],
|
|
1114
|
+
killed: [], removed: false,
|
|
1115
|
+
});
|
|
1116
|
+
delete row.start_ms; delete row.notified;
|
|
1117
|
+
if (yes) {
|
|
1118
|
+
if (mode === 'tree' && pr.roots && pr.roots.length) {
|
|
1119
|
+
row.killed = (kill ? kill(pr.roots.map((p) => p.pid)) : procs.killTree(pr.roots.map((p) => p.pid), { deadline: Date.now() + 10000 })) || [];
|
|
1120
|
+
} else if (mode === 'guarded' && pr.targets && pr.targets.length) {
|
|
1121
|
+
const res = procs.killVerified(pr.targets, { relist, kill, deadline: Date.now() + 10000 });
|
|
1122
|
+
row.killed = res.killed;
|
|
1123
|
+
row.kill_skips = row.kill_skips.concat(res.skipped);
|
|
1124
|
+
}
|
|
1125
|
+
row.removed = core.removeLive(ctx, a.agent_id);
|
|
1126
|
+
try {
|
|
1127
|
+
core.appendEvent(ctx, Object.assign({
|
|
1128
|
+
schema: core.SCHEMA, kind: 'reap', ts: core.nowIso(), agent_id: a.agent_id, task_id: a.task_id, role: a.role, gen: a.gen,
|
|
1129
|
+
parent_task_id: a.parent_task_id, session: a.session_id ? core.sha8(a.session_id) : null, reap_state: a.state,
|
|
1130
|
+
reasons: a.reasons, idle_min: a.idle_min, age_min: a.age_min, worktree: a.cwd ? posixPath(a.cwd) : null,
|
|
1131
|
+
procs: row.procs.length, killed: row.killed.length, killed_pids: row.killed.slice(0, 20), kill_leftovers: killVal, removed: row.removed,
|
|
1132
|
+
}, row.kill_skips.length ? { kill_skips: row.kill_skips.slice(0, 20).map((s) => ({ pid: s.pid, name: String(s.name || '').toLowerCase(), reason: s.reason })) } : {},
|
|
1133
|
+
core.commonFields(ctx)));
|
|
1134
|
+
} catch (e) { core.logError(ctx.home, e, 'reap-event'); }
|
|
1135
|
+
}
|
|
1136
|
+
rows.push(row);
|
|
1137
|
+
}
|
|
1138
|
+
const ok = all.filter((r) => !candIds.has(r.agent_id) && (!sid || r.session_id === sid)).length;
|
|
1139
|
+
// s7: `--orphans` — tail / sleep / Claude Code shells whose parent is gone, older than orphan_kill_min. Dry-run lists them;
|
|
1140
|
+
// --yes kills each after re-checking pid + creation time (never with kill_leftovers:false). One local event reap_orphans.
|
|
1141
|
+
let orph = null;
|
|
1142
|
+
if (wantOrphans) {
|
|
1143
|
+
const minMin = orphanKillMin(ctx);
|
|
1144
|
+
const ot = Array.isArray(list) ? orphanTargets(list, now, minMin, d) : { found: [], skipped: [] };
|
|
1145
|
+
const found = ot.found;
|
|
1146
|
+
orph = {
|
|
1147
|
+
min_age_min: minMin, listed: Array.isArray(list), n: found.length,
|
|
1148
|
+
procs: found.map((p) => ({ pid: p.pid, name: p.name, age_min: Math.round((now - p.created) / 60000), cmd: p.cmd ? String(p.cmd).slice(0, 100) : null })),
|
|
1149
|
+
killed: [], skipped: [], kept: ot.skipped,
|
|
1150
|
+
};
|
|
1151
|
+
if (yes && killOn && found.length) {
|
|
1152
|
+
const res = procs.killVerified(found, { relist, kill, deadline: Date.now() + Math.max(20000, found.length * 3000) });
|
|
1153
|
+
orph.killed = res.killed;
|
|
1154
|
+
orph.skipped = res.skipped;
|
|
1155
|
+
}
|
|
1156
|
+
if (yes) {
|
|
1157
|
+
try {
|
|
1158
|
+
core.appendEvent(ctx, Object.assign({
|
|
1159
|
+
schema: core.SCHEMA, kind: 'reap_orphans', ts: core.nowIso(), n: found.length, killed: orph.killed.length,
|
|
1160
|
+
killed_pids: orph.killed.slice(0, 50), skipped: orph.skipped.slice(0, 20).map((s) => ({ pid: s.pid, reason: s.reason })),
|
|
1161
|
+
names: Array.from(new Set(found.map((p) => String(p.name || '').toLowerCase()))).slice(0, 8), min_age_min: minMin, kill_leftovers: killVal,
|
|
1162
|
+
}, core.commonFields(ctx)));
|
|
1163
|
+
} catch (e) { core.logError(ctx.home, e, 'reap-orphans-event'); }
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
956
1166
|
if (opts.json) {
|
|
957
|
-
return { code: 0, out: JSON.stringify(Object.assign({
|
|
1167
|
+
return { code: 0, out: JSON.stringify(Object.assign({ dry_run: !yes, limits: lim, kill_leftovers: killVal, procs_listed: Array.isArray(list), live_ok: ok, agents: rows }, orph ? { orphans: orph } : {}), null, 2) };
|
|
958
1168
|
}
|
|
959
|
-
|
|
1169
|
+
const head = `reap${yes ? '' : ' (dry-run)'}: ${rows.length} agent kẹt/mồ côi${sid ? ` trong phiên ${core.sha8(sid)}` : ' trên máy'}; ${ok} agent live còn bình thường `
|
|
1170
|
+
+ `(ngưỡng: im ${lim.idle || 'tắt'} phút · tổng ${lim.hard || 'tắt'} phút · sau STATUS ${lim.after_status || 'tắt'} phút).`;
|
|
1171
|
+
const lines = [head];
|
|
1172
|
+
for (const r of rows) {
|
|
1173
|
+
const who = `agent ${r.agent_id} task ${r.task_id || '?'} (${r.role || '?'}, gen ${r.gen}${r.session_id ? `, phiên ${core.sha8(r.session_id)}` : ''})`;
|
|
1174
|
+
const procTxt = !Array.isArray(list) ? 'tiến trình: không đọc được bảng process'
|
|
1175
|
+
: (!r.procs_attributable ? 'tiến trình: không quy được về riêng agent này (không có cwd / cwd dùng chung)'
|
|
1176
|
+
: `tiến trình con: ${r.procs.length ? r.procs.slice(0, 6).map((p) => `${p.name} ${p.pid}`).join(', ') + (r.procs.length > 6 ? ` (+${r.procs.length - 6})` : '') : 'không có'}`);
|
|
1177
|
+
const guardTxt = mode === 'guarded' && r.procs.length
|
|
1178
|
+
? `; sẽ kill ${r.kill_targets.length}${r.kill_targets.length ? ` (${r.kill_targets.slice(0, 6).join(', ')})` : ''}${r.kill_skips.length ? `, giữ ${r.kill_skips.length} [${Array.from(new Set(r.kill_skips.map((s) => s.reason))).join(', ')}]` : ''}`
|
|
1179
|
+
: '';
|
|
1180
|
+
const act = !yes ? '' : ` → ${r.removed ? 'đã xoá file live' : 'file live đã mất'}${r.killed.length ? `, đã kill ${r.killed.length}` : (r.procs.length ? (killOn ? ', không kill được / không có gì quy chắc về agent' : ', không kill (kill_leftovers=false)') : '')}`;
|
|
1181
|
+
lines.push(`- ${who}: ${r.state === 'orphan' ? 'mồ côi' : 'kẹt'} [${core.stuckReasonText(r)}]; chạy ${r.age_min} phút, im ${r.idle_min} phút; ${procTxt}${guardTxt}${r.cwd ? `; worktree ${posixPath(r.cwd)} giữ nguyên` : ''}${act}`);
|
|
1182
|
+
}
|
|
1183
|
+
if (rows.length && !yes) {
|
|
1184
|
+
lines.push('Agent còn sống thì TaskStop <agent_id> trong phiên của nó trước; rồi `dieu-phoi reap --yes` xoá file live (trả chỗ trần) + ghi event reap.'
|
|
1185
|
+
+ (mode === 'guarded' ? ' Chỉ tiến trình ghi "sẽ kill" bị kill (kill_leftovers=guarded: kiểm lại pid + giờ tạo ngay trước khi kill).'
|
|
1186
|
+
: mode === 'tree' ? ' Cây tiến trình con quy được về agent sẽ bị kill (kill_leftovers=true).' : ' Tiến trình con chỉ được liệt kê (kill_leftovers=false).'));
|
|
1187
|
+
}
|
|
1188
|
+
if (orph) {
|
|
1189
|
+
const groups = new Map();
|
|
1190
|
+
for (const p of orph.procs) groups.set(String(p.name).toLowerCase(), (groups.get(String(p.name).toLowerCase()) || 0) + 1);
|
|
1191
|
+
const oldest = orph.procs.length ? Math.max(...orph.procs.map((p) => p.age_min)) : 0;
|
|
1192
|
+
lines.push(!orph.listed ? 'orphans: không đọc được bảng process'
|
|
1193
|
+
: `orphans${yes ? '' : ' (dry-run)'}: ${orph.n} tiến trình mồ côi (cha đã thoát) quá ${orph.min_age_min} phút`
|
|
1194
|
+
+ (orph.n ? ` — ${Array.from(groups).map(([n, c]) => `${n} ×${c}`).join(', ')}; cũ nhất ${Math.round(oldest / 60)} giờ` : ''));
|
|
1195
|
+
if (orph.kept.length) {
|
|
1196
|
+
const why = new Map();
|
|
1197
|
+
for (const k of orph.kept) why.set(k.reason, (why.get(k.reason) || 0) + 1);
|
|
1198
|
+
lines.push(` giữ ${orph.kept.length} cái trông mồ côi nhưng chưa chắc: ${Array.from(why).map(([r, c]) => `${r} ×${c}`).join(', ')} (msys-parent-alive = bash cha còn sống theo MSYS, việc nền đang chạy)`);
|
|
1199
|
+
}
|
|
1200
|
+
for (const p of orph.procs.slice(0, 10)) lines.push(` ${p.name} ${p.pid} (${p.age_min} phút)${p.cmd ? `: ${p.cmd}` : ''}`);
|
|
1201
|
+
if (orph.procs.length > 10) lines.push(` … +${orph.procs.length - 10}`);
|
|
1202
|
+
if (yes) {
|
|
1203
|
+
lines.push(!killOn ? ' → không kill (kill_leftovers=false)'
|
|
1204
|
+
: ` → đã kill ${orph.killed.length}/${orph.n}${orph.skipped.length ? `; bỏ qua ${orph.skipped.length} [${Array.from(new Set(orph.skipped.map((s) => s.reason))).join(', ')}]` : ''}; event reap_orphans`);
|
|
1205
|
+
} else if (orph.n) lines.push(' `dieu-phoi reap --orphans --yes` để kill (kiểm lại pid + giờ tạo ngay trước khi kill).');
|
|
1206
|
+
}
|
|
1207
|
+
return { code: 0, out: lines.join('\n') };
|
|
960
1208
|
}
|
|
961
1209
|
|
|
962
1210
|
// ------------------------------------------------------------------ stats / retro
|
|
@@ -1548,6 +1796,6 @@ function cmdPlanTable(opts) {
|
|
|
1548
1796
|
}
|
|
1549
1797
|
|
|
1550
1798
|
module.exports = {
|
|
1551
|
-
ORCH_NEW_SESSION_TOKENS, ORCH_NEW_SESSION_COMPACTIONS, skillDirOf, cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, recordPlanSuggestions, cmdStats, cmdRetro, cmdPrune: (o) => require('./prune.cjs').cmdPrune(o), cmdChoose, cmdMode, modeEvent, cmdCtx, cmdState, orphanCheck, orphanCount, measureOrch,
|
|
1799
|
+
ORCH_NEW_SESSION_TOKENS, ORCH_NEW_SESSION_COMPACTIONS, skillDirOf, cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, recordPlanSuggestions, cmdStats, cmdRetro, cmdPrune: (o) => require('./prune.cjs').cmdPrune(o), cmdChoose, cmdMode, modeEvent, cmdCtx, cmdState, cmdReap, reapProcsOf, orphanKillMin, orphanCheck, orphanCount, liveCheck, liveLine, freeSlots, measureOrch,
|
|
1552
1800
|
cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
|
|
1553
1801
|
};
|
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 };
|