claude-code-session-manager 0.119.1 → 0.120.0
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/assets/{AgentLibrary-BpW0pGgk.js → AgentLibrary-LBg3mVL8.js} +1 -1
- package/dist/assets/{Button-DZX5guWb.js → Button-C4BN5ISR.js} +1 -1
- package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-CHhjk61s.js} +1 -1
- package/dist/assets/{History-D58whvn_.js → History-CL-T6Lv3.js} +1 -1
- package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-ByWVgXIf.js} +1 -1
- package/dist/assets/{Library-YqMi-vsA.js → Library-QvYVAEmY.js} +1 -1
- package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-FvNe6fo5.js} +1 -1
- package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-C7EmPm8B.js} +1 -1
- package/dist/assets/{Memory-BODDUBy4.js → Memory-nyLR4Yw4.js} +1 -1
- package/dist/assets/{Panel-CeOfUS9B.js → Panel-thElCbLX.js} +1 -1
- package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-FQ9GM6Ca.js} +1 -1
- package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-D9KE7XTI.js} +2 -2
- package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-lAViG8yV.js} +1 -1
- package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-AWpARk7N.js} +1 -1
- package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-c15EEGB_.js} +1 -1
- package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-FvduP1FG.js} +5 -5
- package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-BLep1rtB.js} +1 -1
- package/dist/assets/Settings-D1kTUIDB.js +4 -0
- package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-_40_f0t3.js} +1 -1
- package/dist/assets/{Skills-BeCMBQ3l.js → Skills-DaTkSOuU.js} +1 -1
- package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-DxNf-soM.js} +1 -1
- package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-L6KpBV-x.js} +1 -1
- package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-C_XjtJ82.js} +1 -1
- package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-H0rERKNI.js} +1 -1
- package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-ZUWphDat.js} +1 -1
- package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-BxFKOztb.js} +1 -1
- package/dist/assets/{index-DdEMkmbi.js → index-B5wj0DEX.js} +8 -8
- package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
- package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-BRcYkXJN.js} +1 -1
- package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-DFGp4DST.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-VvrgalfC.js} +1 -1
- package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-mvXKNEE3.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +11 -8
- package/src/main/crashDiagnostics.cjs +18 -13
- package/src/main/health.cjs +129 -4
- package/src/main/index.cjs +57 -18
- package/src/main/ipcSchemas.cjs +3 -3
- package/src/main/lib/activeSessions.cjs +51 -34
- package/src/main/lib/claude/agentModelResolve.cjs +5 -3
- package/src/main/lib/claude/personaToolScope.cjs +68 -0
- package/src/main/lib/epic/archivedTranscript.cjs +91 -0
- package/src/main/lib/epic/archivedTranscriptMigration.cjs +84 -0
- package/src/main/lib/epic/delegationReadiness.cjs +2 -0
- package/src/main/lib/epic/epicSettle.cjs +21 -5
- package/src/main/lib/epic/epicValidationHook.cjs +7 -0
- package/src/main/lib/git/depHealth.cjs +161 -0
- package/src/main/lib/git/worktreeDeps.cjs +158 -5
- package/src/main/lib/machineProfile.cjs +2 -0
- package/src/main/lib/mcpToolCatalog.cjs +3 -1
- package/src/main/lib/opsRetentionSettings.cjs +183 -0
- package/src/main/lib/prd/prdWorktreeReach.cjs +2 -2
- package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
- package/src/main/lib/process/procGroupRss.cjs +61 -0
- package/src/main/lib/scheduler/queueHistory.cjs +68 -9
- package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
- package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
- package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
- package/src/main/lib/telemetry/auditLog.cjs +51 -6
- package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
- package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
- package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
- package/src/main/scheduler/prdParser.cjs +50 -0
- package/src/main/scheduler/prdPaths.cjs +61 -16
- package/src/main/scheduler/reviewLadder.cjs +24 -1
- package/src/main/scheduler.cjs +214 -37
- package/src/main/templates/PRD_AUTHORING.md +2 -2
- package/src/preload/api.d.ts +28 -3
- package/src/preload/index.cjs +7 -0
- package/dist/assets/Settings-B4LuoRnd.js +0 -4
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opsRetentionSettings — machine-level retention policy for the ops sweeps.
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.claude/session-manager/ops-retention.json
|
|
5
|
+
* Shape (DEFAULTS): { runLogs:{maxAgeDays,keepPerSlug}, runsArchiveDays, auditLogMonths,
|
|
6
|
+
* reservedMarkersKeep }
|
|
7
|
+
* Archived transcripts are kept forever (fixed policy, not a setting).
|
|
8
|
+
*
|
|
9
|
+
* Top-level in lib/ (no domain fits: read by scheduler, telemetry and epic sweeps alike).
|
|
10
|
+
* preview() runs the sweeps' own eligibility code in dry-run mode — it deletes nothing.
|
|
11
|
+
* Modelled on updaterSettings.cjs: frozen DEFAULTS, never-throwing read, strict write.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const atomicFs = require('./util/atomicFs.cjs');
|
|
19
|
+
|
|
20
|
+
const DEFAULTS = Object.freeze({
|
|
21
|
+
runLogs: Object.freeze({ maxAgeDays: 14, keepPerSlug: 3 }),
|
|
22
|
+
runsArchiveDays: 30,
|
|
23
|
+
auditLogMonths: 6,
|
|
24
|
+
reservedMarkersKeep: 100,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// [min, max] per integer field; out-of-range values clamp, non-integers round.
|
|
28
|
+
const BOUNDS = Object.freeze({
|
|
29
|
+
'runLogs.maxAgeDays': [3, 3650],
|
|
30
|
+
'runLogs.keepPerSlug': [1, 1000],
|
|
31
|
+
runsArchiveDays: [1, 3650],
|
|
32
|
+
auditLogMonths: [1, 120],
|
|
33
|
+
reservedMarkersKeep: [1, 100000],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function storePath() {
|
|
37
|
+
return path.join(require('./scheduler/schedulerPaths.cjs').claudeHome(), 'session-manager', 'ops-retention.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
41
|
+
|
|
42
|
+
function clamp(key, v) {
|
|
43
|
+
const [min, max] = BOUNDS[key];
|
|
44
|
+
return Math.min(max, Math.max(min, Math.round(v)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strict patch validation (used by write() and the IPC set handler). Returns a
|
|
49
|
+
* partial settings object with clamped integers; throws on unknown keys,
|
|
50
|
+
* or wrong types.
|
|
51
|
+
*/
|
|
52
|
+
function validatePatch(patch) {
|
|
53
|
+
if (!isObj(patch)) throw new Error('Invalid ops retention config');
|
|
54
|
+
const out = {};
|
|
55
|
+
const num = (key, v) => {
|
|
56
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`Invalid ops retention config: ${key}`);
|
|
57
|
+
return clamp(key, v);
|
|
58
|
+
};
|
|
59
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
60
|
+
if (k === 'runLogs') {
|
|
61
|
+
if (!isObj(v)) throw new Error('Invalid ops retention config: runLogs');
|
|
62
|
+
out.runLogs = {};
|
|
63
|
+
for (const [rk, rv] of Object.entries(v)) {
|
|
64
|
+
if (rk !== 'maxAgeDays' && rk !== 'keepPerSlug') throw new Error(`Invalid ops retention config: runLogs.${rk}`);
|
|
65
|
+
out.runLogs[rk] = num(`runLogs.${rk}`, rv);
|
|
66
|
+
}
|
|
67
|
+
} else if (k === 'runsArchiveDays' || k === 'auditLogMonths' || k === 'reservedMarkersKeep') {
|
|
68
|
+
out[k] = num(k, v);
|
|
69
|
+
} else {
|
|
70
|
+
throw new Error(`Invalid ops retention config: unknown key ${k}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lenient field-by-field sanitize of on-disk data: bad fields are dropped, not fatal. */
|
|
77
|
+
function sanitizeStored(data) {
|
|
78
|
+
if (!isObj(data)) return null;
|
|
79
|
+
const out = {};
|
|
80
|
+
const int = (key, v) => (typeof v === 'number' && Number.isFinite(v) ? clamp(key, v) : undefined);
|
|
81
|
+
if (isObj(data.runLogs)) {
|
|
82
|
+
const m = int('runLogs.maxAgeDays', data.runLogs.maxAgeDays);
|
|
83
|
+
const k = int('runLogs.keepPerSlug', data.runLogs.keepPerSlug);
|
|
84
|
+
if (m !== undefined || k !== undefined) {
|
|
85
|
+
out.runLogs = {
|
|
86
|
+
maxAgeDays: m !== undefined ? m : DEFAULTS.runLogs.maxAgeDays,
|
|
87
|
+
keepPerSlug: k !== undefined ? k : DEFAULTS.runLogs.keepPerSlug,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const key of ['runsArchiveDays', 'auditLogMonths', 'reservedMarkersKeep']) {
|
|
92
|
+
const v = int(key, data[key]);
|
|
93
|
+
if (v !== undefined) out[key] = v;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What is explicitly stored (sanitized), or null when absent/corrupt. Never throws. */
|
|
99
|
+
function readStored() {
|
|
100
|
+
try {
|
|
101
|
+
const raw = JSON.parse(fs.readFileSync(storePath(), 'utf8'));
|
|
102
|
+
return sanitizeStored(raw);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e.code !== 'ENOENT') console.warn('[opsRetentionSettings] read failed:', e.message);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function withDefaults(stored) {
|
|
110
|
+
const s = stored || {};
|
|
111
|
+
return {
|
|
112
|
+
runLogs: { ...DEFAULTS.runLogs, ...(s.runLogs || {}) },
|
|
113
|
+
runsArchiveDays: s.runsArchiveDays ?? DEFAULTS.runsArchiveDays,
|
|
114
|
+
auditLogMonths: s.auditLogMonths ?? DEFAULTS.auditLogMonths,
|
|
115
|
+
reservedMarkersKeep: s.reservedMarkersKeep ?? DEFAULTS.reservedMarkersKeep,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Effective settings. Never throws: missing/corrupt file yields DEFAULTS. */
|
|
120
|
+
function read() {
|
|
121
|
+
return withDefaults(readStored());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Validate + merge `patch` over the current settings and persist the full object. */
|
|
125
|
+
function write(patch) {
|
|
126
|
+
const valid = validatePatch(patch);
|
|
127
|
+
const cur = read();
|
|
128
|
+
const next = {
|
|
129
|
+
...cur,
|
|
130
|
+
...valid,
|
|
131
|
+
runLogs: { ...cur.runLogs, ...(valid.runLogs || {}) },
|
|
132
|
+
};
|
|
133
|
+
atomicFs.writeJsonAtomicSync(storePath(), next, { mode: 0o600 });
|
|
134
|
+
return next;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Dry-run report of what each policy would free, via the sweeps' own
|
|
139
|
+
* eligibility code. Read-only. opts: { now, runsDir, plansRoot, jobs, liveKeys }.
|
|
140
|
+
*/
|
|
141
|
+
function preview(opts) {
|
|
142
|
+
const o = opts || {};
|
|
143
|
+
const schedulerPaths = require('./scheduler/schedulerPaths.cjs');
|
|
144
|
+
const runLogRetention = require('./scheduler/runLogRetention.cjs');
|
|
145
|
+
const staleTmpSweep = require('./scheduler/staleTmpSweep.cjs');
|
|
146
|
+
const s = read();
|
|
147
|
+
const now = typeof o.now === 'number' ? o.now : Date.now();
|
|
148
|
+
const runsDir = o.runsDir || schedulerPaths.runsDir();
|
|
149
|
+
const plansRoot = o.plansRoot || schedulerPaths.scheduledPlansRoot();
|
|
150
|
+
|
|
151
|
+
let liveKeys = o.liveKeys;
|
|
152
|
+
if (!liveKeys) {
|
|
153
|
+
let jobs = o.jobs;
|
|
154
|
+
if (!jobs) {
|
|
155
|
+
try {
|
|
156
|
+
jobs = require('./scheduler/queueStore.cjs').readMergedSync().jobs || [];
|
|
157
|
+
} catch {
|
|
158
|
+
jobs = [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
liveKeys = runLogRetention.liveKeysFromJobs(jobs, { runsDir });
|
|
162
|
+
}
|
|
163
|
+
const report = runLogRetention.computeReport(runsDir, { ...s.runLogs }, { now, liveKeys });
|
|
164
|
+
const archive = staleTmpSweep.pruneRunsArchive(path.join(plansRoot, 'runs-archive'), {
|
|
165
|
+
maxAgeDays: s.runsArchiveDays,
|
|
166
|
+
now,
|
|
167
|
+
dryRun: true,
|
|
168
|
+
});
|
|
169
|
+
const tmp = staleTmpSweep.sweepStaleTmp(plansRoot, { now, dryRun: true });
|
|
170
|
+
return {
|
|
171
|
+
runLogs: { files: report.eligible.reduce((n, e) => n + e.removeFiles.length, 0) + report.dodFiles.length, bytes: report.eligibleSummary.bytes },
|
|
172
|
+
runsArchive: { entries: archive.removedFiles, bytes: archive.freedBytes },
|
|
173
|
+
tmp: { files: tmp.removedFiles, bytes: tmp.freedBytes },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function registerIpc(ipcMain) {
|
|
178
|
+
ipcMain.handle('opsRetention:get', () => read());
|
|
179
|
+
ipcMain.handle('opsRetention:set', (_e, patch) => write(patch));
|
|
180
|
+
ipcMain.handle('opsRetention:preview', () => preview());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { storePath, read, readStored, write, validatePatch, preview, registerIpc, DEFAULTS, BOUNDS };
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Incident: burrow PRD 1516-fb-bilko-first-pumpkin named a gitignored runtime path by RELATIVE
|
|
5
5
|
* path (downloads/fb-tracker.db) and an install-cron.sh that baked a worktree path. Scheduled
|
|
6
|
-
* PRDs run in an isolated git job worktree holding only tracked files plus symlinked
|
|
7
|
-
* dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
|
|
6
|
+
* PRDs run in an isolated git job worktree holding only tracked files plus symlinked or (pnpm) worktree-local
|
|
7
|
+
* dependency dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
|
|
8
8
|
* parked needs_human.
|
|
9
9
|
*
|
|
10
10
|
* Two exports: `extractRepoPathCandidates(text)` pulls repo-relative path-looking tokens out of
|
|
@@ -27,6 +27,8 @@ const WORK_TYPES = Object.freeze([
|
|
|
27
27
|
'discussion',
|
|
28
28
|
'build',
|
|
29
29
|
'project-home-builder',
|
|
30
|
+
'art',
|
|
31
|
+
'portfolio',
|
|
30
32
|
]);
|
|
31
33
|
|
|
32
34
|
const WorkTypeSchema = z.enum(WORK_TYPES);
|
|
@@ -37,17 +39,19 @@ const WorkTypeSchema = z.enum(WORK_TYPES);
|
|
|
37
39
|
// legacy-drop preprocessing.
|
|
38
40
|
const LEGACY_WORK_TYPES = Object.freeze(['bilko-host-publisher']);
|
|
39
41
|
|
|
40
|
-
//
|
|
42
|
+
// 'discussion' and 'portfolio' tickets never reach PRD authoring (a domain
|
|
41
43
|
// invariant unrelated to the Epic/PRD tag unification above), so the PRD
|
|
42
44
|
// frontmatter `tag` field — parsed in prdFrontmatter.cjs/.ts and patched via
|
|
43
|
-
// ipcSchemas.cjs's adminPrdFrontmatterPatch — excludes
|
|
45
|
+
// ipcSchemas.cjs's adminPrdFrontmatterPatch — excludes them even though
|
|
44
46
|
// WorkTypeSchema itself (used at PRD-create time) does not.
|
|
45
|
-
const
|
|
47
|
+
const NON_PRD_WORK_TYPES = Object.freeze(['discussion', 'portfolio']);
|
|
48
|
+
const PRD_WORK_TYPES = Object.freeze(WORK_TYPES.filter((t) => !NON_PRD_WORK_TYPES.includes(t)));
|
|
46
49
|
const PrdWorkTypeSchema = z.enum(PRD_WORK_TYPES);
|
|
47
50
|
|
|
48
51
|
module.exports = {
|
|
49
52
|
WORK_TYPES,
|
|
50
53
|
WorkTypeSchema,
|
|
54
|
+
NON_PRD_WORK_TYPES,
|
|
51
55
|
PRD_WORK_TYPES,
|
|
52
56
|
PrdWorkTypeSchema,
|
|
53
57
|
LEGACY_WORK_TYPES,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* procGroupRss.cjs — summed resident memory (MB) of every process in a
|
|
6
|
+
* process group, read from /proc (Linux only).
|
|
7
|
+
*
|
|
8
|
+
* Why: the scheduler's memory gate double-counted running jobs (2026-10-18).
|
|
9
|
+
* Each job reserves 2500 MB, but part of that is already consumed and thus
|
|
10
|
+
* already missing from MemAvailable; subtracting the full reservation again
|
|
11
|
+
* counts it twice. Scheduler jobs are spawned `detached: true`, so a job's
|
|
12
|
+
* pid leads its own process group and its tsc/vite/node children share that
|
|
13
|
+
* pgid — summing the group's RSS tells the gate how much is already used.
|
|
14
|
+
*
|
|
15
|
+
* Parsing: /proc/<pid>/stat is `pid (comm) state ppid pgrp ... rss ...` and
|
|
16
|
+
* comm may contain spaces and parentheses, so split only after the LAST ')'.
|
|
17
|
+
* In the remainder (0-based): state=0, ppid=1, pgrp=2, rss=21 (pages).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
|
|
22
|
+
const PGRP_IDX = 2;
|
|
23
|
+
const RSS_IDX = 21;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {number} pgid
|
|
27
|
+
* @param {{ procRoot?: string, pageSizeKb?: number }} [opts]
|
|
28
|
+
* @returns {number} integer MB (floor); 0 on any non-applicable/failed case
|
|
29
|
+
*/
|
|
30
|
+
function processGroupRssMb(pgid, opts = {}) {
|
|
31
|
+
if (!Number.isInteger(pgid) || pgid <= 0) return 0;
|
|
32
|
+
if (process.platform !== 'linux') return 0;
|
|
33
|
+
const procRoot = opts.procRoot ?? '/proc';
|
|
34
|
+
const pageSizeKb = opts.pageSizeKb ?? 4;
|
|
35
|
+
|
|
36
|
+
let names;
|
|
37
|
+
try {
|
|
38
|
+
names = fs.readdirSync(procRoot);
|
|
39
|
+
} catch {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let totalKb = 0;
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
if (!/^\d+$/.test(name)) continue;
|
|
46
|
+
try {
|
|
47
|
+
const stat = fs.readFileSync(`${procRoot}/${name}/stat`, 'utf8');
|
|
48
|
+
const close = stat.lastIndexOf(')');
|
|
49
|
+
if (close < 0) continue;
|
|
50
|
+
const fields = stat.slice(close + 1).trim().split(/\s+/);
|
|
51
|
+
if (Number(fields[PGRP_IDX]) !== pgid) continue;
|
|
52
|
+
const rssPages = Number(fields[RSS_IDX]);
|
|
53
|
+
if (Number.isFinite(rssPages) && rssPages > 0) totalKb += rssPages * pageSizeKb;
|
|
54
|
+
} catch {
|
|
55
|
+
// vanished or unreadable entry — skip it
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Math.floor(totalKb / 1024);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { processGroupRssMb };
|
|
@@ -111,9 +111,72 @@ function partitionJobs(jobs, nowMs, opts = {}) {
|
|
|
111
111
|
return { hot, toArchive };
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
const SLIM_ARRAY_FIELDS = ['preRunDirtyPaths', 'guardBaseline', 'leftoverPaths'];
|
|
115
|
+
const SALVAGE_PATCH_MAX_BYTES = 2048;
|
|
116
|
+
// Dedupe only needs recent rows: a crash-replay re-submits the batch that was
|
|
117
|
+
// just appended, so the tail of the file always contains its keys.
|
|
118
|
+
const DEDUPE_TAIL_BYTES = 2 * 1024 * 1024;
|
|
119
|
+
|
|
114
120
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
121
|
+
* Pure: returns a copy of `row` with the bulky snapshot arrays
|
|
122
|
+
* (preRunDirtyPaths, guardBaseline, leftoverPaths) replaced by `<field>Count`,
|
|
123
|
+
* and a salvagePatch string over 2 KB replaced by `salvagePatchBytes`. Every
|
|
124
|
+
* other field passes through unchanged. Rows without those fields are
|
|
125
|
+
* returned as-is (same shape, no extra keys).
|
|
126
|
+
*/
|
|
127
|
+
function slimHistoryRow(row) {
|
|
128
|
+
if (!row || typeof row !== 'object') return row;
|
|
129
|
+
let out = row;
|
|
130
|
+
const slim = () => (out === row ? (out = { ...row }) : out);
|
|
131
|
+
for (const f of SLIM_ARRAY_FIELDS) {
|
|
132
|
+
if (Array.isArray(row[f])) {
|
|
133
|
+
const o = slim();
|
|
134
|
+
o[`${f}Count`] = row[f].length;
|
|
135
|
+
delete o[f];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (typeof row.salvagePatch === 'string') {
|
|
139
|
+
const bytes = Buffer.byteLength(row.salvagePatch, 'utf8');
|
|
140
|
+
if (bytes > SALVAGE_PATCH_MAX_BYTES) {
|
|
141
|
+
const o = slim();
|
|
142
|
+
o.salvagePatchBytes = bytes;
|
|
143
|
+
delete o.salvagePatch;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Text of the last `maxBytes` of a file, cut to whole lines ('' if absent). */
|
|
150
|
+
async function readTailText(target, maxBytes) {
|
|
151
|
+
let fh;
|
|
152
|
+
try {
|
|
153
|
+
fh = await fsp.open(target, 'r');
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (e.code === 'ENOENT') return '';
|
|
156
|
+
throw e;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const { size } = await fh.stat();
|
|
160
|
+
if (size === 0) return '';
|
|
161
|
+
const len = Math.min(maxBytes, size);
|
|
162
|
+
const start = size - len;
|
|
163
|
+
const buf = Buffer.alloc(len);
|
|
164
|
+
await fh.read(buf, 0, len, start);
|
|
165
|
+
let text = buf.toString('utf8');
|
|
166
|
+
if (start > 0) {
|
|
167
|
+
// Window may begin mid-line; drop the partial first fragment.
|
|
168
|
+
const nl = text.indexOf('\n');
|
|
169
|
+
text = nl === -1 ? '' : text.slice(nl + 1);
|
|
170
|
+
}
|
|
171
|
+
return text;
|
|
172
|
+
} finally {
|
|
173
|
+
await fh.close();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Append-only JSONL write, deduped by slug+runId against the last 2 MB of
|
|
179
|
+
* what's already on disk. Rows are slimmed (slimHistoryRow) before writing. The dedupe exists for crash-safety: reconcile() appends BEFORE
|
|
117
180
|
* dropping archived jobs from state.jobs, so a crash between those two steps
|
|
118
181
|
* replays the same batch through partitionJobs on the next boot — dedupe
|
|
119
182
|
* here is what stops that replay from double-writing history.
|
|
@@ -133,12 +196,7 @@ async function appendHistory(entries) {
|
|
|
133
196
|
|
|
134
197
|
let appended = 0;
|
|
135
198
|
for (const [target, group] of byPath) {
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
existingText = await fsp.readFile(target, 'utf8');
|
|
139
|
-
} catch (e) {
|
|
140
|
-
if (e.code !== 'ENOENT') throw e;
|
|
141
|
-
}
|
|
199
|
+
const existingText = await readTailText(target, DEDUPE_TAIL_BYTES);
|
|
142
200
|
|
|
143
201
|
const existingKeys = new Set();
|
|
144
202
|
if (existingText) {
|
|
@@ -156,7 +214,7 @@ async function appendHistory(entries) {
|
|
|
156
214
|
if (fresh.length === 0) continue;
|
|
157
215
|
|
|
158
216
|
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
159
|
-
const text = fresh.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
217
|
+
const text = fresh.map((e) => JSON.stringify(slimHistoryRow(e))).join('\n') + '\n';
|
|
160
218
|
await fsp.appendFile(target, text, 'utf8');
|
|
161
219
|
appended += fresh.length;
|
|
162
220
|
}
|
|
@@ -407,6 +465,7 @@ module.exports = {
|
|
|
407
465
|
historyPath,
|
|
408
466
|
partitionJobs,
|
|
409
467
|
appendHistory,
|
|
468
|
+
slimHistoryRow,
|
|
410
469
|
readHistory,
|
|
411
470
|
historyTerminalBySlug,
|
|
412
471
|
completedSlugsForCwd,
|
|
@@ -24,21 +24,32 @@
|
|
|
24
24
|
* directory from being fully removed, but never blocks removal of a claimed
|
|
25
25
|
* slug's own files.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
27
|
+
* FILE KINDS. Each (runId, slug) entry owns two kinds of file:
|
|
28
|
+
* - BULK: `<slug>.log`, `<slug>.supervisor.json`, `<slug>*.patch`,
|
|
29
|
+
* `root-cause-<slug>.md` — the space hogs.
|
|
30
|
+
* - METADATA: `<slug>.meta.json`, `<slug>.verdicts.json`,
|
|
31
|
+
* `<slug>.outcome.json` — tiny; terminalRunOutcome.cjs reads the newest
|
|
32
|
+
* run's copies as the re-run guard fallback.
|
|
33
|
+
*
|
|
34
|
+
* SEMANTICS (age-driven):
|
|
28
35
|
* - Retention is ON by default (DEFAULT_RUN_LOG_RETENTION) when settings
|
|
29
36
|
* carry no `schedulerRunLogRetention`. An explicit `enabled: false` (or a
|
|
30
37
|
* config with no non-empty policy) opts out: computeReport, and
|
|
31
38
|
* applyRetention when opted out, only READ the filesystem.
|
|
32
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* -
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* - A LIVE entry (status pending/running/needs_review/investigating — see
|
|
40
|
+
* LIVE_STATUSES) loses nothing, regardless of age or count.
|
|
41
|
+
* - With `maxAgeDays` set, age alone decides: a non-live entry older than
|
|
42
|
+
* maxAgeDays loses its BULK files even when it is the slug's newest/only
|
|
43
|
+
* run. `keepPerSlug` applies within the age window only — entries younger
|
|
44
|
+
* than maxAgeDays are always kept, so it never rescues an old entry.
|
|
45
|
+
* - With only `keepPerSlug` set (no maxAgeDays), bulk files of entries ranked
|
|
46
|
+
* beyond the newest N are eligible.
|
|
47
|
+
* - METADATA is removed only from entries that are bulk-eligible AND not the
|
|
48
|
+
* slug's newest run: the newest run of every slug always keeps its
|
|
49
|
+
* meta/verdicts/outcome files.
|
|
50
|
+
* - A directory left holding only definition-of-done-*.md files (batch-keyed,
|
|
51
|
+
* claimed by no entry) whose mtime is older than maxAgeDays is removed with
|
|
52
|
+
* them; a directory left empty is removed.
|
|
42
53
|
*/
|
|
43
54
|
|
|
44
55
|
const fs = require('node:fs');
|
|
@@ -135,19 +146,26 @@ function scanRunEntries(runsDir) {
|
|
|
135
146
|
const metaFiles = files.filter((f) => f.endsWith(META_SUFFIX));
|
|
136
147
|
for (const metaFile of metaFiles) {
|
|
137
148
|
const slug = metaFile.slice(0, -META_SUFFIX.length);
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
const isBulk = (f) =>
|
|
150
|
+
f === `${slug}.log` ||
|
|
151
|
+
f === `${slug}.supervisor.json` ||
|
|
152
|
+
f === `root-cause-${slug}.md` ||
|
|
153
|
+
(f.startsWith(`${slug}.`) && f.endsWith('.patch'));
|
|
154
|
+
const isMetadata = (f) =>
|
|
155
|
+
f === metaFile || f === `${slug}.verdicts.json` || f === `${slug}.outcome.json`;
|
|
156
|
+
const relatedNames = files.filter((f) => isBulk(f) || isMetadata(f));
|
|
141
157
|
|
|
142
158
|
let sizeBytes = 0;
|
|
143
159
|
let mtimeMs = null;
|
|
144
160
|
const filePaths = [];
|
|
161
|
+
const fileInfos = [];
|
|
145
162
|
for (const name of relatedNames) {
|
|
146
163
|
const fp = path.join(dir, name);
|
|
147
164
|
filePaths.push(fp);
|
|
148
165
|
try {
|
|
149
166
|
const st = fs.statSync(fp);
|
|
150
167
|
sizeBytes += st.size;
|
|
168
|
+
fileInfos.push({ path: fp, size: st.size, kind: isBulk(name) ? 'bulk' : 'metadata' });
|
|
151
169
|
if (mtimeMs === null || st.mtimeMs > mtimeMs) mtimeMs = st.mtimeMs;
|
|
152
170
|
} catch {
|
|
153
171
|
// file vanished mid-scan — skip its contribution
|
|
@@ -168,6 +186,7 @@ function scanRunEntries(runsDir) {
|
|
|
168
186
|
slug,
|
|
169
187
|
dir,
|
|
170
188
|
files: filePaths,
|
|
189
|
+
fileInfos,
|
|
171
190
|
sizeBytes,
|
|
172
191
|
timeMs: recordedAtMs ?? mtimeMs ?? 0,
|
|
173
192
|
});
|
|
@@ -206,10 +225,23 @@ function computeEligibility(entries, policy, opts) {
|
|
|
206
225
|
const isMostRecent = rank === 0;
|
|
207
226
|
const isLive = liveKeys.has(`${e.slug}|${e.runId}`);
|
|
208
227
|
const ageDays = (now - e.timeMs) / DAY_MS;
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
|
|
228
|
+
// Age alone decides when set; keepPerSlug only governs count-only policies.
|
|
229
|
+
const retained = maxAgeDays != null ? !(ageDays > maxAgeDays) : !(rank >= keepPerSlug);
|
|
230
|
+
const bulkEligible = hasPolicy && !isLive && !retained;
|
|
231
|
+
const metaEligible = bulkEligible && !isMostRecent;
|
|
232
|
+
const infos = e.fileInfos || [];
|
|
233
|
+
const removeFiles = infos.filter((f) => (f.kind === 'bulk' ? bulkEligible : metaEligible));
|
|
234
|
+
return {
|
|
235
|
+
...e,
|
|
236
|
+
ageDays,
|
|
237
|
+
rank,
|
|
238
|
+
isMostRecent,
|
|
239
|
+
isLive,
|
|
240
|
+
bulkEligible,
|
|
241
|
+
metaEligible,
|
|
242
|
+
removeFiles,
|
|
243
|
+
eligible: removeFiles.length > 0,
|
|
244
|
+
};
|
|
213
245
|
});
|
|
214
246
|
}
|
|
215
247
|
|
|
@@ -228,27 +260,66 @@ function computeReport(runsDir, policy, opts) {
|
|
|
228
260
|
const oldestRunAt = entries.length ? Math.min(...entries.map((e) => e.timeMs)) : null;
|
|
229
261
|
|
|
230
262
|
const eligible = evaluated.filter((e) => e.eligible);
|
|
231
|
-
|
|
263
|
+
let bulkBytes = 0;
|
|
264
|
+
let metadataBytes = 0;
|
|
265
|
+
for (const e of eligible) {
|
|
266
|
+
for (const f of e.removeFiles) {
|
|
267
|
+
if (f.kind === 'bulk') bulkBytes += f.size;
|
|
268
|
+
else metadataBytes += f.size;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A directory is fully removable when, after the eligible files are
|
|
273
|
+
// unlinked, nothing remains except definition-of-done reports older than
|
|
274
|
+
// maxAgeDays (which go with it). Any other leftover (a non-eligible entry's
|
|
275
|
+
// files, an unclaimed file, a young DoD report) keeps the directory alive.
|
|
276
|
+
const maxAgeDays = policy && policy.maxAgeDays;
|
|
277
|
+
const removing = new Set();
|
|
278
|
+
for (const e of eligible) for (const f of e.removeFiles) removing.add(f.path);
|
|
232
279
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
280
|
+
let allDirs;
|
|
281
|
+
try {
|
|
282
|
+
allDirs = fs.readdirSync(runsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(runsDir, d.name));
|
|
283
|
+
} catch {
|
|
284
|
+
allDirs = [];
|
|
285
|
+
}
|
|
237
286
|
const removableDirs = [];
|
|
238
|
-
|
|
287
|
+
const dodFiles = [];
|
|
288
|
+
for (const dir of allDirs) {
|
|
239
289
|
let actualFiles;
|
|
240
290
|
try {
|
|
241
291
|
actualFiles = fs.readdirSync(dir);
|
|
242
292
|
} catch {
|
|
243
293
|
continue;
|
|
244
294
|
}
|
|
245
|
-
if (actualFiles.length === 0) continue;
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
295
|
+
if (actualFiles.length === 0) continue; // empty orphans: sweepOrphanDirs
|
|
296
|
+
const dirDod = [];
|
|
297
|
+
let removable = true;
|
|
298
|
+
for (const f of actualFiles) {
|
|
299
|
+
const fp = path.join(dir, f);
|
|
300
|
+
if (removing.has(fp)) continue;
|
|
301
|
+
if (maxAgeDays != null && /^definition-of-done-.*\.md$/.test(f)) {
|
|
302
|
+
try {
|
|
303
|
+
const st = fs.statSync(fp);
|
|
304
|
+
if ((now - st.mtimeMs) / DAY_MS > maxAgeDays) {
|
|
305
|
+
dirDod.push({ path: fp, size: st.size, kind: 'metadata' });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
// vanished — treat as gone
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
removable = false;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
if (removable) {
|
|
317
|
+
removableDirs.push(dir);
|
|
318
|
+
for (const d of dirDod) {
|
|
319
|
+
dodFiles.push(d);
|
|
320
|
+
metadataBytes += d.size;
|
|
321
|
+
}
|
|
250
322
|
}
|
|
251
|
-
if (actualFiles.every((f) => claimedEligible.has(f))) removableDirs.push(dir);
|
|
252
323
|
}
|
|
253
324
|
|
|
254
325
|
return {
|
|
@@ -262,16 +333,30 @@ function computeReport(runsDir, policy, opts) {
|
|
|
262
333
|
oldestRunAt,
|
|
263
334
|
},
|
|
264
335
|
eligible,
|
|
265
|
-
eligibleSummary: {
|
|
336
|
+
eligibleSummary: {
|
|
337
|
+
count: eligible.length,
|
|
338
|
+
bytes: bulkBytes + metadataBytes,
|
|
339
|
+
bulkBytes,
|
|
340
|
+
metadataBytes,
|
|
341
|
+
},
|
|
342
|
+
dodFiles,
|
|
266
343
|
removableDirs,
|
|
267
344
|
};
|
|
268
345
|
}
|
|
269
346
|
|
|
270
347
|
/**
|
|
271
|
-
* The effective retention config:
|
|
272
|
-
* when present, else DEFAULT_RUN_LOG_RETENTION.
|
|
348
|
+
* The effective retention config: ops-retention.json `runLogs` when stored,
|
|
349
|
+
* else the caller's `schedulerRunLogRetention` when present, else DEFAULT_RUN_LOG_RETENTION.
|
|
273
350
|
*/
|
|
274
351
|
function resolveRetentionConfig(settings) {
|
|
352
|
+
// Machine-level ops-retention.json wins over the legacy scheduler-machine.json key.
|
|
353
|
+
// Lazy require: opsRetentionSettings requires this module (preview).
|
|
354
|
+
try {
|
|
355
|
+
const stored = require('../opsRetentionSettings.cjs').readStored();
|
|
356
|
+
if (stored && stored.runLogs) return { enabled: true, policy: { ...stored.runLogs } };
|
|
357
|
+
} catch {
|
|
358
|
+
// unreadable settings never block retention — fall through to legacy/default
|
|
359
|
+
}
|
|
275
360
|
const cfg = settings && settings.schedulerRunLogRetention;
|
|
276
361
|
return cfg == null ? DEFAULT_RUN_LOG_RETENTION : cfg;
|
|
277
362
|
}
|
|
@@ -409,16 +494,17 @@ function applyRetention(runsDir, settings, opts) {
|
|
|
409
494
|
let freedBytes = 0;
|
|
410
495
|
const errors = [];
|
|
411
496
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
497
|
+
const toRemove = [];
|
|
498
|
+
for (const entry of report.eligible) for (const f of entry.removeFiles) toRemove.push(f.path);
|
|
499
|
+
for (const f of report.dodFiles) toRemove.push(f.path);
|
|
500
|
+
for (const fp of toRemove) {
|
|
501
|
+
try {
|
|
502
|
+
const st = fs.statSync(fp);
|
|
503
|
+
fs.unlinkSync(fp);
|
|
504
|
+
removedFiles += 1;
|
|
505
|
+
freedBytes += st.size;
|
|
506
|
+
} catch (e) {
|
|
507
|
+
if (e && e.code !== 'ENOENT') errors.push({ path: fp, error: e.message });
|
|
422
508
|
}
|
|
423
509
|
}
|
|
424
510
|
|
|
@@ -430,9 +430,12 @@ function enqueueTimestamp(job) {
|
|
|
430
430
|
* nothing. A single project can therefore never drain every free slot while
|
|
431
431
|
* another project with eligible work gets none in the same tick.
|
|
432
432
|
* 1. fewest jobs currently `running` in that project, ascending;
|
|
433
|
-
* 2. tiebreak: the oldest pending job's enqueue timestamp
|
|
433
|
+
* 2. tiebreak: the oldest DISPATCHABLE pending job's enqueue timestamp
|
|
434
434
|
* (enqueueTimestamp — createdAt → queuedAt → statusHistory → startedAt),
|
|
435
|
-
* ascending, unprovable ages last
|
|
435
|
+
* ascending, unprovable ages last. Rows blocked by an unfinished
|
|
436
|
+
* dependsOn do NOT count (a project with only blocked rows gets
|
|
437
|
+
* Infinity). 2026-10-18: Game-Academy's dep-blocked 02:16Z row kept it
|
|
438
|
+
* ahead of Self's runnable 02:50Z rows, so it took every freed slot.
|
|
436
439
|
* 3. final deterministic tiebreak: the project cwd string.
|
|
437
440
|
*
|
|
438
441
|
* WHY NOT THE PRD NUMBER. The previous sort key was each project's lowest
|
|
@@ -576,8 +579,9 @@ function pickNextBatch(allJobs, running, freeSlots, quietOpts = {}) {
|
|
|
576
579
|
projectJobs.filter((j) => running.has(j.slug)).map((j) => j.slug),
|
|
577
580
|
);
|
|
578
581
|
const runningCount = projectJobs.filter((j) => j.status === 'running').length;
|
|
582
|
+
const satisfied = satisfiedSlugsFor(cwd);
|
|
579
583
|
const oldestPendingMs = projectJobs
|
|
580
|
-
.filter(pickable)
|
|
584
|
+
.filter((j) => pickable(j) && !findBlockingDep(j, projectJobs, satisfied))
|
|
581
585
|
.reduce((min, j) => Math.min(min, enqueueTimestamp(j)), Infinity);
|
|
582
586
|
candidates.push({ cwd, view, runningSlugsInProject, runningCount, oldestPendingMs, active: true });
|
|
583
587
|
}
|