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
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* state a project's single-writer law needs to arbitrate, and it must
|
|
8
8
|
* survive even if the project's ops root doesn't exist yet.
|
|
9
9
|
*
|
|
10
|
+
* Rotation: on the first append in a new calendar month (UTC) the live file is
|
|
11
|
+
* gzipped to audit-log-<YYYY-MM>.jsonl.gz beside it (YYYY-MM = the month of the
|
|
12
|
+
* file's last write) and a fresh live file starts; the newest `auditLogMonths` rolled
|
|
13
|
+
* files are kept. The live file stays plain JSONL, so readers are unaffected.
|
|
14
|
+
*
|
|
10
15
|
* Best-effort only: a logging failure must never block the mint/create it is
|
|
11
16
|
* observing (same posture as rcaReport.cjs / dodDrainHook.cjs).
|
|
12
17
|
*
|
|
@@ -17,6 +22,7 @@
|
|
|
17
22
|
|
|
18
23
|
const fs = require('node:fs');
|
|
19
24
|
const path = require('node:path');
|
|
25
|
+
const zlib = require('node:zlib');
|
|
20
26
|
|
|
21
27
|
const { auditLogPath } = require('../scheduler/schedulerPaths.cjs');
|
|
22
28
|
|
|
@@ -36,21 +42,60 @@ function callerContext() {
|
|
|
36
42
|
};
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
const DEFAULT_MAX_ROLLED = 6;
|
|
46
|
+
|
|
47
|
+
/** Rolled-file cap from ops-retention.json (one file read per rotation); 6 if unreadable. */
|
|
48
|
+
function maxRolled() {
|
|
49
|
+
try {
|
|
50
|
+
const n = require('../opsRetentionSettings.cjs').read().auditLogMonths;
|
|
51
|
+
return Number.isFinite(n) && n >= 1 ? n : DEFAULT_MAX_ROLLED;
|
|
52
|
+
} catch { return DEFAULT_MAX_ROLLED; }
|
|
53
|
+
}
|
|
54
|
+
const ROLLED_RE = /^audit-log-\d{4}-\d{2}\.jsonl\.gz$/;
|
|
55
|
+
|
|
56
|
+
function monthKey(d) { return d.toISOString().slice(0, 7); }
|
|
57
|
+
|
|
58
|
+
/** Roll the live file if its last write was in an earlier month than `now`; prune old rolls. */
|
|
59
|
+
function rotateIfNewMonth(target, now) {
|
|
60
|
+
let st;
|
|
61
|
+
try { st = fs.statSync(target); } catch { return; }
|
|
62
|
+
if (!st.isFile() || st.size === 0) return;
|
|
63
|
+
const oldMonth = monthKey(st.mtime);
|
|
64
|
+
if (oldMonth === monthKey(now)) return;
|
|
65
|
+
const dir = path.dirname(target);
|
|
66
|
+
const base = path.basename(target, path.extname(target));
|
|
67
|
+
const rolled = path.join(dir, `${base}-${oldMonth}.jsonl.gz`);
|
|
68
|
+
// appendFileSync: gzip members concatenate, so a pre-existing roll for that month is extended.
|
|
69
|
+
fs.appendFileSync(rolled, zlib.gzipSync(fs.readFileSync(target)));
|
|
70
|
+
fs.unlinkSync(target);
|
|
71
|
+
const rolls = fs.readdirSync(dir).filter((f) => ROLLED_RE.test(f)).sort();
|
|
72
|
+
for (const f of rolls.slice(0, Math.max(0, rolls.length - maxRolled()))) {
|
|
73
|
+
try { fs.unlinkSync(path.join(dir, f)); } catch { /* best-effort */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
/**
|
|
40
|
-
* appendAuditEvent(kind, fields) — append one JSONL record. `kind` is a short
|
|
78
|
+
* appendAuditEvent(kind, fields, opts?) — append one JSONL record. `kind` is a short
|
|
41
79
|
* label ('epic_mint' | 'prd_create' | ...); `fields` is event-specific detail
|
|
42
|
-
* (cwd, epicId, status, title, slug, etc).
|
|
80
|
+
* (cwd, epicId, status, title, slug, etc). `opts.now` (Date) / `opts.target` are
|
|
81
|
+
* test seams. Never throws; a rotation failure never drops the append.
|
|
43
82
|
*/
|
|
44
|
-
function appendAuditEvent(kind, fields = {}) {
|
|
83
|
+
function appendAuditEvent(kind, fields = {}, opts = {}) {
|
|
45
84
|
try {
|
|
85
|
+
const now = opts.now ?? new Date();
|
|
46
86
|
const record = {
|
|
47
|
-
at:
|
|
87
|
+
at: now.toISOString(),
|
|
48
88
|
kind,
|
|
49
89
|
...fields,
|
|
50
90
|
caller: callerContext(),
|
|
51
91
|
};
|
|
52
|
-
const target = auditLogPath();
|
|
92
|
+
const target = opts.target ?? auditLogPath();
|
|
53
93
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
94
|
+
try {
|
|
95
|
+
rotateIfNewMonth(target, now);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error('[auditLog] rotation failed', e?.message ?? String(e));
|
|
98
|
+
}
|
|
54
99
|
fs.appendFileSync(target, `${JSON.stringify(record)}\n`);
|
|
55
100
|
} catch (e) {
|
|
56
101
|
console.error('[auditLog] failed to append audit event', e?.message ?? String(e));
|
|
@@ -60,7 +105,7 @@ function appendAuditEvent(kind, fields = {}) {
|
|
|
60
105
|
/**
|
|
61
106
|
* readTail(maxBytes, target?) → string[] — the complete JSONL lines within the
|
|
62
107
|
* last `maxBytes` of the log, oldest first. O(maxBytes), never O(file): the
|
|
63
|
-
*
|
|
108
|
+
* live log is a provenance record that rolls monthly but can still be large. A
|
|
64
109
|
* line cut by the window's start is dropped. Missing/unreadable file → [].
|
|
65
110
|
*/
|
|
66
111
|
function readTail(maxBytes, target = auditLogPath()) {
|
|
@@ -34,4 +34,52 @@ function reportCrash({ reason, meta, deps } = {}) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// A 10 s+ main-loop stall means the main thread was wedged (the 2026-10-19
|
|
38
|
+
// hang death showed 23 s stalls at 972 MB, far below any memory pressure), so
|
|
39
|
+
// a stall outranks memory when picking the likely cause.
|
|
40
|
+
const HANG_LOOP_DELAY_MS = 10000;
|
|
41
|
+
const MEMORY_PRESSURE_MB = 3000;
|
|
42
|
+
|
|
43
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Turns the previous run's last sample into a likely cause. silentGapSec is
|
|
47
|
+
* accepted for the caller's meta but does not change the verdict.
|
|
48
|
+
* @param {{ lastLoopDelayMaxMs?: unknown, lastTotalMb?: unknown, silentGapSec?: unknown }} [sample]
|
|
49
|
+
* @returns {'main-hang' | 'memory-pressure' | 'unknown'}
|
|
50
|
+
*/
|
|
51
|
+
function classifyUncleanShutdown({ lastLoopDelayMaxMs, lastTotalMb } = {}) {
|
|
52
|
+
if (isNum(lastLoopDelayMaxMs) && lastLoopDelayMaxMs >= HANG_LOOP_DELAY_MS) return 'main-hang';
|
|
53
|
+
if (isNum(lastTotalMb) && lastTotalMb >= MEMORY_PRESSURE_MB) return 'memory-pressure';
|
|
54
|
+
return 'unknown';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Routes a Chromium child-process-gone event. Chromium restarts the Utility
|
|
59
|
+
* 'Network Service' itself, so a non-oom exit there is a warn log line; all
|
|
60
|
+
* else is a crash error via reportCrash. Fail-inert: never throws.
|
|
61
|
+
*/
|
|
62
|
+
function reportChildProcessGone({ details, lastTotalMb, deps } = {}) {
|
|
63
|
+
try {
|
|
64
|
+
const d = details || {};
|
|
65
|
+
const type = d.type;
|
|
66
|
+
const name = d.name || d.serviceName;
|
|
67
|
+
if (type === 'Utility' && name === 'Network Service' && d.reason !== 'oom') {
|
|
68
|
+
const telemetryClient = (deps && deps.telemetryClient) || require('./telemetryClient.cjs');
|
|
69
|
+
return telemetryClient.logLine({
|
|
70
|
+
level: 'warn',
|
|
71
|
+
msg: 'crash.child-restartable',
|
|
72
|
+
fields: { type, name, reason: d.reason, exitCode: d.exitCode, lastTotalMb },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return reportCrash({
|
|
76
|
+
reason: d.reason || 'unknown',
|
|
77
|
+
meta: { type, name, exitCode: d.exitCode, lastTotalMb },
|
|
78
|
+
deps,
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { reportCrash, classifyUncleanShutdown, reportChildProcessGone };
|
|
@@ -45,7 +45,14 @@ function todayFile(cwd) {
|
|
|
45
45
|
return path.join(logsDir(cwd), `errors-${yyyy}-${mm}-${dd}.jsonl`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// Non-PII triage code: short lowercase slug. Anything else is dropped (never thrown).
|
|
49
|
+
const CODE_RE = /^[a-z0-9][a-z0-9:._-]{0,47}$/;
|
|
50
|
+
|
|
51
|
+
function normalizeCode(code) {
|
|
52
|
+
return typeof code === 'string' && CODE_RE.test(code) ? code : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta, safeCode }) {
|
|
49
56
|
const file = todayFile(cwd);
|
|
50
57
|
try {
|
|
51
58
|
assertOpsWrite(file, 'logs');
|
|
@@ -58,6 +65,7 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
|
|
|
58
65
|
`scope:${scope || 'unknown'}`,
|
|
59
66
|
...(tabId ? [`tab:${tabId}`] : []),
|
|
60
67
|
...(epicId ? [`epic:${epicId}`] : []),
|
|
68
|
+
...(safeCode ? [`code:${safeCode}`] : []),
|
|
61
69
|
];
|
|
62
70
|
const allTags = Array.from(new Set([...autoTags, ...tags]));
|
|
63
71
|
|
|
@@ -68,6 +76,7 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
|
|
|
68
76
|
tabId: tabId || null,
|
|
69
77
|
epicId: epicId || null,
|
|
70
78
|
tags: allTags,
|
|
79
|
+
...(safeCode ? { code: safeCode } : {}),
|
|
71
80
|
message: String(message ?? ''),
|
|
72
81
|
...(meta !== undefined ? { meta: sanitizeMeta(meta) } : {}),
|
|
73
82
|
};
|
|
@@ -83,9 +92,13 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
|
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
/**
|
|
86
|
-
* Mirrors
|
|
95
|
+
* Mirrors appendError() lines to telemetryClient — the single tap that
|
|
87
96
|
* covers pty/chatRunner/scheduler/logs.cjs/renderer in one place, since they
|
|
88
|
-
* all already funnel through appendError.
|
|
97
|
+
* all already funnel through appendError. Level routing: 'error' ships as a
|
|
98
|
+
* telemetry error, 'warn' as a warn log line, anything else ('info', 'debug')
|
|
99
|
+
* ships nothing (it is a success/diagnostic, not an error). An optional
|
|
100
|
+
* validated `code` rides in context/fields and the msg label so host-side
|
|
101
|
+
* reports are triageable though `message` is always redacted. Runs AFTER the local write and is
|
|
89
102
|
* independently wrapped so a throwing telemetry stub can never prevent or
|
|
90
103
|
* corrupt it. Reported regardless of the local ephemeral-cwd refusal — an
|
|
91
104
|
* Epic/job worktree still has a REAL project behind it, just not one that
|
|
@@ -105,7 +118,8 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
|
|
|
105
118
|
* `scope`, a small closed vocabulary of subsystem names — structural, never
|
|
106
119
|
* free text.
|
|
107
120
|
*/
|
|
108
|
-
function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message }) {
|
|
121
|
+
function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message, safeCode }) {
|
|
122
|
+
if (level !== 'error' && level !== 'warn') return;
|
|
109
123
|
try {
|
|
110
124
|
const telemetryClient = require('./telemetryClient.cjs');
|
|
111
125
|
const { projectRootOf } = require('../activeSessions.cjs');
|
|
@@ -116,8 +130,11 @@ function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message })
|
|
|
116
130
|
...(epicId ? [`epic:${epicId}`] : []),
|
|
117
131
|
];
|
|
118
132
|
const allTags = Array.from(new Set([...autoTags, ...(tags || [])]));
|
|
119
|
-
const label = `opsError:${scope || 'unknown'}`;
|
|
120
|
-
const context = {
|
|
133
|
+
const label = safeCode ? `opsError:${scope || 'unknown'}:${safeCode}` : `opsError:${scope || 'unknown'}`;
|
|
134
|
+
const context = {
|
|
135
|
+
scope: scope || 'unknown', tags: allTags, cwd: normalizedCwd, message,
|
|
136
|
+
...(safeCode ? { code: safeCode } : {}),
|
|
137
|
+
};
|
|
121
138
|
if (level === 'warn') {
|
|
122
139
|
telemetryClient.logLine({ level: 'warn', msg: label, fields: context });
|
|
123
140
|
} else {
|
|
@@ -159,7 +176,8 @@ function isInsideTestScratch(cwd) {
|
|
|
159
176
|
* @param {{
|
|
160
177
|
* cwd: string, // required — which project's ops root owns this line
|
|
161
178
|
* scope: string, // subsystem name, e.g. 'pty', 'chatRunner', 'voice'
|
|
162
|
-
* level?: string, // default 'error'; 'warn'
|
|
179
|
+
* level?: string, // default 'error'; telemetry: 'error' -> error, 'warn' -> warn log, else nothing
|
|
180
|
+
* code?: string, // optional non-PII triage slug, /^[a-z0-9][a-z0-9:._-]{0,47}$/; invalid is dropped
|
|
163
181
|
* tabId?: string, // claudeSessionId of the tab this error belongs to, if any
|
|
164
182
|
* epicId?: string, // Epic id, if this error happened inside an Epic-backed run
|
|
165
183
|
* tags?: string[], // extra caller-supplied tags, merged with the auto-derived ones
|
|
@@ -167,13 +185,15 @@ function isInsideTestScratch(cwd) {
|
|
|
167
185
|
* meta?: unknown,
|
|
168
186
|
* }} entry
|
|
169
187
|
*/
|
|
170
|
-
function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
|
|
188
|
+
function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta, code }) {
|
|
171
189
|
if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
|
|
172
190
|
|
|
173
191
|
// Test-process guard: a vitest fixture passing a real project root must never
|
|
174
192
|
// write to that project's logs/ NOR ship a fake event to production telemetry.
|
|
175
193
|
if (process.env.VITEST && !isInsideTestScratch(cwd)) return;
|
|
176
194
|
|
|
195
|
+
const safeCode = normalizeCode(code);
|
|
196
|
+
|
|
177
197
|
if (isEphemeralCwd(cwd)) {
|
|
178
198
|
// A worktree is torn down when its Epic/job ends and os.tmpdir() is
|
|
179
199
|
// scratch space either way — never materialize logs there. See
|
|
@@ -183,10 +203,10 @@ function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], me
|
|
|
183
203
|
// tap below still fires — this refusal is about the LOCAL write only.
|
|
184
204
|
console.warn(`[opsErrorLog] appendError: refusing ephemeral cwd "${cwd}" (scope=${scope || 'unknown'})`);
|
|
185
205
|
} else {
|
|
186
|
-
writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta });
|
|
206
|
+
writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta, safeCode });
|
|
187
207
|
}
|
|
188
208
|
|
|
189
|
-
reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message });
|
|
209
|
+
reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message, safeCode });
|
|
190
210
|
}
|
|
191
211
|
|
|
192
212
|
module.exports = { appendError, logsDir, todayFile };
|
|
@@ -137,7 +137,8 @@ function sentPath() {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
|
-
* True when running under a test runner (VITEST, NODE_ENV=test
|
|
140
|
+
* True when running under a test runner (VITEST, NODE_ENV=test, or Playwright
|
|
141
|
+
* e2e via SM_E2E=1, set by tests/e2e/_helpers/launchApp.ts) with no
|
|
141
142
|
* explicit SM_TELEMETRY_SPOOL override. Gates appendRecord() below so a test
|
|
142
143
|
* that merely exercises epicMint/scheduler/etc. code paths — without itself
|
|
143
144
|
* setting up an isolated spool — can never accumulate fake records into a
|
|
@@ -148,7 +149,7 @@ function sentPath() {
|
|
|
148
149
|
*/
|
|
149
150
|
function isTestEnvironment() {
|
|
150
151
|
if (process.env.SM_TELEMETRY_SPOOL) return false;
|
|
151
|
-
return !!process.env.VITEST || process.env.NODE_ENV === 'test';
|
|
152
|
+
return !!process.env.VITEST || process.env.NODE_ENV === 'test' || process.env.SM_E2E === '1';
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
function getBeaconToken() {
|
|
@@ -826,4 +827,6 @@ module.exports = {
|
|
|
826
827
|
_setFetchImpl,
|
|
827
828
|
_setMachineProfileBuilder,
|
|
828
829
|
_setLogger,
|
|
830
|
+
_currentEnv: currentEnv,
|
|
831
|
+
_buildInstallWire: buildInstallWire,
|
|
829
832
|
};
|
|
@@ -325,6 +325,45 @@ async function maxParallelGroupInUse(prdsDir) {
|
|
|
325
325
|
return max;
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Bound the `.reserved-NN` marker pile. Markers far below the highest
|
|
330
|
+
* reserved NN protect nothing (the O_EXCL race only matters for candidates
|
|
331
|
+
* near the floor, and `.max-allocated-group` already keeps the allocator
|
|
332
|
+
* monotonic), so keep the newest `keep` numbers and delete older markers
|
|
333
|
+
* whose mtime is also older than `minAgeMs`. Returns { removed, kept }.
|
|
334
|
+
*/
|
|
335
|
+
async function pruneStaleReservations(prdsDir, { keep = 100, minAgeMs = 3600000, now = Date.now() } = {}) {
|
|
336
|
+
let entries;
|
|
337
|
+
try {
|
|
338
|
+
entries = await fsp.readdir(prdsDir);
|
|
339
|
+
} catch {
|
|
340
|
+
return { removed: 0, kept: 0 };
|
|
341
|
+
}
|
|
342
|
+
const markers = [];
|
|
343
|
+
let max = 0;
|
|
344
|
+
for (const name of entries) {
|
|
345
|
+
const m = name.match(RESERVATION_RE);
|
|
346
|
+
if (!m) continue;
|
|
347
|
+
const nn = Number(m[1]);
|
|
348
|
+
markers.push({ name, nn });
|
|
349
|
+
max = Math.max(max, nn);
|
|
350
|
+
}
|
|
351
|
+
const cutoff = max - keep;
|
|
352
|
+
let removed = 0;
|
|
353
|
+
for (const { name, nn } of markers) {
|
|
354
|
+
if (nn > cutoff) continue;
|
|
355
|
+
try {
|
|
356
|
+
const st = await fsp.stat(path.join(prdsDir, name));
|
|
357
|
+
if (now - st.mtimeMs <= minAgeMs) continue;
|
|
358
|
+
await fsp.unlink(path.join(prdsDir, name));
|
|
359
|
+
removed += 1;
|
|
360
|
+
} catch {
|
|
361
|
+
// raced with another remover, or unreadable — leave it
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { removed, kept: markers.length - removed };
|
|
365
|
+
}
|
|
366
|
+
|
|
328
367
|
/**
|
|
329
368
|
* Atomically allocate a brand-new parallel-group number under prdsDir.
|
|
330
369
|
* Returns the reserved NN. Reservation survives a crash mid-allocation
|
|
@@ -360,6 +399,16 @@ async function allocateParallelGroup(prdsDir, { extraFloor = 0 } = {}) {
|
|
|
360
399
|
if (candidate > highWater) {
|
|
361
400
|
await writeHighWaterMark(prdsDir, candidate);
|
|
362
401
|
}
|
|
402
|
+
try {
|
|
403
|
+
let keep = 100;
|
|
404
|
+
try {
|
|
405
|
+
const k = require('../lib/opsRetentionSettings.cjs').read().reservedMarkersKeep;
|
|
406
|
+
if (Number.isFinite(k) && k >= 0) keep = k;
|
|
407
|
+
} catch { /* default */ }
|
|
408
|
+
await pruneStaleReservations(prdsDir, { keep });
|
|
409
|
+
} catch {
|
|
410
|
+
// best-effort; never fail an allocation over housekeeping
|
|
411
|
+
}
|
|
363
412
|
return candidate;
|
|
364
413
|
}
|
|
365
414
|
throw new Error(`allocateParallelGroup: exhausted ${MAX_RESERVE_ATTEMPTS} reservation attempts starting at ${candidate}`);
|
|
@@ -370,6 +419,7 @@ module.exports = {
|
|
|
370
419
|
parsePrd,
|
|
371
420
|
listPrdFiles,
|
|
372
421
|
allocateParallelGroup,
|
|
422
|
+
pruneStaleReservations,
|
|
373
423
|
maxParallelGroupInUse,
|
|
374
424
|
PRD_READ_MAX_BYTES,
|
|
375
425
|
_resetCache,
|
|
@@ -13,9 +13,10 @@ const schedulerPaths = require('../lib/scheduler/schedulerPaths.cjs');
|
|
|
13
13
|
const logs = require('../logs.cjs');
|
|
14
14
|
const { SCHEDULE_SLUG_RE } = require('../ipcSchemas.cjs');
|
|
15
15
|
const { DEFAULT_PROJECT_CWD } = require('../lib/scheduler/schedulerBatch.cjs');
|
|
16
|
-
const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs, ancestorEpicPrdDirs, ancestorEpicArchivedPrdDirs } = require('../lib/prd/prdLocations.cjs');
|
|
16
|
+
const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, resolveEpicPrdWriteDir, isSafeEpicId, listArchivedPrdDirs, ancestorEpicPrdDirs, ancestorEpicArchivedPrdDirs } = require('../lib/prd/prdLocations.cjs');
|
|
17
17
|
const { migratePrds, consolidateFlatPrds, legacyAdoptExistingPrds } = require('../lib/prd/prdMigration.cjs');
|
|
18
|
-
const {
|
|
18
|
+
const { opsPath } = require('../lib/opsOwnership.cjs');
|
|
19
|
+
const { allProjectCwds, projectRootOf } = require('../lib/activeSessions.cjs');
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Every PRD-source directory currently in play: the legacy global dir (still
|
|
@@ -145,23 +146,69 @@ async function findPrdDir(slug) {
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
|
-
*
|
|
149
|
-
*
|
|
149
|
+
* Deterministic, scan-free candidate PRD dirs for a job, derived only from
|
|
150
|
+
* `job.cwd` (projectRootOf-normalized): the exact `job.epicId` Epic dir (when
|
|
151
|
+
* the id is safe and the dir exists), then every Epic prds dir of the project,
|
|
152
|
+
* then the project's own prds dir. Ordered, deduplicated; [] when job.cwd is
|
|
153
|
+
* missing or not absolute. All paths are built with path.join.
|
|
154
|
+
*/
|
|
155
|
+
async function jobScopedPrdDirs(job) {
|
|
156
|
+
const cwd = job && job.cwd;
|
|
157
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return [];
|
|
158
|
+
const root = projectRootOf(cwd);
|
|
159
|
+
const out = [];
|
|
160
|
+
const add = (dir) => { if (dir && !out.includes(dir)) out.push(dir); };
|
|
161
|
+
if (isSafeEpicId(job.epicId)) {
|
|
162
|
+
try {
|
|
163
|
+
const exact = resolveEpicPrdWriteDir(root, job.epicId);
|
|
164
|
+
if (fs.existsSync(exact)) add(exact);
|
|
165
|
+
} catch { /* unsafe epicId — skip the exact dir */ }
|
|
166
|
+
}
|
|
167
|
+
for (const dir of listEpicPrdDirs(root)) add(dir);
|
|
168
|
+
add(module.exports.prdDirForCwd(root));
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function dirHasSlug(dir, slug) {
|
|
173
|
+
try {
|
|
174
|
+
await fsp.access(path.join(dir, `${slug}.md`));
|
|
175
|
+
return true;
|
|
176
|
+
} catch { return false; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Job-aware widening of findPrdDir: tries the job's exact Epic dir from
|
|
181
|
+
* jobScopedPrdDirs first, then the ordinary global candidate search, then the
|
|
182
|
+
* remaining job-scoped dirs, then a stored absolute PRD path on the row (if
|
|
150
183
|
* the queue schema ever carries one — checked defensively), then walks up
|
|
151
184
|
* to 3 ancestor directories above `job.cwd` for that EXACT `job.epicId`'s
|
|
152
185
|
* `prds` dir (prdLocations.ancestorEpicPrdDirs).
|
|
153
186
|
*
|
|
154
|
-
*
|
|
155
|
-
* (
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
187
|
+
* The job-scoped lookup is what keeps a live PRD resolvable when its project
|
|
188
|
+
* has fallen out of allProjectCwds (a transcript-tail heuristic), which would
|
|
189
|
+
* otherwise hide it from findPrdDir (incident: Rabbits-Game
|
|
190
|
+
* 41-bunny-run-cycle-art, 2026-10-20, retired as `prd-missing`).
|
|
191
|
+
*
|
|
192
|
+
* The ancestor walk fixes the incident where an Epic lives in a non-git
|
|
193
|
+
* PARENT folder P (P/session-manager-operations/scheduler/epics/<epicId>/
|
|
194
|
+
* prds/*.md) but its PRDs' frontmatter `cwd` is the sub-repo P/repo — P is
|
|
195
|
+
* never itself a tracked project cwd, so candidatePrdsDirs() never visits it.
|
|
196
|
+
* epicId is matched exactly (never a glob); every path is built with
|
|
197
|
+
* path.join, never string concatenation with user input.
|
|
161
198
|
*/
|
|
162
199
|
async function findPrdDirForJob(job) {
|
|
163
|
-
const
|
|
200
|
+
const slug = job && job.slug;
|
|
201
|
+
const scoped = typeof slug === 'string' && slug && path.basename(slug) === slug
|
|
202
|
+
? await module.exports.jobScopedPrdDirs(job) : [];
|
|
203
|
+
const exactDir = scoped.length && isSafeEpicId(job.epicId)
|
|
204
|
+
? opsPath(job.cwd, 'scheduler', 'epics', job.epicId, 'prds')
|
|
205
|
+
: null;
|
|
206
|
+
if (exactDir && scoped[0] === exactDir && await dirHasSlug(exactDir, slug)) return exactDir;
|
|
207
|
+
const viaGlobalSearch = await module.exports.findPrdDir(slug);
|
|
164
208
|
if (viaGlobalSearch) return viaGlobalSearch;
|
|
209
|
+
for (const dir of scoped) {
|
|
210
|
+
if (await dirHasSlug(dir, slug)) return dir;
|
|
211
|
+
}
|
|
165
212
|
if (job && typeof job.prdPath === 'string' && job.prdPath) {
|
|
166
213
|
try {
|
|
167
214
|
await fsp.access(job.prdPath);
|
|
@@ -170,10 +217,7 @@ async function findPrdDirForJob(job) {
|
|
|
170
217
|
}
|
|
171
218
|
if (job && job.cwd && job.epicId) {
|
|
172
219
|
for (const dir of ancestorEpicPrdDirs(job.cwd, job.epicId)) {
|
|
173
|
-
|
|
174
|
-
await fsp.access(path.join(dir, `${job.slug}.md`));
|
|
175
|
-
return dir;
|
|
176
|
-
} catch { /* not here — try the next ancestor */ }
|
|
220
|
+
if (await dirHasSlug(dir, slug)) return dir;
|
|
177
221
|
}
|
|
178
222
|
}
|
|
179
223
|
return null;
|
|
@@ -474,6 +518,7 @@ module.exports = {
|
|
|
474
518
|
archivedTwinExists,
|
|
475
519
|
findPrdDir,
|
|
476
520
|
findPrdDirForJob,
|
|
521
|
+
jobScopedPrdDirs,
|
|
477
522
|
safeSlugPathIn,
|
|
478
523
|
realPrdsDir,
|
|
479
524
|
safeSlugPath,
|
|
@@ -507,6 +507,23 @@ function healRefusalReason(job, verdict, committedDuringRun) {
|
|
|
507
507
|
* gate or a real non-zero-exit error) is excluded — that failure is
|
|
508
508
|
* evidence, not silence, and must never become a heal candidate (PRD 1102).
|
|
509
509
|
*/
|
|
510
|
+
// A human cancelled this row (scheduler_cancel_job). Incident 2026-10-19
|
|
511
|
+
// (git-viewer): cancelled jobs were parked as 'failed' (source
|
|
512
|
+
// 'remote:cancelJob'), then selectFailedAutoResetTargets re-queued them 10
|
|
513
|
+
// minutes later and they ran and merged unwanted commits. Marker is
|
|
514
|
+
// `cancelledAt`; the statusHistory source is the fallback for rows cancelled
|
|
515
|
+
// before the marker existed. Every automated selector must skip these; only a
|
|
516
|
+
// human scheduler_reset_job revives one.
|
|
517
|
+
function isCancelledJob(job) {
|
|
518
|
+
if (!job) return false;
|
|
519
|
+
if (typeof job.cancelledAt === 'string' && job.cancelledAt) return true;
|
|
520
|
+
const history = Array.isArray(job.statusHistory) ? job.statusHistory : [];
|
|
521
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
522
|
+
if (history[i] && history[i].to === job.status) return history[i].source === 'remote:cancelJob';
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
|
|
510
527
|
function isFailedUnverifiedShaped(job) {
|
|
511
528
|
if (!job || job.status !== 'failed') return false;
|
|
512
529
|
if (job.verifierVerdict && RESCANNABLE_VERDICTS.has(job.verifierVerdict)) return true;
|
|
@@ -517,7 +534,7 @@ function isFailedUnverifiedShaped(job) {
|
|
|
517
534
|
}
|
|
518
535
|
|
|
519
536
|
function isRescanCandidate(job) {
|
|
520
|
-
if (!job) return false;
|
|
537
|
+
if (!job || isCancelledJob(job)) return false;
|
|
521
538
|
// Default-ELIGIBLE: every needs_review row is a heal candidate unless its
|
|
522
539
|
// verdict is in RESCAN_EXCLUDED_VERDICTS. No runId requirement here — a row
|
|
523
540
|
// without one still gets the evidence rung and the unresolvable annotation.
|
|
@@ -549,6 +566,7 @@ function isTranscriptRescannable(job) {
|
|
|
549
566
|
function selectEvidenceScanTargets(jobs, now = Date.now()) {
|
|
550
567
|
const due = [];
|
|
551
568
|
for (const j of jobs ?? []) {
|
|
569
|
+
if (isCancelledJob(j)) continue;
|
|
552
570
|
if (j.status !== 'needs_review' || !isRescanCandidate(j)) continue;
|
|
553
571
|
if (isTranscriptRescannable(j)) continue;
|
|
554
572
|
if (j.looksDone) continue;
|
|
@@ -678,6 +696,7 @@ function failedAutoResetDisabled() {
|
|
|
678
696
|
function selectFailedAutoResetTargets(jobs, now, thresholdMs) {
|
|
679
697
|
const targets = [];
|
|
680
698
|
for (const j of jobs ?? []) {
|
|
699
|
+
if (isCancelledJob(j)) continue;
|
|
681
700
|
if (j.status !== 'failed') continue;
|
|
682
701
|
const attempts = j.failedAutoResetAttempts ?? 0;
|
|
683
702
|
if (attempts >= FAILED_AUTORESET_CAP) continue;
|
|
@@ -729,6 +748,7 @@ function selectFailedAutoResetTargets(jobs, now, thresholdMs) {
|
|
|
729
748
|
function findStuckFailedJobs(jobs, now, thresholdMs) {
|
|
730
749
|
const stuck = [];
|
|
731
750
|
for (const j of jobs ?? []) {
|
|
751
|
+
if (isCancelledJob(j)) continue;
|
|
732
752
|
if (j.status !== 'failed') continue;
|
|
733
753
|
if (j.stuckFailedNotified === true) continue;
|
|
734
754
|
if (!isRescanCandidate(j)) continue;
|
|
@@ -797,6 +817,7 @@ function needsReviewAutoResolveDisabled() {
|
|
|
797
817
|
function selectExhaustedNeedsReviewTargets(jobs, now, thresholdMs) {
|
|
798
818
|
const targets = [];
|
|
799
819
|
for (const j of jobs ?? []) {
|
|
820
|
+
if (isCancelledJob(j)) continue;
|
|
800
821
|
if (j.status !== 'needs_review') continue;
|
|
801
822
|
if (isNeedsHuman(j)) continue;
|
|
802
823
|
if (!isEligibleForNeedsReviewAutoResolve(j, jobs)) continue;
|
|
@@ -840,6 +861,7 @@ function selectExhaustedNeedsReviewTargets(jobs, now, thresholdMs) {
|
|
|
840
861
|
function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRunId }) {
|
|
841
862
|
const slugsInQueue = new Set(jobs.map((j) => j.slug));
|
|
842
863
|
return jobs.filter((job) => {
|
|
864
|
+
if (isCancelledJob(job)) return false;
|
|
843
865
|
if (job.status !== 'needs_review') return false;
|
|
844
866
|
if (isNeedsHuman(job)) return false;
|
|
845
867
|
// A job parked here because it was blocked by a sibling's foreign WIP
|
|
@@ -983,6 +1005,7 @@ function selectGateShadowTarget(jobs, headByCwd, { authorityDisabled = false, ba
|
|
|
983
1005
|
|
|
984
1006
|
module.exports = {
|
|
985
1007
|
isNeedsHuman,
|
|
1008
|
+
isCancelledJob,
|
|
986
1009
|
selectResumeRecoveryTarget,
|
|
987
1010
|
MECHANICALLY_RESOLVABLE_VERDICTS,
|
|
988
1011
|
isMechanicalRecoveryFutile,
|