claude-code-session-manager 0.36.0 → 0.37.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/{TiptapBody-D5b7nejd.js → TiptapBody-Cg8YZhVZ.js} +58 -58
- package/dist/assets/index-CTTjT08J.css +32 -0
- package/dist/assets/index-_iBXLuvt.js +3496 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
- package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
- package/src/main/__tests__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -0
- package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
- package/src/main/__tests__/queueHistory.test.cjs +233 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +46 -12
- package/src/main/config.cjs +8 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +35 -1
- package/src/main/ipcSchemas.cjs +5 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -0
- package/src/main/lib/queueHistory.cjs +216 -0
- package/src/main/lib/rcaFeedbackHook.cjs +346 -0
- package/src/main/lib/schedulerConfig.cjs +16 -0
- package/src/main/queueOps.cjs +92 -0
- package/src/main/scheduler/prdParser.cjs +52 -1
- package/src/main/scheduler.cjs +237 -29
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +1 -0
- package/dist/assets/index-CkiGRskz.css +0 -32
- package/dist/assets/index-DrzirIUy.js +0 -3562
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Moves terminal (completed/failed) scheduler jobs older than a retention
|
|
4
|
+
// window out of the hot queue.json jobs[] array into an append-only
|
|
5
|
+
// history.jsonl sidecar. Keeps queue.json small (mutation cost, broadcast
|
|
6
|
+
// payload size, pickNextBatch/reconcile scan cost all scale with jobs[]
|
|
7
|
+
// length) while preserving full history for the renderer's History view.
|
|
8
|
+
//
|
|
9
|
+
// Kept pure where fs isn't unavoidable: partitionJobs takes an injected
|
|
10
|
+
// `nowMs` and `opts` so it's testable without touching the real clock or
|
|
11
|
+
// disk. appendHistory/readHistory own the actual fs I/O.
|
|
12
|
+
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fsp = require('fs').promises;
|
|
16
|
+
const { HISTORY_RETENTION_MS } = require('./schedulerConfig.cjs');
|
|
17
|
+
|
|
18
|
+
const HISTORY_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'history.jsonl');
|
|
19
|
+
|
|
20
|
+
function isFixPlanSlug(slug) {
|
|
21
|
+
return /^\d+-fix-/.test(slug);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function jobKey(job) {
|
|
25
|
+
return `${job?.slug ?? ''}|${job?.runId ?? ''}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* O(n) over `jobs`. Splits terminal jobs into `hot` (stays in queue.json)
|
|
30
|
+
* and `toArchive` (moves to history.jsonl). A completed/failed job archives
|
|
31
|
+
* only when ALL of:
|
|
32
|
+
* - status is 'completed' or 'failed'
|
|
33
|
+
* - finishedAt parses and is older than opts.retentionMs (default
|
|
34
|
+
* HISTORY_RETENTION_MS)
|
|
35
|
+
* - it is not the healTargetForFix() parent of a pending/running fix-plan
|
|
36
|
+
* job (NN-fix-<slug> relationships stay hot together so the fix-plan
|
|
37
|
+
* can still find and promote its original when it completes)
|
|
38
|
+
*/
|
|
39
|
+
function partitionJobs(jobs, nowMs, opts = {}) {
|
|
40
|
+
const retentionMs = opts.retentionMs ?? HISTORY_RETENTION_MS;
|
|
41
|
+
const list = Array.isArray(jobs) ? jobs : [];
|
|
42
|
+
|
|
43
|
+
// Slugs of jobs that a still-pending/running fix-plan is going to try to
|
|
44
|
+
// heal (see healTargetForFix in scheduler.cjs — same regex, kept in sync).
|
|
45
|
+
const protectedSlugs = new Set();
|
|
46
|
+
for (const j of list) {
|
|
47
|
+
if (!j || !isFixPlanSlug(j.slug)) continue;
|
|
48
|
+
if (j.status !== 'pending' && j.status !== 'running') continue;
|
|
49
|
+
protectedSlugs.add(j.slug.replace(/^(\d+)-fix-/, '$1-'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hot = [];
|
|
53
|
+
const toArchive = [];
|
|
54
|
+
for (const j of list) {
|
|
55
|
+
if (!j) continue;
|
|
56
|
+
const finishedMs = j.finishedAt ? Date.parse(j.finishedAt) : NaN;
|
|
57
|
+
const archivable =
|
|
58
|
+
(j.status === 'completed' || j.status === 'failed') &&
|
|
59
|
+
Number.isFinite(finishedMs) &&
|
|
60
|
+
(nowMs - finishedMs) > retentionMs &&
|
|
61
|
+
!protectedSlugs.has(j.slug);
|
|
62
|
+
if (archivable) toArchive.push(j);
|
|
63
|
+
else hot.push(j);
|
|
64
|
+
}
|
|
65
|
+
return { hot, toArchive };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Append-only JSONL write, deduped by slug+runId against what's already on
|
|
70
|
+
* disk. The dedupe exists for crash-safety: reconcile() appends BEFORE
|
|
71
|
+
* dropping archived jobs from state.jobs, so a crash between those two steps
|
|
72
|
+
* replays the same batch through partitionJobs on the next boot — dedupe
|
|
73
|
+
* here is what stops that replay from double-writing history.
|
|
74
|
+
*/
|
|
75
|
+
async function appendHistory(entries) {
|
|
76
|
+
const list = Array.isArray(entries) ? entries.filter(Boolean) : [];
|
|
77
|
+
if (list.length === 0) return { appended: 0 };
|
|
78
|
+
|
|
79
|
+
let existingText = '';
|
|
80
|
+
try {
|
|
81
|
+
existingText = await fsp.readFile(HISTORY_PATH, 'utf8');
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e.code !== 'ENOENT') throw e;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const existingKeys = new Set();
|
|
87
|
+
if (existingText) {
|
|
88
|
+
for (const line of existingText.split('\n')) {
|
|
89
|
+
if (!line.trim()) continue;
|
|
90
|
+
try {
|
|
91
|
+
existingKeys.add(jobKey(JSON.parse(line)));
|
|
92
|
+
} catch {
|
|
93
|
+
// corrupt/partial line — ignore for dedupe purposes
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fresh = list.filter((e) => !existingKeys.has(jobKey(e)));
|
|
99
|
+
if (fresh.length === 0) return { appended: 0 };
|
|
100
|
+
|
|
101
|
+
await fsp.mkdir(path.dirname(HISTORY_PATH), { recursive: true });
|
|
102
|
+
const text = fresh.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
103
|
+
await fsp.appendFile(HISTORY_PATH, text, 'utf8');
|
|
104
|
+
return { appended: fresh.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads the newest `limit` (clamped [1,500], default 50) entries from
|
|
109
|
+
* history.jsonl, newest-first, WITHOUT loading the whole file when it's
|
|
110
|
+
* large — reads from the end in growing chunks until enough whole lines are
|
|
111
|
+
* found (or the file start is reached).
|
|
112
|
+
*/
|
|
113
|
+
async function readHistory({ limit } = {}) {
|
|
114
|
+
const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
|
|
115
|
+
|
|
116
|
+
let fh;
|
|
117
|
+
try {
|
|
118
|
+
fh = await fsp.open(HISTORY_PATH, 'r');
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (e.code === 'ENOENT') return [];
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const { size } = await fh.stat();
|
|
126
|
+
if (size === 0) return [];
|
|
127
|
+
|
|
128
|
+
let readSize = Math.min(65536, size);
|
|
129
|
+
let lines = [];
|
|
130
|
+
for (;;) {
|
|
131
|
+
const start = size - readSize;
|
|
132
|
+
const buf = Buffer.alloc(readSize);
|
|
133
|
+
await fh.read(buf, 0, readSize, start);
|
|
134
|
+
let text = buf.toString('utf8');
|
|
135
|
+
if (start > 0) {
|
|
136
|
+
// The chunk may begin mid-line; drop the (possibly partial) first
|
|
137
|
+
// fragment rather than risk parsing a truncated record.
|
|
138
|
+
const firstNl = text.indexOf('\n');
|
|
139
|
+
text = firstNl === -1 ? '' : text.slice(firstNl + 1);
|
|
140
|
+
}
|
|
141
|
+
lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
142
|
+
if (lines.length >= cap || readSize >= size) break;
|
|
143
|
+
readSize = Math.min(readSize * 2, size);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const tail = lines.slice(-cap);
|
|
147
|
+
const parsed = [];
|
|
148
|
+
for (const line of tail) {
|
|
149
|
+
try {
|
|
150
|
+
parsed.push(JSON.parse(line));
|
|
151
|
+
} catch {
|
|
152
|
+
// corrupt/partial line — skip
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// File-order tail is oldest→newest; caller wants newest-first.
|
|
156
|
+
return parsed.reverse();
|
|
157
|
+
} finally {
|
|
158
|
+
await fh.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// mtime-gated cache, same convention as scheduler/prdParser.cjs's dirCache —
|
|
163
|
+
// repeated reconcile() calls between appends hit the cache instead of
|
|
164
|
+
// re-reading/re-parsing a JSONL file that only grows over time.
|
|
165
|
+
let historyCacheMtime = -1;
|
|
166
|
+
let historyCacheMap = null;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Full-file scan of history.jsonl, returning slug -> { status, finishedAt }
|
|
170
|
+
* for the most recently appended record of each slug. O(H) where H is the
|
|
171
|
+
* line count of history.jsonl; cached by the file's mtime so a reconcile()
|
|
172
|
+
* pass with no new archives since the last call is O(1).
|
|
173
|
+
*
|
|
174
|
+
* Exists so reconcile() can tell "this on-disk PRD's job row already left
|
|
175
|
+
* jobs[] into history — do not resurrect it as a fresh pending job" apart
|
|
176
|
+
* from "this is a genuinely brand-new PRD nobody has ever queued."
|
|
177
|
+
*/
|
|
178
|
+
async function historyTerminalBySlug() {
|
|
179
|
+
let mtime;
|
|
180
|
+
try {
|
|
181
|
+
mtime = (await fsp.stat(HISTORY_PATH)).mtimeMs;
|
|
182
|
+
} catch {
|
|
183
|
+
mtime = -1;
|
|
184
|
+
}
|
|
185
|
+
if (mtime === historyCacheMtime && historyCacheMap) return historyCacheMap;
|
|
186
|
+
|
|
187
|
+
const map = new Map();
|
|
188
|
+
let text = '';
|
|
189
|
+
try {
|
|
190
|
+
text = await fsp.readFile(HISTORY_PATH, 'utf8');
|
|
191
|
+
} catch (e) {
|
|
192
|
+
if (e.code !== 'ENOENT') throw e;
|
|
193
|
+
}
|
|
194
|
+
if (text) {
|
|
195
|
+
for (const line of text.split('\n')) {
|
|
196
|
+
if (!line.trim()) continue;
|
|
197
|
+
try {
|
|
198
|
+
const j = JSON.parse(line);
|
|
199
|
+
if (j?.slug) map.set(j.slug, { status: j.status, finishedAt: j.finishedAt });
|
|
200
|
+
} catch {
|
|
201
|
+
// corrupt/partial line — ignore
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
historyCacheMtime = mtime;
|
|
206
|
+
historyCacheMap = map;
|
|
207
|
+
return map;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
HISTORY_PATH,
|
|
212
|
+
partitionJobs,
|
|
213
|
+
appendHistory,
|
|
214
|
+
readHistory,
|
|
215
|
+
historyTerminalBySlug,
|
|
216
|
+
};
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* rcaFeedbackHook.cjs — files a deterministic Root Cause Analysis feedback
|
|
5
|
+
* item when a scheduler job is parked in `needs_review`.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the dodDrainHook.cjs pattern: fire-and-forget, idempotent,
|
|
8
|
+
* kill-switched, never throws to the caller. Unlike the DoD gate this fires
|
|
9
|
+
* per-job at the needs_review transition, not at queue drain.
|
|
10
|
+
*
|
|
11
|
+
* No LLM calls live here — the RCA body is assembled deterministically from
|
|
12
|
+
* the run's meta.json, log tail, and the original PRD's acceptance-criteria
|
|
13
|
+
* section. When an Opus auto-fix investigation later runs (spawnInvestigation
|
|
14
|
+
* in scheduler.cjs) and produces a root-cause summary, that text is folded
|
|
15
|
+
* into this SAME file's "Investigation analysis" section via a second call
|
|
16
|
+
* with the same (slug, runId) — never a duplicate file.
|
|
17
|
+
*
|
|
18
|
+
* Kill-switch: SM_RCA_DISABLE=1 (mirrors SM_DOD_DISABLE / SM_SUPERVISOR_DISABLE).
|
|
19
|
+
*
|
|
20
|
+
* Electron-free by design (no require('electron')) so tests stay hermetic.
|
|
21
|
+
* config.cjs itself requires('electron'), but that resolves to a harmless
|
|
22
|
+
* string (not the electron module) when required outside the Electron
|
|
23
|
+
* runtime, and this lib never touches ipcMain — only the atomic-write /
|
|
24
|
+
* path-validation helpers.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const config = require('../config.cjs');
|
|
31
|
+
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
32
|
+
const { readTail } = require('./fileTail.cjs');
|
|
33
|
+
|
|
34
|
+
const PRDS_DIR = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'prds');
|
|
35
|
+
|
|
36
|
+
// The one project this hook always knows how to reach, used as the fallback
|
|
37
|
+
// destination when a job's own cwd has no feedback inbox.
|
|
38
|
+
const SM_REPO_ROOT = path.join(os.homedir(), 'Projects', 'session-manager');
|
|
39
|
+
const SM_REPO_FEEDBACK_DIR = path.join(SM_REPO_ROOT, 'session-manager-operations', 'feedback');
|
|
40
|
+
|
|
41
|
+
// NEVER write here: ~/.claude/session-manager/feedback/ is the external
|
|
42
|
+
// watchdog's auto-PRD intake (scripts/scheduler-watchdog.cjs `sweep()`) — it
|
|
43
|
+
// converts anything dropped there into a scheduled PRD automatically. Filing
|
|
44
|
+
// an RCA there would create an RCA → auto-PRD → run → needs_review → RCA
|
|
45
|
+
// cycle with no human in the loop. Both real destinations below are project
|
|
46
|
+
// feedback INBOXES (consumed by /process-feedback), which is a different,
|
|
47
|
+
// human/agent-triaged path.
|
|
48
|
+
|
|
49
|
+
// ─── VERDICT_LABELS — deliberate duplicate ──────────────────────────────────
|
|
50
|
+
// Mirrors src/renderer/components/tabs/scheduler/sched-primitives.tsx:123-131.
|
|
51
|
+
// The main process cannot import renderer TSX, so this vocabulary is kept in
|
|
52
|
+
// sync by hand. If you change one, change the other.
|
|
53
|
+
const VERDICT_LABELS = {
|
|
54
|
+
halt: 'verifier halted',
|
|
55
|
+
deps_unmet: 'dependencies unmet',
|
|
56
|
+
transcript_errors: 'transcript had errors',
|
|
57
|
+
verify_unavailable: 'verify unavailable',
|
|
58
|
+
uncommitted_changes: 'uncommitted changes',
|
|
59
|
+
no_verdict_sentinel: 'no commit or verdict sentinel',
|
|
60
|
+
pass_no_commit: 'PASS sentinel but no commit landed',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function humanVerdict(verdict) {
|
|
64
|
+
return VERDICT_LABELS[verdict] ?? verdict ?? 'unknown';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Failure-class matching (deterministic, no LLM) ─────────────────────────
|
|
68
|
+
|
|
69
|
+
const FAILURE_CLASSES = {
|
|
70
|
+
STUCK_LOOP: 'stuck-loop',
|
|
71
|
+
POST_AC_OVERRUN: 'post-ac-overrun',
|
|
72
|
+
NO_SENTINEL: 'no-sentinel',
|
|
73
|
+
UNCOMMITTED: 'uncommitted-changes',
|
|
74
|
+
TRANSCRIPT_ERRORS: 'transcript-errors',
|
|
75
|
+
UNKNOWN: 'unknown',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const PREVENTION_HINTS = {
|
|
79
|
+
[FAILURE_CLASSES.STUCK_LOOP]:
|
|
80
|
+
'Bound every command with `timeout <N> <cmd>` — never leave an unbounded `until`/`while true`/`sleep` poll in a PRD body (PRD_AUTHORING.md loop-hang guidance).',
|
|
81
|
+
[FAILURE_CLASSES.POST_AC_OVERRUN]:
|
|
82
|
+
'Stop at the acceptance checklist — do not add "while we\'re here" work (generators, fixtures, extra polish) once the last AC item is satisfied (PRD_AUTHORING.md post-AC-overrun incident).',
|
|
83
|
+
[FAILURE_CLASSES.NO_SENTINEL]:
|
|
84
|
+
'End every run with a truthful `SCHEDULER_VERDICT: PASS`/`FAIL` sentinel as the literal last line, after the finish-protocol commit has landed.',
|
|
85
|
+
[FAILURE_CLASSES.UNCOMMITTED]:
|
|
86
|
+
'Run `git add -A && git commit` as the final finish-protocol step before printing the verdict sentinel — never end a run with a dirty working tree.',
|
|
87
|
+
[FAILURE_CLASSES.TRANSCRIPT_ERRORS]:
|
|
88
|
+
'Recover or annotate every error within ~10 lines (e.g. `# expected/handled: <why>`) instead of leaving a bare Traceback near the end of the transcript.',
|
|
89
|
+
[FAILURE_CLASSES.UNKNOWN]:
|
|
90
|
+
'Re-run the acceptance criteria gate locally against the failure log to pin down the specific break before re-queuing.',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract the <RCA>...</RCA> block an investigation agent is instructed to
|
|
95
|
+
* print before authoring its fix-plan PRD (see spawnInvestigation's prompt in
|
|
96
|
+
* scheduler.cjs). The investigation log is a stream-json transcript, so a
|
|
97
|
+
* literal newline inside the agent's text lands JSON-escaped as `\n` — unescape
|
|
98
|
+
* common JSON string escapes before matching so a multi-line block extracts
|
|
99
|
+
* cleanly. Returns null if no block is found.
|
|
100
|
+
*/
|
|
101
|
+
function extractRcaBlock(text) {
|
|
102
|
+
if (!text) return null;
|
|
103
|
+
const unescaped = text.replace(/\\n/g, '\n').replace(/\\"/g, '"');
|
|
104
|
+
const m = unescaped.match(/<RCA>([\s\S]*?)<\/RCA>/);
|
|
105
|
+
return m ? m[1].trim() : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const STUCK_LOOP_RE = /\b(until\s|while\s+true|sleep\s)/i;
|
|
109
|
+
const AC_CHECKBOX_RE = /^\s*[-*]\s*\[[xX]\]/;
|
|
110
|
+
const SENTINEL_PASS_RE = /SCHEDULER_VERDICT:\s*PASS/;
|
|
111
|
+
const STUCK_LOOP_WINDOW = 40; // "final lines" per AC
|
|
112
|
+
const POST_AC_OVERRUN_MIN_TAIL_FRACTION = 0.3;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Classify why a job likely landed in needs_review, from the log tail plus
|
|
116
|
+
* the verifier verdict. Pure/deterministic — no LLM, no I/O.
|
|
117
|
+
*
|
|
118
|
+
* Complexity: O(n) over log-tail lines (n bounded — callers pass a tail, not
|
|
119
|
+
* a full log).
|
|
120
|
+
*/
|
|
121
|
+
function classifyFailure({ verdict, logTail }) {
|
|
122
|
+
const lines = (logTail || '').split('\n');
|
|
123
|
+
|
|
124
|
+
const tailWindow = lines.slice(-STUCK_LOOP_WINDOW);
|
|
125
|
+
if (tailWindow.some((l) => STUCK_LOOP_RE.test(l))) {
|
|
126
|
+
return FAILURE_CLASSES.STUCK_LOOP;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// post-AC overrun: the log shows an AC checkbox line, then keeps going
|
|
130
|
+
// (fails) well after it, with no PASS sentinel in between — i.e. the run
|
|
131
|
+
// did the acceptance work then kept working (and died) past it.
|
|
132
|
+
let lastAcIdx = -1;
|
|
133
|
+
lines.forEach((l, i) => {
|
|
134
|
+
if (AC_CHECKBOX_RE.test(l)) lastAcIdx = i;
|
|
135
|
+
});
|
|
136
|
+
if (lastAcIdx >= 0 && lines.length > 0) {
|
|
137
|
+
const tailAfterAc = lines.slice(lastAcIdx + 1);
|
|
138
|
+
const passSeenAfter = tailAfterAc.some((l) => SENTINEL_PASS_RE.test(l));
|
|
139
|
+
const remainingFraction = tailAfterAc.length / lines.length;
|
|
140
|
+
if (!passSeenAfter && remainingFraction > POST_AC_OVERRUN_MIN_TAIL_FRACTION) {
|
|
141
|
+
return FAILURE_CLASSES.POST_AC_OVERRUN;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (verdict === 'uncommitted_changes') return FAILURE_CLASSES.UNCOMMITTED;
|
|
146
|
+
if (verdict === 'no_verdict_sentinel' || verdict === 'pass_no_commit') return FAILURE_CLASSES.NO_SENTINEL;
|
|
147
|
+
if (verdict === 'transcript_errors' || verdict === 'verify_unavailable') return FAILURE_CLASSES.TRANSCRIPT_ERRORS;
|
|
148
|
+
return FAILURE_CLASSES.UNKNOWN;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extract the `# Acceptance criteria` section (any heading level) from a PRD
|
|
153
|
+
* body, up to the next heading or end of body. Returns null if not found.
|
|
154
|
+
*/
|
|
155
|
+
function extractAcceptanceCriteria(prdBody) {
|
|
156
|
+
if (typeof prdBody !== 'string') return null;
|
|
157
|
+
const lines = prdBody.split('\n');
|
|
158
|
+
const startIdx = lines.findIndex((l) => /^#{1,6}\s*Acceptance criteria\s*$/i.test(l.trim()));
|
|
159
|
+
if (startIdx === -1) return null;
|
|
160
|
+
const rest = lines.slice(startIdx + 1);
|
|
161
|
+
const endOffset = rest.findIndex((l) => /^#{1,6}\s+\S/.test(l));
|
|
162
|
+
const section = endOffset === -1 ? rest : rest.slice(0, endOffset);
|
|
163
|
+
const text = section.join('\n').trim();
|
|
164
|
+
return text || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Destination resolution ──────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve where an RCA for `job` should be filed.
|
|
171
|
+
* 1. <job.cwd>/session-manager-operations/feedback/ if it exists.
|
|
172
|
+
* 2. Else session-manager's own repo inbox, naming the target project.
|
|
173
|
+
*/
|
|
174
|
+
function resolveDestination(job) {
|
|
175
|
+
const jobCwd = job?.cwd;
|
|
176
|
+
if (jobCwd) {
|
|
177
|
+
const candidate = path.join(jobCwd, 'session-manager-operations', 'feedback');
|
|
178
|
+
try {
|
|
179
|
+
if (fs.statSync(candidate).isDirectory()) {
|
|
180
|
+
return { dir: candidate, allowlistRoot: jobCwd, targetProjectNote: null };
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// missing/inaccessible — fall through to the session-manager fallback
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const targetProjectNote = jobCwd
|
|
187
|
+
? `Filed into session-manager's own feedback inbox because \`${jobCwd}\` has no ` +
|
|
188
|
+
`\`session-manager-operations/feedback/\` directory. Target project: \`${jobCwd}\`.`
|
|
189
|
+
: 'Filed into session-manager\'s own feedback inbox (job had no cwd on record).';
|
|
190
|
+
return { dir: SM_REPO_FEEDBACK_DIR, allowlistRoot: SM_REPO_ROOT, targetProjectNote };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function shortRunId(runId) {
|
|
194
|
+
const cleaned = String(runId ?? '').replace(/[^a-zA-Z0-9]/g, '');
|
|
195
|
+
return cleaned.slice(0, 12) || 'norun';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function rcaFileName(dateStr, slug, runId) {
|
|
199
|
+
return `${dateStr}-rca-${slug}-${shortRunId(runId)}.md`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function lastNLines(text, n) {
|
|
203
|
+
const lines = (text || '').split('\n');
|
|
204
|
+
return lines.slice(-n).join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readRunMeta(runDir, slug) {
|
|
208
|
+
if (!runDir) return null;
|
|
209
|
+
try {
|
|
210
|
+
const raw = fs.readFileSync(path.join(runDir, `${slug}.meta.json`), 'utf8');
|
|
211
|
+
return JSON.parse(raw);
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readPrdBody(slug) {
|
|
218
|
+
try {
|
|
219
|
+
const raw = fs.readFileSync(path.join(PRDS_DIR, `${slug}.md`), 'utf8');
|
|
220
|
+
return splitFrontmatter(raw).body;
|
|
221
|
+
} catch {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Assemble the deterministic RCA markdown body (frontmatter + sections).
|
|
228
|
+
* Pure — no I/O, no LLM.
|
|
229
|
+
*/
|
|
230
|
+
function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, investigationText, targetProjectNote }) {
|
|
231
|
+
const title = `Root cause: ${job.slug} → needs_review (${humanVerdict(verdict)})`;
|
|
232
|
+
const fm = ['---', `title: ${title}`, 'source: scheduler-rca', 'type: rca', 'severity: normal', `prdSlug: ${job.slug}`, `runId: ${job.runId}`, `verdict: ${verdict}`, '---'].join('\n');
|
|
233
|
+
|
|
234
|
+
const last60 = lastNLines(logTail, 60);
|
|
235
|
+
const exitCode = meta?.exitCode ?? job.exitCode ?? 'unknown';
|
|
236
|
+
const durationMs = meta?.durationMs != null ? `${meta.durationMs}ms` : 'unknown';
|
|
237
|
+
const prevention = PREVENTION_HINTS[failureClass] ?? PREVENTION_HINTS[FAILURE_CLASSES.UNKNOWN];
|
|
238
|
+
|
|
239
|
+
const sections = [
|
|
240
|
+
`# What happened\n\nJob \`${job.slug}\` was parked in \`needs_review\` with verifier verdict ` +
|
|
241
|
+
`\`${verdict}\` (${humanVerdict(verdict)}).${job.error ? `\n\n${job.error}` : ''}`,
|
|
242
|
+
`# Evidence\n\n- Exit code: ${exitCode}\n- Duration: ${durationMs}\n\nLast 60 log lines:\n\n\`\`\`\n${last60 || '(no log available)'}\n\`\`\``,
|
|
243
|
+
`# The PRD's acceptance criteria\n\n${acText || '(acceptance criteria not found — original PRD may have been archived or removed)'}`,
|
|
244
|
+
`# Likely failure class\n\n**${failureClass}**\n\nPrevention: ${prevention}`,
|
|
245
|
+
];
|
|
246
|
+
if (targetProjectNote) sections.push(`# Note\n\n${targetProjectNote}`);
|
|
247
|
+
if (investigationText) sections.push(`## Investigation analysis\n\n${investigationText.trim()}`);
|
|
248
|
+
|
|
249
|
+
return `${fm}\n\n${sections.join('\n\n')}\n`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* File (or update) a deterministic RCA feedback item for a needs_review job.
|
|
254
|
+
* Never throws — logs one line on success/skip/error and always resolves.
|
|
255
|
+
*
|
|
256
|
+
* Idempotency: the destination filename is deterministic per (slug, runId).
|
|
257
|
+
* A second call for the same (slug, runId) with no investigationText is a
|
|
258
|
+
* no-op; a second call WITH investigationText overwrites the same file to
|
|
259
|
+
* add/replace the "Investigation analysis" section rather than filing a
|
|
260
|
+
* duplicate. A different runId (new run of the same slug) files a new RCA.
|
|
261
|
+
*
|
|
262
|
+
* @param {object} opts
|
|
263
|
+
* @param {{slug: string, cwd?: string, runId: string, exitCode?: number, error?: string}} opts.job
|
|
264
|
+
* @param {string} [opts.runDir] Run directory containing <slug>.log / <slug>.meta.json.
|
|
265
|
+
* @param {string} opts.verdict Verifier verdict string (e.g. 'uncommitted_changes').
|
|
266
|
+
* @param {Array} [opts.annotations] Non-blocking verifier annotations (currently unused
|
|
267
|
+
* in the body beyond being accepted for future use).
|
|
268
|
+
* @param {string} [opts.investigationText] <RCA> block text from an Opus investigation.
|
|
269
|
+
* @param {Date} [opts.now] Override for the date segment of the filename (testing).
|
|
270
|
+
* @returns {Promise<{filed: boolean, path?: string, updated?: boolean, reason?: string}>}
|
|
271
|
+
*/
|
|
272
|
+
async function fileRcaFeedback({ job, runDir, verdict, annotations, investigationText, now } = {}) {
|
|
273
|
+
try {
|
|
274
|
+
if (process.env.SM_RCA_DISABLE === '1') {
|
|
275
|
+
console.log('[rca] skip: SM_RCA_DISABLE=1');
|
|
276
|
+
return { filed: false, reason: 'disabled' };
|
|
277
|
+
}
|
|
278
|
+
if (!job || !job.slug || !job.runId) {
|
|
279
|
+
console.log('[rca] skip: missing job.slug/job.runId');
|
|
280
|
+
return { filed: false, reason: 'missing-job-fields' };
|
|
281
|
+
}
|
|
282
|
+
// Defense-in-depth: job.slug is already constrained by the scheduler's own
|
|
283
|
+
// slug schema (ipcSchemas.cjs SCHEDULE_SLUG_RE) wherever a job is created,
|
|
284
|
+
// but this lib builds filesystem paths straight from it — a second,
|
|
285
|
+
// narrower check here costs nothing and closes off future regex laxity
|
|
286
|
+
// upstream (mirrors scheduler.cjs's safeSlugPath()).
|
|
287
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(job.slug)) {
|
|
288
|
+
console.log(`[rca] skip: unsafe job.slug ${JSON.stringify(job.slug)}`);
|
|
289
|
+
return { filed: false, reason: 'unsafe-slug' };
|
|
290
|
+
}
|
|
291
|
+
if (!verdict) {
|
|
292
|
+
console.log(`[rca] skip: no verdict for ${job.slug}`);
|
|
293
|
+
return { filed: false, reason: 'no-verdict' };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const dest = resolveDestination(job);
|
|
297
|
+
const dateStr = (now instanceof Date ? now : new Date()).toISOString().slice(0, 10);
|
|
298
|
+
const fileName = rcaFileName(dateStr, job.slug, job.runId);
|
|
299
|
+
const destPath = path.resolve(path.join(dest.dir, fileName));
|
|
300
|
+
if (!destPath.startsWith(path.resolve(dest.dir) + path.sep)) {
|
|
301
|
+
console.log(`[rca] skip: destPath escaped dest dir (${destPath})`);
|
|
302
|
+
return { filed: false, reason: 'unsafe-path' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const alreadyFiled = fs.existsSync(destPath);
|
|
306
|
+
if (alreadyFiled && !investigationText) {
|
|
307
|
+
console.log(`[rca] skip: already filed for ${job.slug}@${job.runId}`);
|
|
308
|
+
return { filed: false, reason: 'duplicate', path: destPath };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const meta = readRunMeta(runDir, job.slug);
|
|
312
|
+
const logPath = runDir ? path.join(runDir, `${job.slug}.log`) : null;
|
|
313
|
+
const logTail = logPath ? readTail(logPath, 64 * 1024) : '';
|
|
314
|
+
const acText = extractAcceptanceCriteria(readPrdBody(job.slug));
|
|
315
|
+
const failureClass = classifyFailure({ verdict, logTail });
|
|
316
|
+
|
|
317
|
+
const markdown = buildRcaMarkdown({
|
|
318
|
+
job, verdict, meta, logTail, acText, failureClass, investigationText,
|
|
319
|
+
targetProjectNote: dest.targetProjectNote, annotations,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
config.addAllowedRoot(dest.allowlistRoot);
|
|
323
|
+
await config.writeTextAtomic(destPath, markdown);
|
|
324
|
+
|
|
325
|
+
console.log(`[rca] ${alreadyFiled ? 'updated (investigation)' : 'filed'} ${destPath}`);
|
|
326
|
+
return { filed: true, path: destPath, updated: alreadyFiled };
|
|
327
|
+
} catch (e) {
|
|
328
|
+
console.error('[rca] error filing RCA feedback', e?.message ?? String(e));
|
|
329
|
+
return { filed: false, reason: 'error', error: e?.message ?? String(e) };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
fileRcaFeedback,
|
|
335
|
+
// Pure helpers, exported for unit tests.
|
|
336
|
+
humanVerdict,
|
|
337
|
+
classifyFailure,
|
|
338
|
+
extractAcceptanceCriteria,
|
|
339
|
+
extractRcaBlock,
|
|
340
|
+
resolveDestination,
|
|
341
|
+
rcaFileName,
|
|
342
|
+
buildRcaMarkdown,
|
|
343
|
+
FAILURE_CLASSES,
|
|
344
|
+
VERDICT_LABELS,
|
|
345
|
+
SM_REPO_FEEDBACK_DIR,
|
|
346
|
+
};
|
|
@@ -21,4 +21,20 @@ module.exports = {
|
|
|
21
21
|
MAX_JOB_DURATION_MS: 4 * 60 * 60_000,
|
|
22
22
|
SUPERVISOR_INTERVAL_MS: 15 * 60_000,
|
|
23
23
|
SUPERVISOR_PROBE_STALE_MS: 10 * 60_000,
|
|
24
|
+
// Trailing-edge debounce window for schedule:state broadcasts. A burst of
|
|
25
|
+
// mutations (boot reverify healing several rows, poll-loop refreshes,
|
|
26
|
+
// queue-linter fixups) arms one timer and sends a single push with the
|
|
27
|
+
// latest state, instead of one full-payload IPC broadcast per mutation.
|
|
28
|
+
// Lone mutations still arrive promptly (~200ms). State-machine transitions
|
|
29
|
+
// (pause/resume, job start/finish/reap/reset) bypass this via
|
|
30
|
+
// broadcast({ flush: true }).
|
|
31
|
+
BROADCAST_COALESCE_MS: 200,
|
|
32
|
+
// Terminal (completed/failed) jobs older than this move from queue.json's
|
|
33
|
+
// hot jobs[] into the append-only history.jsonl sidecar. See
|
|
34
|
+
// src/main/lib/queueHistory.cjs.
|
|
35
|
+
HISTORY_RETENTION_MS: 7 * 24 * 60 * 60_000,
|
|
36
|
+
// Cadence for the in-app intraday refresher that keeps TODAY's History
|
|
37
|
+
// rollup line current (see historyAggregator.cjs's refreshIntradayToday).
|
|
38
|
+
// Cheap: LRU-warm live parse, no full transcript re-read.
|
|
39
|
+
HISTORY_INTRADAY_REFRESH_MS: 5 * 60_000,
|
|
24
40
|
};
|