@cleocode/core 2026.6.15 → 2026.6.17
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/dist/docs/export-document.js +41 -0
- package/dist/docs/export-document.js.map +1 -1
- package/dist/gc/janitor.d.ts +149 -0
- package/dist/gc/janitor.d.ts.map +1 -0
- package/dist/gc/janitor.js +760 -0
- package/dist/gc/janitor.js.map +1 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +3 -0
- package/dist/internal.js.map +1 -1
- package/dist/llm/cli-ops.d.ts +15 -4
- package/dist/llm/cli-ops.d.ts.map +1 -1
- package/dist/llm/cli-ops.js +78 -21
- package/dist/llm/cli-ops.js.map +1 -1
- package/dist/llm/credential-pool.d.ts +62 -0
- package/dist/llm/credential-pool.d.ts.map +1 -1
- package/dist/llm/credential-pool.js +161 -0
- package/dist/llm/credential-pool.js.map +1 -1
- package/dist/llm/cross-provider-selector.d.ts +8 -3
- package/dist/llm/cross-provider-selector.d.ts.map +1 -1
- package/dist/llm/cross-provider-selector.js +71 -8
- package/dist/llm/cross-provider-selector.js.map +1 -1
- package/dist/llm/plugin-facade.js +122 -1
- package/dist/llm/plugin-facade.js.map +1 -1
- package/dist/selfimprove/fix-gen-context.d.ts +172 -0
- package/dist/selfimprove/fix-gen-context.d.ts.map +1 -0
- package/dist/selfimprove/fix-gen-context.js +218 -0
- package/dist/selfimprove/fix-gen-context.js.map +1 -0
- package/dist/selfimprove/fix-gen.d.ts +60 -4
- package/dist/selfimprove/fix-gen.d.ts.map +1 -1
- package/dist/selfimprove/fix-gen.js +91 -11
- package/dist/selfimprove/fix-gen.js.map +1 -1
- package/dist/selfimprove/op-source-map.d.ts +92 -0
- package/dist/selfimprove/op-source-map.d.ts.map +1 -0
- package/dist/selfimprove/op-source-map.js +187 -0
- package/dist/selfimprove/op-source-map.js.map +1 -0
- package/dist/selfimprove/probe-helper.d.ts +61 -0
- package/dist/selfimprove/probe-helper.d.ts.map +1 -0
- package/dist/selfimprove/probe-helper.js +64 -0
- package/dist/selfimprove/probe-helper.js.map +1 -0
- package/dist/selfimprove/scenarios/seeded-code-regression/golden.json +18 -0
- package/dist/selfimprove/scenarios/seeded-code-regression/scenario.json +12 -0
- package/dist/setup/index.d.ts +1 -0
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/index.js +1 -0
- package/dist/setup/index.js.map +1 -1
- package/dist/setup/sections/models-roles.d.ts +32 -1
- package/dist/setup/sections/models-roles.d.ts.map +1 -1
- package/dist/setup/sections/models-roles.js +118 -19
- package/dist/setup/sections/models-roles.js.map +1 -1
- package/dist/setup/whoami-summary.d.ts +34 -0
- package/dist/setup/whoami-summary.d.ts.map +1 -0
- package/dist/setup/whoami-summary.js +106 -0
- package/dist/setup/whoami-summary.js.map +1 -0
- package/package.json +12 -12
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLEO Janitor — orphan process reaper + stale scope/lock/debris sweep.
|
|
3
|
+
*
|
|
4
|
+
* Composable engine that the GC subsystem and the `cleo janitor run` CLI verb
|
|
5
|
+
* call. Every action is:
|
|
6
|
+
* - Silent: no console output; one JSONL line per action appended to
|
|
7
|
+
* `.cleo/audit/janitor.jsonl`, plus counts in the returned result.
|
|
8
|
+
* - Idempotent: a fully-converged state produces zero actions on re-run.
|
|
9
|
+
* - Dry-run capable: `dryRun: true` reports planned actions without mutating
|
|
10
|
+
* anything.
|
|
11
|
+
*
|
|
12
|
+
* ## Categories
|
|
13
|
+
*
|
|
14
|
+
* | # | Category | Description |
|
|
15
|
+
* |---|--------------------------|------------------------------------------------------|
|
|
16
|
+
* | 1 | `reaped` | Leaked MCP/agent helpers (registration-primary) |
|
|
17
|
+
* | 2 | `scopesStopped` | Cleo-owned systemd transient scopes that exited |
|
|
18
|
+
* | 3 | `locksReclaimed` | Sentient/GC lock files held by dead PIDs |
|
|
19
|
+
* | 4 | `semaphoreSlotsCleared` | Tool-semaphore slot dirs held past staleMs |
|
|
20
|
+
* | 5 | `worktreesPruned` | Orphan worktree directories |
|
|
21
|
+
* | 6 | `worktreesQuarantined` | Dirty/unpushed worktrees quarantined |
|
|
22
|
+
* | 7 | `tmpRemoved` | Stale CLEO temp directories |
|
|
23
|
+
* | 8 | `attachmentsRepaired` | Attachment rows/files repaired |
|
|
24
|
+
* | 9 | `configsRepaired` | Corrupt config files restored |
|
|
25
|
+
*
|
|
26
|
+
* ## Amendment compliance
|
|
27
|
+
*
|
|
28
|
+
* - **Amendment 1 (registration-primary)**: orphan detection uses cleo-owned
|
|
29
|
+
* scope/pgid as PRIMARY discriminator; signature+age only for UNREGISTERED
|
|
30
|
+
* processes when all stdio pipe peers are dead.
|
|
31
|
+
* - **Amendment 2 (regression)**: reparented double-fork of a LIVE session is
|
|
32
|
+
* preserved; same of a DEAD session is reaped.
|
|
33
|
+
* - **Amendment 3 (scope reaping)**: restricted to units starting with
|
|
34
|
+
* `cleo-` prefix inside `cleo.slice`. Never touches `run-*.scope`.
|
|
35
|
+
* - **Amendment 4 (idempotency)**: TERM→KILL escalation spanning runs is
|
|
36
|
+
* legitimate; a fully-converged state produces zero actions on second run.
|
|
37
|
+
* - **Amendment 5 (liveness probe)**: stale locks reclaimed only after
|
|
38
|
+
* verifying the holder PID is dead — never on mtime alone.
|
|
39
|
+
* - **Amendment 6 (silence)**: every action → `.cleo/audit/janitor.jsonl`;
|
|
40
|
+
* zero desktop notifications; zero console noise in non-verbose mode.
|
|
41
|
+
* - **Amendment 7 (tmp debris)**: reuses `gc/cleanup.ts` patterns directly.
|
|
42
|
+
*
|
|
43
|
+
* @module @cleocode/core/gc/janitor
|
|
44
|
+
* @task T11995
|
|
45
|
+
* @epic T11992
|
|
46
|
+
*/
|
|
47
|
+
import { spawnSync } from 'node:child_process';
|
|
48
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
49
|
+
import { join } from 'node:path';
|
|
50
|
+
import { repairConfigFile } from '../config/config-repair.js';
|
|
51
|
+
import { getCleoHome, resolveOrCwd } from '../paths.js';
|
|
52
|
+
import { repairAttachmentStore } from '../store/attachment-repair.js';
|
|
53
|
+
import { pruneOrphanTempDirs, pruneOrphanWorktrees } from './cleanup.js';
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Constants
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Default minimum age (ms) of an unregistered process before reap eligibility.
|
|
59
|
+
* 10 minutes gives a freshly-spawned MCP server time to register.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_GRACE_MS = 10 * 60 * 1000;
|
|
62
|
+
/**
|
|
63
|
+
* Default SIGTERM→SIGKILL grace period (ms).
|
|
64
|
+
* Matches suite-reaper.ts value for consistency.
|
|
65
|
+
*/
|
|
66
|
+
export const SIGTERM_GRACE_MS = 3_000;
|
|
67
|
+
/**
|
|
68
|
+
* Default stale-ms for tool-semaphore slot lock directories.
|
|
69
|
+
* Matches tool-semaphore.ts default.
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_SEMAPHORE_STALE_MS = 600_000;
|
|
72
|
+
/**
|
|
73
|
+
* Unit-name prefix for cleo-owned transient scopes (Amendment 3).
|
|
74
|
+
* `cleo-` covers cleo-agent-*, cleo-db-*, cleo-test-*, etc.
|
|
75
|
+
*/
|
|
76
|
+
const CLEO_UNIT_PREFIX = 'cleo-';
|
|
77
|
+
/**
|
|
78
|
+
* Units to NEVER stop — intentional long-lived cleo daemons (Amendment 3).
|
|
79
|
+
*/
|
|
80
|
+
const DAEMON_UNIT_ALLOWLIST = [
|
|
81
|
+
'cleo-daemon.service',
|
|
82
|
+
'cleo-gateway.service',
|
|
83
|
+
'cleo-daemon',
|
|
84
|
+
'cleo-gateway',
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Command-line patterns that identify cleo MCP-suite processes.
|
|
88
|
+
* Matched against /proc/<pid>/cmdline (null-byte separated, joined to spaces).
|
|
89
|
+
* Only used for the UNREGISTERED fallback path (Amendment 1).
|
|
90
|
+
*/
|
|
91
|
+
const MCP_CMDLINE_SIGNATURES = [
|
|
92
|
+
'@cleocode/cleo',
|
|
93
|
+
'.local/share/cleo',
|
|
94
|
+
'caamp-mcp',
|
|
95
|
+
'claude-code-mcp',
|
|
96
|
+
'pi-mcp',
|
|
97
|
+
];
|
|
98
|
+
/** Relative path of the janitor audit log (Amendment 6). */
|
|
99
|
+
const JANITOR_AUDIT_REL = 'audit/janitor.jsonl';
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Audit helpers (Amendment 6)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/**
|
|
104
|
+
* Append one structured action entry to the janitor audit log.
|
|
105
|
+
* Synchronous; fails silently (audit is best-effort).
|
|
106
|
+
*/
|
|
107
|
+
function auditLog(cleoDir, action, detail, dryRun) {
|
|
108
|
+
try {
|
|
109
|
+
const auditPath = join(cleoDir, JANITOR_AUDIT_REL);
|
|
110
|
+
mkdirSync(join(cleoDir, 'audit'), { recursive: true });
|
|
111
|
+
const entry = JSON.stringify({
|
|
112
|
+
ts: new Date().toISOString(),
|
|
113
|
+
action,
|
|
114
|
+
dryRun,
|
|
115
|
+
agentId: process.env['CLEO_AGENT_ID'] ?? 'cleo',
|
|
116
|
+
...detail,
|
|
117
|
+
});
|
|
118
|
+
appendFileSync(auditPath, `${entry}\n`, { encoding: 'utf-8' });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// best-effort
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// /proc helpers (Linux-only)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
/**
|
|
128
|
+
* Check whether a PID is alive via `kill(pid, 0)`.
|
|
129
|
+
* Returns `false` for ESRCH or any other error.
|
|
130
|
+
*/
|
|
131
|
+
export function isPidAlive(pid) {
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, 0);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Read `/proc/<pid>/cmdline` and return the null-byte-separated content as a
|
|
142
|
+
* space-joined string. Returns `''` on any failure.
|
|
143
|
+
*/
|
|
144
|
+
function readProcCmdline(pid) {
|
|
145
|
+
try {
|
|
146
|
+
return readFileSync(`/proc/${pid}/cmdline`, 'utf-8').replace(/\0/g, ' ').trim();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Approximate process start time as Unix epoch ms by reading
|
|
154
|
+
* `/proc/<pid>/stat` field 22 (starttime in clock ticks since boot) and
|
|
155
|
+
* `/proc/uptime` (seconds since boot).
|
|
156
|
+
*
|
|
157
|
+
* Returns 0 on failure (treated conservatively as "zero age").
|
|
158
|
+
*/
|
|
159
|
+
function estimateProcessStartMs(pid) {
|
|
160
|
+
try {
|
|
161
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8').trim().split(' ');
|
|
162
|
+
const startTicks = Number.parseInt(stat[21] ?? '0', 10);
|
|
163
|
+
if (!Number.isFinite(startTicks) || startTicks <= 0)
|
|
164
|
+
return 0;
|
|
165
|
+
const uptimeSec = Number.parseFloat(readFileSync('/proc/uptime', 'utf-8').split(' ')[0] ?? '0');
|
|
166
|
+
if (!Number.isFinite(uptimeSec) || uptimeSec <= 0)
|
|
167
|
+
return 0;
|
|
168
|
+
const clkTck = 100; // USER_HZ on Linux x86_64
|
|
169
|
+
const bootEpochMs = Date.now() - uptimeSec * 1000;
|
|
170
|
+
return bootEpochMs + (startTicks / clkTck) * 1000;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Enumerate all integer-named entries under `/proc` as PIDs.
|
|
178
|
+
* Returns an empty array when `/proc` is unavailable.
|
|
179
|
+
*/
|
|
180
|
+
function enumProcPids() {
|
|
181
|
+
try {
|
|
182
|
+
return readdirSync('/proc')
|
|
183
|
+
.map((e) => Number.parseInt(e, 10))
|
|
184
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Systemd helpers
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
/**
|
|
194
|
+
* List all cleo-owned transient scope unit names via `systemctl --user`.
|
|
195
|
+
*
|
|
196
|
+
* Returns unit names matching `cleo-*.scope` (Amendment 3).
|
|
197
|
+
* Returns `[]` when systemd is unavailable.
|
|
198
|
+
*/
|
|
199
|
+
function listCleoScopeUnits() {
|
|
200
|
+
if (process.platform !== 'linux')
|
|
201
|
+
return [];
|
|
202
|
+
try {
|
|
203
|
+
const result = spawnSync('systemctl', ['--user', 'list-units', '--type=scope', '--all', '--no-legend', '--no-pager'], { encoding: 'utf-8', timeout: 10_000 });
|
|
204
|
+
if (result.status !== 0 || !result.stdout)
|
|
205
|
+
return [];
|
|
206
|
+
return result.stdout
|
|
207
|
+
.split('\n')
|
|
208
|
+
.map((line) => line.trim().split(/\s+/)[0] ?? '')
|
|
209
|
+
.filter((name) => name.startsWith(CLEO_UNIT_PREFIX) &&
|
|
210
|
+
name.endsWith('.scope') &&
|
|
211
|
+
!DAEMON_UNIT_ALLOWLIST.includes(name));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Get the `SubState` of a systemd unit (e.g. `'exited'`, `'failed'`, `'running'`).
|
|
219
|
+
* Returns `''` on failure.
|
|
220
|
+
*/
|
|
221
|
+
function getUnitSubState(unitName) {
|
|
222
|
+
try {
|
|
223
|
+
const result = spawnSync('systemctl', ['--user', 'show', unitName, '--property=SubState', '--value'], { encoding: 'utf-8', timeout: 5_000 });
|
|
224
|
+
return (result.stdout ?? '').trim();
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Read `/sys/fs/cgroup<cgroupPath>/cgroup.procs` for a systemd unit.
|
|
232
|
+
* Returns an empty array when not available.
|
|
233
|
+
*/
|
|
234
|
+
function getUnitCgroupProcs(unitName) {
|
|
235
|
+
if (process.platform !== 'linux')
|
|
236
|
+
return [];
|
|
237
|
+
try {
|
|
238
|
+
const pathResult = spawnSync('systemctl', ['--user', 'show', unitName, '--property=ControlGroup', '--value'], { encoding: 'utf-8', timeout: 5_000 });
|
|
239
|
+
const cgroupPath = (pathResult.stdout ?? '').trim();
|
|
240
|
+
if (!cgroupPath)
|
|
241
|
+
return [];
|
|
242
|
+
const procsPath = `/sys/fs/cgroup${cgroupPath}/cgroup.procs`;
|
|
243
|
+
if (!existsSync(procsPath))
|
|
244
|
+
return [];
|
|
245
|
+
const contents = readFileSync(procsPath, 'utf-8').trim();
|
|
246
|
+
if (!contents)
|
|
247
|
+
return [];
|
|
248
|
+
return contents
|
|
249
|
+
.split('\n')
|
|
250
|
+
.map((s) => Number.parseInt(s.trim(), 10))
|
|
251
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Check whether any live process in `/proc` is a member of a given cleo
|
|
259
|
+
* scope unit by cgroup membership.
|
|
260
|
+
* Returns the owning unit name, or `null` if none found.
|
|
261
|
+
*/
|
|
262
|
+
function findOwningCleoScope(pid, unitNames) {
|
|
263
|
+
for (const unitName of unitNames) {
|
|
264
|
+
const procs = getUnitCgroupProcs(unitName);
|
|
265
|
+
if (procs.includes(pid))
|
|
266
|
+
return unitName;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Pipe-peer liveness check (Amendment 1 fallback guard)
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
/**
|
|
274
|
+
* Return `true` when all stdio pipe peers of a process are dead.
|
|
275
|
+
*
|
|
276
|
+
* A reparented double-forked helper of a LIVE session has at least one live
|
|
277
|
+
* pipe peer. If all stdio fds are pipes whose other ends belong to dead
|
|
278
|
+
* processes (or the fds are not pipes), the process is truly orphaned.
|
|
279
|
+
*
|
|
280
|
+
* Returns `false` (conservative — don't reap) on any enumeration failure.
|
|
281
|
+
*/
|
|
282
|
+
function areAllStdioPeersDead(pid) {
|
|
283
|
+
if (process.platform !== 'linux')
|
|
284
|
+
return false;
|
|
285
|
+
try {
|
|
286
|
+
const fdDir = `/proc/${pid}/fd`;
|
|
287
|
+
for (const fdName of ['0', '1', '2']) {
|
|
288
|
+
const fdPath = join(fdDir, fdName);
|
|
289
|
+
let target;
|
|
290
|
+
try {
|
|
291
|
+
target = readlinkSync(fdPath);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
continue; // fd missing or unreadable — not a pipe
|
|
295
|
+
}
|
|
296
|
+
const inodeMatch = target.match(/^pipe:\[(\d+)\]$/);
|
|
297
|
+
if (!inodeMatch)
|
|
298
|
+
continue; // not a pipe
|
|
299
|
+
const inode = inodeMatch[1];
|
|
300
|
+
// Find any live process that holds the same pipe inode
|
|
301
|
+
const allPids = enumProcPids();
|
|
302
|
+
for (const peerPid of allPids) {
|
|
303
|
+
if (peerPid === pid)
|
|
304
|
+
continue;
|
|
305
|
+
if (!isPidAlive(peerPid))
|
|
306
|
+
continue;
|
|
307
|
+
try {
|
|
308
|
+
const peerFds = readdirSync(`/proc/${peerPid}/fd`);
|
|
309
|
+
for (const peerFd of peerFds) {
|
|
310
|
+
try {
|
|
311
|
+
const peerTarget = readlinkSync(`/proc/${peerPid}/fd/${peerFd}`);
|
|
312
|
+
if (peerTarget === `pipe:[${inode}]`) {
|
|
313
|
+
// Found a live process sharing this pipe
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// unreadable
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// unreadable proc entry
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return false; // conservative
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Category 1: Orphan process reaping (Amendment 1 & 2)
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
/**
|
|
337
|
+
* Reap orphaned MCP/agent processes.
|
|
338
|
+
*
|
|
339
|
+
* Primary path (Amendment 1): processes inside DEAD cleo-owned scopes.
|
|
340
|
+
* Fallback path: unregistered processes matching MCP signatures, older than
|
|
341
|
+
* `gracePeriodMs`, whose all stdio pipe peers are dead.
|
|
342
|
+
*
|
|
343
|
+
* Regression guard (Amendment 2): a reparented helper of a LIVE scope is
|
|
344
|
+
* preserved; only helpers of DEAD scopes are reaped.
|
|
345
|
+
*/
|
|
346
|
+
function reapOrphanProcesses(opts) {
|
|
347
|
+
const { gracePeriodMs, dryRun, cleoDir, liveUnits, deadUnits } = opts;
|
|
348
|
+
const allUnits = [...liveUnits, ...deadUnits];
|
|
349
|
+
let reaped = 0;
|
|
350
|
+
if (process.platform !== 'linux')
|
|
351
|
+
return 0;
|
|
352
|
+
// PRIMARY: reap processes inside dead cleo scopes
|
|
353
|
+
for (const deadUnit of deadUnits) {
|
|
354
|
+
const procs = getUnitCgroupProcs(deadUnit);
|
|
355
|
+
for (const pid of procs) {
|
|
356
|
+
if (!isPidAlive(pid))
|
|
357
|
+
continue;
|
|
358
|
+
const cmdline = readProcCmdline(pid);
|
|
359
|
+
const startMs = estimateProcessStartMs(pid);
|
|
360
|
+
const ageMs = startMs > 0 ? Date.now() - startMs : gracePeriodMs + 1;
|
|
361
|
+
if (!dryRun) {
|
|
362
|
+
try {
|
|
363
|
+
process.kill(pid, 'SIGTERM');
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// ESRCH = already gone — idempotent
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
auditLog(cleoDir, 'reap-process', {
|
|
370
|
+
pid,
|
|
371
|
+
cmdline: cmdline.slice(0, 200),
|
|
372
|
+
unit: deadUnit,
|
|
373
|
+
ageMs,
|
|
374
|
+
signal: 'SIGTERM',
|
|
375
|
+
path: 'registration-primary',
|
|
376
|
+
}, dryRun);
|
|
377
|
+
reaped++;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// FALLBACK: scan /proc for unregistered MCP-signature processes
|
|
381
|
+
const allPids = enumProcPids();
|
|
382
|
+
for (const pid of allPids) {
|
|
383
|
+
if (pid === process.pid)
|
|
384
|
+
continue;
|
|
385
|
+
if (!isPidAlive(pid))
|
|
386
|
+
continue;
|
|
387
|
+
// Skip if owned by any registered cleo scope (live or dead)
|
|
388
|
+
if (findOwningCleoScope(pid, allUnits) !== null)
|
|
389
|
+
continue;
|
|
390
|
+
const cmdline = readProcCmdline(pid);
|
|
391
|
+
if (!MCP_CMDLINE_SIGNATURES.some((sig) => cmdline.includes(sig)))
|
|
392
|
+
continue;
|
|
393
|
+
const startMs = estimateProcessStartMs(pid);
|
|
394
|
+
const ageMs = startMs > 0 ? Date.now() - startMs : 0;
|
|
395
|
+
if (ageMs < gracePeriodMs)
|
|
396
|
+
continue;
|
|
397
|
+
// Amendment 1: only reap when all stdio peers are dead
|
|
398
|
+
if (!areAllStdioPeersDead(pid))
|
|
399
|
+
continue;
|
|
400
|
+
if (!dryRun) {
|
|
401
|
+
try {
|
|
402
|
+
process.kill(pid, 'SIGTERM');
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// ESRCH = already gone
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
auditLog(cleoDir, 'reap-process', { pid, cmdline: cmdline.slice(0, 200), ageMs, signal: 'SIGTERM', path: 'signature-fallback' }, dryRun);
|
|
409
|
+
reaped++;
|
|
410
|
+
}
|
|
411
|
+
return reaped;
|
|
412
|
+
}
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Category 2: Dead cleo-owned transient scopes (Amendment 3)
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
/**
|
|
417
|
+
* Stop and reset-failed all dead cleo-owned transient scope units.
|
|
418
|
+
*
|
|
419
|
+
* A scope is "dead" when `SubState` is `exited`, `failed`, or `dead`,
|
|
420
|
+
* OR when its `cgroup.procs` is empty (Amendment 3 precondition).
|
|
421
|
+
*
|
|
422
|
+
* Operations are no-ops on already-stopped scopes (idempotent).
|
|
423
|
+
*/
|
|
424
|
+
function stopDeadScopes(opts) {
|
|
425
|
+
const { unitNames, dryRun, cleoDir } = opts;
|
|
426
|
+
let stopped = 0;
|
|
427
|
+
const deadUnits = [];
|
|
428
|
+
for (const unitName of unitNames) {
|
|
429
|
+
// NEVER touch allowlisted daemons (Amendment 3)
|
|
430
|
+
if (DAEMON_UNIT_ALLOWLIST.includes(unitName))
|
|
431
|
+
continue;
|
|
432
|
+
const subState = getUnitSubState(unitName);
|
|
433
|
+
const procs = getUnitCgroupProcs(unitName);
|
|
434
|
+
const isDead = subState === 'exited' ||
|
|
435
|
+
subState === 'failed' ||
|
|
436
|
+
subState === 'dead' ||
|
|
437
|
+
(subState !== 'running' && procs.length === 0);
|
|
438
|
+
if (!isDead)
|
|
439
|
+
continue;
|
|
440
|
+
deadUnits.push(unitName);
|
|
441
|
+
if (!dryRun) {
|
|
442
|
+
spawnSync('systemctl', ['--user', 'stop', unitName], {
|
|
443
|
+
stdio: 'ignore',
|
|
444
|
+
timeout: 10_000,
|
|
445
|
+
});
|
|
446
|
+
spawnSync('systemctl', ['--user', 'reset-failed', unitName], {
|
|
447
|
+
stdio: 'ignore',
|
|
448
|
+
timeout: 5_000,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
auditLog(cleoDir, 'stop-dead-scope', { unit: unitName, subState, cgroupProcs: procs.length }, dryRun);
|
|
452
|
+
stopped++;
|
|
453
|
+
}
|
|
454
|
+
return { stopped, deadUnits };
|
|
455
|
+
}
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// Category 3: Stale PID lock files (Amendment 5)
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
/**
|
|
460
|
+
* Reclaim stale PID-based lock files whose recorded PID is dead.
|
|
461
|
+
*
|
|
462
|
+
* Covers:
|
|
463
|
+
* - `.cleo/sentient.lock`
|
|
464
|
+
* - `.cleo/gc.lock`
|
|
465
|
+
*
|
|
466
|
+
* NEVER reclaims on mtime alone — liveness probe first (Amendment 5).
|
|
467
|
+
* Truncates rather than deletes (matching the `acquireLock` reclaim pattern in
|
|
468
|
+
* `sentient/daemon.ts:593`).
|
|
469
|
+
*/
|
|
470
|
+
function reclaimStalePidLocks(opts) {
|
|
471
|
+
const { cleoDir, dryRun } = opts;
|
|
472
|
+
const candidates = [join(cleoDir, 'sentient.lock'), join(cleoDir, 'gc.lock')];
|
|
473
|
+
let reclaimed = 0;
|
|
474
|
+
for (const lockPath of candidates) {
|
|
475
|
+
if (!existsSync(lockPath))
|
|
476
|
+
continue;
|
|
477
|
+
try {
|
|
478
|
+
const contents = readFileSync(lockPath, 'utf-8').trim();
|
|
479
|
+
const pid = Number.parseInt(contents, 10);
|
|
480
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
481
|
+
continue;
|
|
482
|
+
// Amendment 5: liveness probe — skip if alive
|
|
483
|
+
if (isPidAlive(pid))
|
|
484
|
+
continue;
|
|
485
|
+
// PID dead → stale lock
|
|
486
|
+
if (!dryRun) {
|
|
487
|
+
writeFileSync(lockPath, '', 'utf-8');
|
|
488
|
+
}
|
|
489
|
+
auditLog(cleoDir, 'reclaim-pid-lock', { path: lockPath, stalePid: pid }, dryRun);
|
|
490
|
+
reclaimed++;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// unreadable — skip
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return reclaimed;
|
|
497
|
+
}
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// Category 4: Stale tool-semaphore slot directories (Amendment 5)
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
/**
|
|
502
|
+
* Clear stale proper-lockfile lock-directories under getCleoHome()/locks/tool-star.
|
|
503
|
+
*
|
|
504
|
+
* proper-lockfile creates slot.lock/ directories. A slot is stale when:
|
|
505
|
+
* 1. Its mtime is older than staleMs, AND
|
|
506
|
+
* 2. The pid file inside (written by proper-lockfile) records a dead PID.
|
|
507
|
+
*
|
|
508
|
+
* Amendment 5: if the PID inside is alive, skip even if mtime is old.
|
|
509
|
+
*/
|
|
510
|
+
function clearStaleSemaphoreSlots(opts) {
|
|
511
|
+
const { dryRun, cleoDir, staleMs = DEFAULT_SEMAPHORE_STALE_MS } = opts;
|
|
512
|
+
const locksRoot = join(getCleoHome(), 'locks');
|
|
513
|
+
if (!existsSync(locksRoot))
|
|
514
|
+
return 0;
|
|
515
|
+
let cleared = 0;
|
|
516
|
+
const now = Date.now();
|
|
517
|
+
try {
|
|
518
|
+
for (const toolDir of readdirSync(locksRoot)) {
|
|
519
|
+
if (!toolDir.startsWith('tool-'))
|
|
520
|
+
continue;
|
|
521
|
+
const toolPath = join(locksRoot, toolDir);
|
|
522
|
+
try {
|
|
523
|
+
if (!statSync(toolPath).isDirectory())
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
for (const entry of readdirSync(toolPath)) {
|
|
531
|
+
if (!entry.endsWith('.lock'))
|
|
532
|
+
continue;
|
|
533
|
+
const lockDirPath = join(toolPath, entry);
|
|
534
|
+
try {
|
|
535
|
+
const st = statSync(lockDirPath);
|
|
536
|
+
if (!st.isDirectory())
|
|
537
|
+
continue;
|
|
538
|
+
if (now - st.mtimeMs < staleMs)
|
|
539
|
+
continue;
|
|
540
|
+
// Amendment 5: read the pid file inside the lock directory
|
|
541
|
+
const pidFile = join(lockDirPath, 'pid');
|
|
542
|
+
if (existsSync(pidFile)) {
|
|
543
|
+
try {
|
|
544
|
+
const pidStr = readFileSync(pidFile, 'utf-8').trim();
|
|
545
|
+
const pid = Number.parseInt(pidStr, 10);
|
|
546
|
+
if (Number.isFinite(pid) && pid > 0 && isPidAlive(pid))
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
// unreadable pid file — fall through to remove
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const ageMsLock = now - st.mtimeMs;
|
|
554
|
+
if (!dryRun) {
|
|
555
|
+
rmSync(lockDirPath, { recursive: true, force: true });
|
|
556
|
+
}
|
|
557
|
+
auditLog(cleoDir, 'clear-semaphore-slot', { lockDir: lockDirPath, ageMsLock }, dryRun);
|
|
558
|
+
cleared++;
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// stat/rm failed — skip
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// readdir failed — skip
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// locksRoot readdir failed
|
|
572
|
+
}
|
|
573
|
+
return cleared;
|
|
574
|
+
}
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// Public API
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
/**
|
|
579
|
+
* Run the CLEO janitor sweep.
|
|
580
|
+
*
|
|
581
|
+
* Runs all enabled categories and returns a structured result with per-category
|
|
582
|
+
* counts. Designed to be called from:
|
|
583
|
+
* - `cleo janitor run [--dry-run]` (manual verb)
|
|
584
|
+
* - GC subsystem sentient tick (daemon mode)
|
|
585
|
+
* - Session start/end (daemon-off lazy mode)
|
|
586
|
+
*
|
|
587
|
+
* @param opts - Janitor options.
|
|
588
|
+
* @returns Structured result.
|
|
589
|
+
*
|
|
590
|
+
* @task T11995
|
|
591
|
+
* @epic T11992
|
|
592
|
+
*/
|
|
593
|
+
export async function runJanitor(opts = {}) {
|
|
594
|
+
const { dryRun = false, gracePeriodMs = DEFAULT_GRACE_MS, worktreesRoot, activeTaskIds, skip = {}, } = opts;
|
|
595
|
+
// Resolve cleoDir
|
|
596
|
+
let cleoDir;
|
|
597
|
+
if (opts.cleoDir) {
|
|
598
|
+
cleoDir = opts.cleoDir;
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
try {
|
|
602
|
+
const { resolveCleoDir } = await import('../paths.js');
|
|
603
|
+
cleoDir = resolveCleoDir();
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
cleoDir = join(resolveOrCwd(), '.cleo');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Ensure audit dir (best-effort)
|
|
610
|
+
try {
|
|
611
|
+
mkdirSync(join(cleoDir, 'audit'), { recursive: true });
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// ignore
|
|
615
|
+
}
|
|
616
|
+
let reaped = 0;
|
|
617
|
+
let scopesStopped = 0;
|
|
618
|
+
let locksReclaimed = 0;
|
|
619
|
+
let semaphoreSlotsCleared = 0;
|
|
620
|
+
let worktreesPruned = 0;
|
|
621
|
+
let worktreesQuarantined = 0;
|
|
622
|
+
let tmpRemoved = 0;
|
|
623
|
+
let attachmentsRepaired = 0;
|
|
624
|
+
let configsRepaired = 0;
|
|
625
|
+
let errors = 0;
|
|
626
|
+
// ── Category 2: Scope inventory (must run before Category 1) ─────────────
|
|
627
|
+
let liveUnits = [];
|
|
628
|
+
let deadUnits = [];
|
|
629
|
+
if (!skip.scopes && process.platform === 'linux') {
|
|
630
|
+
try {
|
|
631
|
+
const allCleoUnits = listCleoScopeUnits();
|
|
632
|
+
const result = stopDeadScopes({ unitNames: allCleoUnits, dryRun, cleoDir });
|
|
633
|
+
scopesStopped = result.stopped;
|
|
634
|
+
deadUnits = result.deadUnits;
|
|
635
|
+
liveUnits = allCleoUnits.filter((u) => !deadUnits.includes(u));
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
errors++;
|
|
639
|
+
auditLog(cleoDir, 'error', { category: 'scopes', error: err instanceof Error ? err.message : String(err) }, dryRun);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// ── Category 1: Orphan process reaping ───────────────────────────────────
|
|
643
|
+
if (!skip.processes && process.platform === 'linux') {
|
|
644
|
+
try {
|
|
645
|
+
reaped = reapOrphanProcesses({ gracePeriodMs, dryRun, cleoDir, liveUnits, deadUnits });
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
errors++;
|
|
649
|
+
auditLog(cleoDir, 'error', { category: 'processes', error: err instanceof Error ? err.message : String(err) }, dryRun);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// ── Category 3: Stale PID locks ───────────────────────────────────────────
|
|
653
|
+
if (!skip.locks) {
|
|
654
|
+
try {
|
|
655
|
+
locksReclaimed = reclaimStalePidLocks({ cleoDir, dryRun });
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
errors++;
|
|
659
|
+
auditLog(cleoDir, 'error', { category: 'locks', error: err instanceof Error ? err.message : String(err) }, dryRun);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// ── Category 4: Semaphore slots ───────────────────────────────────────────
|
|
663
|
+
if (!skip.semaphores) {
|
|
664
|
+
try {
|
|
665
|
+
semaphoreSlotsCleared = clearStaleSemaphoreSlots({ dryRun, cleoDir });
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
errors++;
|
|
669
|
+
auditLog(cleoDir, 'error', { category: 'semaphores', error: err instanceof Error ? err.message : String(err) }, dryRun);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// ── Category 5/6: Worktrees ───────────────────────────────────────────────
|
|
673
|
+
if (!skip.worktrees && activeTaskIds !== undefined) {
|
|
674
|
+
try {
|
|
675
|
+
const wRoot = worktreesRoot ?? join(getCleoHome(), 'worktrees');
|
|
676
|
+
const result = pruneOrphanWorktrees({ worktreesRoot: wRoot, activeTaskIds, dryRun });
|
|
677
|
+
worktreesPruned = result.removed;
|
|
678
|
+
worktreesQuarantined = result.quarantined;
|
|
679
|
+
if (result.errors.length > 0)
|
|
680
|
+
errors += result.errors.length;
|
|
681
|
+
for (const p of result.removedPaths) {
|
|
682
|
+
auditLog(cleoDir, 'prune-worktree', { path: p }, dryRun);
|
|
683
|
+
}
|
|
684
|
+
for (const p of result.quarantinedPaths) {
|
|
685
|
+
auditLog(cleoDir, 'quarantine-worktree', { path: p }, dryRun);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
errors++;
|
|
690
|
+
auditLog(cleoDir, 'error', { category: 'worktrees', error: err instanceof Error ? err.message : String(err) }, dryRun);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// ── Category 7: Stale tmp dirs (Amendment 7) ──────────────────────────────
|
|
694
|
+
if (!skip.tmp) {
|
|
695
|
+
try {
|
|
696
|
+
const tmpResult = pruneOrphanTempDirs({ dryRun });
|
|
697
|
+
tmpRemoved = tmpResult.removed;
|
|
698
|
+
if (tmpResult.errors.length > 0)
|
|
699
|
+
errors += tmpResult.errors.length;
|
|
700
|
+
for (const p of tmpResult.removedPaths) {
|
|
701
|
+
auditLog(cleoDir, 'prune-tmp', { path: p }, dryRun);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
errors++;
|
|
706
|
+
auditLog(cleoDir, 'error', { category: 'tmp', error: err instanceof Error ? err.message : String(err) }, dryRun);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// ── Category 8: Attachment repair ────────────────────────────────────────
|
|
710
|
+
if (!skip.attachments) {
|
|
711
|
+
try {
|
|
712
|
+
const result = await repairAttachmentStore({ dryRun, cwd: join(cleoDir, '..') });
|
|
713
|
+
attachmentsRepaired = result.rowsWithoutFilesCount + result.unreferencedBlobsDeletedCount;
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
errors++;
|
|
717
|
+
auditLog(cleoDir, 'error', { category: 'attachments', error: err instanceof Error ? err.message : String(err) }, dryRun);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// ── Category 9: Config repair ─────────────────────────────────────────────
|
|
721
|
+
if (!skip.config) {
|
|
722
|
+
const projectRoot = join(cleoDir, '..');
|
|
723
|
+
const backupDir = join(cleoDir, 'backups');
|
|
724
|
+
const configCandidates = [join(cleoDir, 'config.json'), join(cleoDir, 'project-info.json')];
|
|
725
|
+
for (const configPath of configCandidates) {
|
|
726
|
+
if (!existsSync(configPath))
|
|
727
|
+
continue;
|
|
728
|
+
try {
|
|
729
|
+
// repairConfigFile(configPath, backupDir, cwd): positional args
|
|
730
|
+
const result = await repairConfigFile(configPath, existsSync(backupDir) ? backupDir : null, projectRoot);
|
|
731
|
+
if (result.outcome !== 'healthy') {
|
|
732
|
+
configsRepaired++;
|
|
733
|
+
auditLog(cleoDir, 'repair-config', { configPath, outcome: result.outcome, detail: result.detail }, dryRun);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
errors++;
|
|
738
|
+
auditLog(cleoDir, 'error', {
|
|
739
|
+
category: 'config',
|
|
740
|
+
configPath,
|
|
741
|
+
error: err instanceof Error ? err.message : String(err),
|
|
742
|
+
}, dryRun);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
reaped,
|
|
748
|
+
scopesStopped,
|
|
749
|
+
locksReclaimed,
|
|
750
|
+
semaphoreSlotsCleared,
|
|
751
|
+
worktreesPruned,
|
|
752
|
+
worktreesQuarantined,
|
|
753
|
+
tmpRemoved,
|
|
754
|
+
attachmentsRepaired,
|
|
755
|
+
configsRepaired,
|
|
756
|
+
errors,
|
|
757
|
+
dryRun,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=janitor.js.map
|