claude-code-session-manager 0.119.1 → 0.121.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-FO2ei2wB.js} +1 -1
- package/dist/assets/{Button-DZX5guWb.js → Button-CeIOXUPU.js} +1 -1
- package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-D0p__Hil.js} +1 -1
- package/dist/assets/{History-D58whvn_.js → History-DVqQ5azN.js} +1 -1
- package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-DkTnRHyT.js} +1 -1
- package/dist/assets/{Library-YqMi-vsA.js → Library-l7KS6pkE.js} +1 -1
- package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-DDw8fuRT.js} +1 -1
- package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-UUNIxk_3.js} +1 -1
- package/dist/assets/{Memory-BODDUBy4.js → Memory-CNYUoQ8C.js} +1 -1
- package/dist/assets/{Panel-CeOfUS9B.js → Panel-Bb54soc2.js} +1 -1
- package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-D-dNdV3f.js} +1 -1
- package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-DhcIXRhQ.js} +2 -2
- package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-DEAkDNjA.js} +1 -1
- package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-xCEgvTGU.js} +1 -1
- package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-BSEs7ecA.js} +1 -1
- package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-BIKJIU-C.js} +5 -5
- package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-DcaFyAV-.js} +1 -1
- package/dist/assets/Settings-vcdWy_Jx.js +4 -0
- package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-CLrJWTPd.js} +1 -1
- package/dist/assets/{Skills-BeCMBQ3l.js → Skills-CYM4rBEC.js} +1 -1
- package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-BWNIu-iY.js} +1 -1
- package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-CYdwHK6m.js} +1 -1
- package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-Kt90lHrD.js} +1 -1
- package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-D1OXWYOz.js} +1 -1
- package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-BeQR3CKh.js} +1 -1
- package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-B0qafrRo.js} +1 -1
- package/dist/assets/{index-DdEMkmbi.js → index-Bq9uH08R.js} +201 -201
- package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
- package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-0ZksSAPB.js} +1 -1
- package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-C0nh8yfa.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-DPkoBybS.js} +1 -1
- package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-BDJMUihy.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 +54 -14
- 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 +18 -4
- 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/persona-macro/macroLibrary.cjs +75 -0
- package/src/main/lib/prd/prdCreate.cjs +25 -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/remoteTranscriptTail.cjs +69 -2
- 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/main/webRemote.cjs +82 -91
- package/src/preload/api.d.ts +41 -3
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-B4LuoRnd.js +0 -4
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Bounded-tail reader for Claude Code JSONL transcripts: the last N human-readable
|
|
4
4
|
// chat messages (user prompts + assistant text) without reading the whole file.
|
|
5
|
+
// Also builds the phone's conversation page (exchanges + live tail) in pure code.
|
|
5
6
|
// Top-level (no domain fits: epic/ transcripts are path-only; util/ is domain-free).
|
|
6
7
|
// Transcripts reach hundreds of MB, so cost is O(maxBytes), never O(file).
|
|
7
8
|
|
|
@@ -11,6 +12,10 @@ const DEFAULT_LIMIT = 30;
|
|
|
11
12
|
const MAX_LIMIT = 100;
|
|
12
13
|
const DEFAULT_MAX_BYTES = 524288;
|
|
13
14
|
const MAX_TEXT_CHARS = 4000;
|
|
15
|
+
const MAX_PROMPT_CHARS = 2000;
|
|
16
|
+
const MAX_RESULT_CHARS = 16000;
|
|
17
|
+
const MAX_LIVE_MESSAGES = 20;
|
|
18
|
+
const MAX_PAGE_TEXT_BYTES = 100000; // relay closes any message over 256 KB
|
|
14
19
|
|
|
15
20
|
/** Join the plain-text parts of a message `content` (string or block array). */
|
|
16
21
|
function textOf(content) {
|
|
@@ -22,8 +27,12 @@ function textOf(content) {
|
|
|
22
27
|
.join('\n');
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
function cap(text, max) {
|
|
31
|
+
return text.length > max ? text.slice(0, max - 1) + '…' : text;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
function truncate(text) {
|
|
26
|
-
return text
|
|
35
|
+
return cap(text, MAX_TEXT_CHARS);
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
/** Parse one JSONL line into a chat message, or null when it is not one. */
|
|
@@ -87,4 +96,62 @@ async function readRecentChatMessages(filePath, { limit = DEFAULT_LIMIT, maxByte
|
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
|
|
90
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Pure: build one phone-ready conversation page from exchanges (NEWEST-FIRST,
|
|
101
|
+
* callers fetch `limit + 1` so `hasMore` is derivable) plus the live transcript tail.
|
|
102
|
+
* @param {{ exchanges?: any[], tail?: any[], limit?: number, before?: number }} [opts]
|
|
103
|
+
* @returns {{ messages: Array<{ role: string, text: string, ts: string|null, uuid: string|null, live?: true }>, hasMore: boolean, nextBefore: number|null }}
|
|
104
|
+
* messages oldest-first: history, then live.
|
|
105
|
+
*/
|
|
106
|
+
function buildRemoteConversation({ exchanges = [], tail = [], limit = 10, before = 0 } = {}) {
|
|
107
|
+
const n = Math.floor(Number(limit));
|
|
108
|
+
const lim = Number.isFinite(n) && n >= 1 ? n : 10;
|
|
109
|
+
const b = Math.max(0, Math.floor(Number(before)) || 0);
|
|
110
|
+
const valid = (Array.isArray(exchanges) ? exchanges : []).filter((e) => e && typeof e === 'object' && typeof e.ts === 'string');
|
|
111
|
+
const candidates = valid.slice(0, lim);
|
|
112
|
+
let hasMore = valid.length > lim;
|
|
113
|
+
let budget = MAX_PAGE_TEXT_BYTES;
|
|
114
|
+
const size = (text) => Buffer.byteLength(text, 'utf8');
|
|
115
|
+
|
|
116
|
+
// Live tail (first page only): newest-first until the budget is hit.
|
|
117
|
+
const live = [];
|
|
118
|
+
if (b === 0 && Array.isArray(tail)) {
|
|
119
|
+
const newest = valid.reduce((m, e) => Math.max(m, Date.parse(e.ts) || 0), 0);
|
|
120
|
+
const fresh = tail.filter((m) => {
|
|
121
|
+
if (!m || typeof m !== 'object' || typeof m.text !== 'string') return false;
|
|
122
|
+
if (!valid.length) return true;
|
|
123
|
+
const t = typeof m.ts === 'string' ? Date.parse(m.ts) : NaN;
|
|
124
|
+
return Number.isFinite(t) && t > newest;
|
|
125
|
+
});
|
|
126
|
+
for (const m of fresh.slice(-MAX_LIVE_MESSAGES).reverse()) {
|
|
127
|
+
const bytes = size(m.text);
|
|
128
|
+
if (bytes > budget) break;
|
|
129
|
+
budget -= bytes;
|
|
130
|
+
live.push({ role: m.role, text: m.text, ts: m.ts ?? null, uuid: m.uuid ?? null, live: true });
|
|
131
|
+
}
|
|
132
|
+
live.reverse();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// History: whole exchanges newest-first, oldest dropped when over budget.
|
|
136
|
+
const history = [];
|
|
137
|
+
let returned = 0;
|
|
138
|
+
for (const e of candidates) {
|
|
139
|
+
const pair = [];
|
|
140
|
+
const prompt = typeof e.prompt === 'string' ? e.prompt.trim() : '';
|
|
141
|
+
const result = typeof e.result === 'string' ? e.result.trim() : '';
|
|
142
|
+
if (prompt) pair.push({ role: 'user', text: cap(prompt, MAX_PROMPT_CHARS), ts: e.ts, uuid: `ex:${e.ts}:u` });
|
|
143
|
+
if (result) pair.push({ role: 'assistant', text: cap(result, MAX_RESULT_CHARS), ts: e.ts, uuid: `ex:${e.ts}:a` });
|
|
144
|
+
const bytes = pair.reduce((s, m) => s + size(m.text), 0);
|
|
145
|
+
if (bytes > budget) {
|
|
146
|
+
hasMore = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
budget -= bytes;
|
|
150
|
+
history.unshift(...pair);
|
|
151
|
+
returned++;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { messages: [...history, ...live], hasMore, nextBefore: hasMore ? b + returned : null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { readRecentChatMessages, buildRemoteConversation };
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* staleTmpSweep.cjs — reaps tmp files leaked by dead writers in scheduled-plans/
|
|
5
|
+
* (`queue.json.tmp-<pid>-*`, `.max-allocated-group.tmp-<pid>-*`) and prunes
|
|
6
|
+
* `scheduled-plans/runs-archive/` by age.
|
|
7
|
+
*
|
|
8
|
+
* Safety: a tmp file is removed only when it is older than minAgeMs AND the pid
|
|
9
|
+
* embedded in its name is not alive. `queue.json.bak*` files are never deleted —
|
|
10
|
+
* they are reported only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
const TMP_RE = /\.tmp-(\d+)-/;
|
|
18
|
+
|
|
19
|
+
function defaultIsPidAlive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (e) {
|
|
24
|
+
// EPERM = process exists but belongs to someone else → alive.
|
|
25
|
+
return !!e && e.code === 'EPERM';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sweepStaleTmp(dir, opts) {
|
|
30
|
+
const o = opts || {};
|
|
31
|
+
const minAgeMs = typeof o.minAgeMs === 'number' ? o.minAgeMs : 3600000;
|
|
32
|
+
const now = typeof o.now === 'number' ? o.now : Date.now();
|
|
33
|
+
const isPidAlive = typeof o.isPidAlive === 'function' ? o.isPidAlive : defaultIsPidAlive;
|
|
34
|
+
const dryRun = o.dryRun === true;
|
|
35
|
+
const result = { removedFiles: 0, freedBytes: 0, removed: [], backups: [], errors: [] };
|
|
36
|
+
let names;
|
|
37
|
+
try {
|
|
38
|
+
names = fs.readdirSync(dir);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e && e.code !== 'ENOENT') result.errors.push(e.message);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
if (name.startsWith('queue.json.bak')) {
|
|
45
|
+
result.backups.push(name);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const m = TMP_RE.exec(name);
|
|
49
|
+
if (!m) continue;
|
|
50
|
+
const full = path.join(dir, name);
|
|
51
|
+
try {
|
|
52
|
+
const st = fs.statSync(full);
|
|
53
|
+
if (!st.isFile() || now - st.mtimeMs <= minAgeMs) continue;
|
|
54
|
+
if (isPidAlive(Number(m[1]))) continue;
|
|
55
|
+
if (!dryRun) fs.unlinkSync(full);
|
|
56
|
+
result.removedFiles += 1;
|
|
57
|
+
result.freedBytes += st.size;
|
|
58
|
+
result.removed.push(name);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (e && e.code !== 'ENOENT') result.errors.push(`${name}: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sizeOf(p, st) {
|
|
67
|
+
if (!st.isDirectory()) return st.size;
|
|
68
|
+
let total = 0;
|
|
69
|
+
let kids = [];
|
|
70
|
+
try { kids = fs.readdirSync(p); } catch { return 0; }
|
|
71
|
+
for (const k of kids) {
|
|
72
|
+
try { total += sizeOf(path.join(p, k), fs.lstatSync(path.join(p, k))); } catch { /* raced */ }
|
|
73
|
+
}
|
|
74
|
+
return total;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Lazy require: opsRetentionSettings requires this module (preview), so a
|
|
78
|
+
// top-level require here would be circular.
|
|
79
|
+
function defaultRunsArchiveDays() {
|
|
80
|
+
try {
|
|
81
|
+
return require('../opsRetentionSettings.cjs').read().runsArchiveDays;
|
|
82
|
+
} catch {
|
|
83
|
+
return 30;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pruneRunsArchive(archiveDir, opts) {
|
|
88
|
+
const o = opts || {};
|
|
89
|
+
const maxAgeDays = typeof o.maxAgeDays === 'number' ? o.maxAgeDays : defaultRunsArchiveDays();
|
|
90
|
+
const dryRun = o.dryRun === true;
|
|
91
|
+
const now = typeof o.now === 'number' ? o.now : Date.now();
|
|
92
|
+
const result = { removedFiles: 0, freedBytes: 0, removed: [], errors: [] };
|
|
93
|
+
let names;
|
|
94
|
+
try {
|
|
95
|
+
names = fs.readdirSync(archiveDir);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e && e.code !== 'ENOENT') result.errors.push(e.message);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
const full = path.join(archiveDir, name);
|
|
102
|
+
try {
|
|
103
|
+
const st = fs.lstatSync(full);
|
|
104
|
+
if (now - st.mtimeMs <= maxAgeDays * DAY_MS) continue;
|
|
105
|
+
const bytes = sizeOf(full, st);
|
|
106
|
+
if (!dryRun) fs.rmSync(full, { recursive: true, force: true });
|
|
107
|
+
result.removedFiles += 1;
|
|
108
|
+
result.freedBytes += bytes;
|
|
109
|
+
result.removed.push(name);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e && e.code !== 'ENOENT') result.errors.push(`${name}: ${e.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { sweepStaleTmp, pruneRunsArchive };
|