claude-code-session-manager 0.60.0 → 0.62.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-BtVPGBaq.js → TiptapBody-Bn-LMce4.js} +42 -42
- package/dist/assets/{index-DtNip-Zx.js → index-BfiMRkln.js} +763 -760
- package/dist/assets/{index-CPMP2XZ_.css → index-DOXvph-Z.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/main/__tests__/chat-preamble-anchors.test.cjs +100 -0
- package/src/main/__tests__/epicContextDigest.test.cjs +34 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +58 -1
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +12 -4
- package/src/main/__tests__/scheduler-verify-prd-path.test.cjs +109 -0
- package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +122 -0
- package/src/main/chatRunner.cjs +4 -0
- package/src/main/config.cjs +29 -1
- package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +87 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +197 -0
- package/src/main/lib/activeIndexMerge.cjs +9 -0
- package/src/main/lib/epicContextDigest.cjs +23 -9
- package/src/main/lib/jobWorktree.cjs +327 -0
- package/src/main/lib/rcaReport.cjs +3 -2
- package/src/main/scheduler.cjs +254 -72
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobWorktree.cjs — per-job `git worktree` isolation for concurrent scheduler
|
|
3
|
+
* runs (PRD 994).
|
|
4
|
+
*
|
|
5
|
+
* Problem: once the queue genuinely runs several jobs wide in one project,
|
|
6
|
+
* N headless `claude -p` processes edit the SAME working tree simultaneously.
|
|
7
|
+
* Observed live on 2026-08-02 with only two concurrent writers: a full test
|
|
8
|
+
* run failed on another job's half-written renderer files, a commit had to be
|
|
9
|
+
* hand-staged to avoid sweeping a sibling job's WIP, and a commit was silently
|
|
10
|
+
* rewritten to a new SHA by a concurrent rebase. This module gives each job
|
|
11
|
+
* its own linked worktree — its edits, its test runs, its commit — isolated
|
|
12
|
+
* from every sibling job and from the human's own interactive session in the
|
|
13
|
+
* same repo.
|
|
14
|
+
*
|
|
15
|
+
* ---------- the ops-root hazard (read before touching cwd plumbing) ----------
|
|
16
|
+
*
|
|
17
|
+
* `<cwd>/session-manager-operations/` (PRDs, queue state, run logs) is
|
|
18
|
+
* resolved from a project's cwd by lib/prdLocations.cjs and lib/queueStore.cjs
|
|
19
|
+
* — every function there takes `cwd` as an explicit parameter, never
|
|
20
|
+
* `process.cwd()`. That means the ops root follows WHATEVER cwd value the
|
|
21
|
+
* caller passes in. A prior incident (recorded in this project's memory as
|
|
22
|
+
* `no_schedule_self_e2e`) is exactly this mistake: swapping the job's cwd for
|
|
23
|
+
* a worktree cwd gives the job a different, EMPTY ops root and orphans its own
|
|
24
|
+
* PRD/queue row.
|
|
25
|
+
*
|
|
26
|
+
* The fix here is structural: this module never changes `job.cwd`. It hands
|
|
27
|
+
* back a SEPARATE `execCwd` (the worktree directory) that scheduler.cjs uses
|
|
28
|
+
* ONLY as the spawned child process's `cwd` spawn option. Every PRD-path
|
|
29
|
+
* resolution, queue read/write, and run-log path in scheduler.cjs keeps using
|
|
30
|
+
* `job.cwd` (the main tree) exactly as before — this module is additive, not
|
|
31
|
+
* a cwd substitution.
|
|
32
|
+
*
|
|
33
|
+
* ---------- commit-guard visibility across the worktree boundary ----------
|
|
34
|
+
*
|
|
35
|
+
* scheduler.cjs's commit-guard reads git state (uncommittedChanges/gitHead)
|
|
36
|
+
* from `job.cwd` (the main tree) before and after a run. If a job's commit
|
|
37
|
+
* only ever lands on a throwaway worktree branch, the main tree's HEAD never
|
|
38
|
+
* moves and the guard would wrongly conclude nothing was committed (this is
|
|
39
|
+
* the pre-existing `pass-no-commit-worktree-commit-invisible-at-exit` /
|
|
40
|
+
* RCA 770-pr269 incident class scheduler.cjs already has retry logic for).
|
|
41
|
+
* `integrateJobBranch` closes that gap for OUR managed worktrees: it merges
|
|
42
|
+
* the job's branch into `job.cwd`'s own HEAD (ff-only when possible, a real
|
|
43
|
+
* merge commit otherwise) BEFORE scheduler.cjs runs any post-run git check —
|
|
44
|
+
* so the commit is a real commit on the main tree, not something the guard
|
|
45
|
+
* has to go looking for.
|
|
46
|
+
*/
|
|
47
|
+
'use strict';
|
|
48
|
+
|
|
49
|
+
const fs = require('node:fs');
|
|
50
|
+
const fsp = require('node:fs/promises');
|
|
51
|
+
const path = require('node:path');
|
|
52
|
+
const os = require('node:os');
|
|
53
|
+
const crypto = require('node:crypto');
|
|
54
|
+
const { execFile } = require('node:child_process');
|
|
55
|
+
|
|
56
|
+
// Root under which every job worktree is checked out. Kept OUTSIDE any
|
|
57
|
+
// project's own tree (os.tmpdir(), not `<cwd>/.git/...`) so a job's worktree
|
|
58
|
+
// never shows up in the main tree's own file listings, `find`, or a tsc
|
|
59
|
+
// rootDir scan of `<cwd>`.
|
|
60
|
+
const WORKTREE_ROOT = path.join(os.tmpdir(), 'session-manager-job-worktrees');
|
|
61
|
+
|
|
62
|
+
// Disk estimate for this repo (session-manager): `git worktree add` checks
|
|
63
|
+
// out only git-tracked source, not `node_modules`/`dist` — a fresh checkout
|
|
64
|
+
// of this repo's tracked tree is ~30-40 MB (measured via `git ls-files | xargs
|
|
65
|
+
// du -ch` on the working tree, minus the .git object store the worktree
|
|
66
|
+
// shares with the main tree rather than duplicating). At the default cap of
|
|
67
|
+
// 4 concurrent worktrees that's under 200 MB — negligible next to the
|
|
68
|
+
// multi-GB `node_modules` a job's own `npm install`/build step may add inside
|
|
69
|
+
// its worktree (uncounted here; that risk is bounded by MIN_FREE_MB_PER_JOB's
|
|
70
|
+
// existing per-job memory gate in scheduler.cjs, not by this cap).
|
|
71
|
+
const DEFAULT_MAX_CONCURRENT_WORKTREES = 4;
|
|
72
|
+
|
|
73
|
+
function isWorktreeDisabled() {
|
|
74
|
+
return process.env.SM_JOB_WORKTREE_DISABLE === '1';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getMaxConcurrentWorktrees() {
|
|
78
|
+
const raw = Number(process.env.SM_JOB_WORKTREE_MAX);
|
|
79
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_CONCURRENT_WORKTREES;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// In-memory count of worktrees currently checked out by THIS process. Reset
|
|
83
|
+
// to 0 on every restart by design — a crash can never leave this counter
|
|
84
|
+
// permanently wedged above the cap; reconcileWorktreesOnBoot cleans up any
|
|
85
|
+
// leaked ON-DISK checkouts separately (see below).
|
|
86
|
+
let activeWorktreeCount = 0;
|
|
87
|
+
|
|
88
|
+
function execGit(args, { cwd, timeout = 20_000 } = {}) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
execFile('git', args, { cwd, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
91
|
+
if (err) {
|
|
92
|
+
err.stderrText = stderr;
|
|
93
|
+
reject(err);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
resolve(stdout || '');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function isGitRepo(cwd) {
|
|
102
|
+
if (!cwd) return false;
|
|
103
|
+
try {
|
|
104
|
+
const out = await execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 10_000 });
|
|
105
|
+
return out.trim() === 'true';
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when `cwd`'s own working tree (not any worktree) has zero pending changes. */
|
|
112
|
+
async function isBaseTreeClean(cwd) {
|
|
113
|
+
try {
|
|
114
|
+
const out = await execGit(['status', '--porcelain'], { cwd, timeout: 10_000 });
|
|
115
|
+
return out.trim().length === 0;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hashOf(input) {
|
|
122
|
+
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function worktreeDirFor(cwd, slug) {
|
|
126
|
+
return path.join(WORKTREE_ROOT, hashOf(cwd), slug);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function branchNameFor(slug) {
|
|
130
|
+
return `sm-job/${slug}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Best-effort teardown of one worktree checkout — never throws. */
|
|
134
|
+
async function removeWorktreeDir(cwd, dir) {
|
|
135
|
+
try {
|
|
136
|
+
await execGit(['worktree', 'remove', '--force', dir], { cwd, timeout: 15_000 });
|
|
137
|
+
} catch {
|
|
138
|
+
// Not registered (already removed) or dir already gone — fall through to
|
|
139
|
+
// a plain rm so a half-created checkout never leaks disk either.
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
143
|
+
} catch {
|
|
144
|
+
/* best-effort */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create a linked worktree for one job, on a fresh branch checked out from
|
|
150
|
+
* the main tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd }`
|
|
151
|
+
* on success, or `{ ok: false, reason }` — the reason is always a short,
|
|
152
|
+
* human-readable string meant to be logged verbatim so a fallback to running
|
|
153
|
+
* in place is never silent.
|
|
154
|
+
*
|
|
155
|
+
* Never throws: every failure mode (not a repo, dirty base, cap reached, git
|
|
156
|
+
* error) is a normal, expected outcome for a project that hasn't opted into
|
|
157
|
+
* — or currently can't support — isolation, not an exceptional one.
|
|
158
|
+
*/
|
|
159
|
+
async function createJobWorktree({ cwd, slug }) {
|
|
160
|
+
if (isWorktreeDisabled()) return { ok: false, reason: 'disabled via SM_JOB_WORKTREE_DISABLE=1' };
|
|
161
|
+
if (!cwd || typeof cwd !== 'string') return { ok: false, reason: 'no cwd provided' };
|
|
162
|
+
if (!slug || typeof slug !== 'string') return { ok: false, reason: 'no slug provided' };
|
|
163
|
+
|
|
164
|
+
if (!(await isGitRepo(cwd))) return { ok: false, reason: 'not a git repository' };
|
|
165
|
+
|
|
166
|
+
// A dirty base tree means the job may be depending on the human's own
|
|
167
|
+
// uncommitted WIP in `cwd` — a worktree only ever checks out committed
|
|
168
|
+
// HEAD content, so isolating into one here would silently drop that WIP
|
|
169
|
+
// from what the job sees. Falling back to running in place is strictly
|
|
170
|
+
// safer than guessing.
|
|
171
|
+
if (!(await isBaseTreeClean(cwd))) return { ok: false, reason: 'base working tree has uncommitted changes' };
|
|
172
|
+
|
|
173
|
+
if (activeWorktreeCount >= getMaxConcurrentWorktrees()) {
|
|
174
|
+
return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees()} concurrent)` };
|
|
175
|
+
}
|
|
176
|
+
// Reserve the slot SYNCHRONOUSLY (before any `await` below) so two jobs
|
|
177
|
+
// spawned in the same tick can't both pass the check above and both
|
|
178
|
+
// proceed — without this, the cap is a TOCTOU race: N concurrent callers
|
|
179
|
+
// all read the pre-increment count before either increments it. Released
|
|
180
|
+
// again below on any failure path so a failed create never permanently
|
|
181
|
+
// shrinks capacity.
|
|
182
|
+
activeWorktreeCount++;
|
|
183
|
+
|
|
184
|
+
const dir = worktreeDirFor(cwd, slug);
|
|
185
|
+
const branch = branchNameFor(slug);
|
|
186
|
+
try {
|
|
187
|
+
await fsp.mkdir(path.dirname(dir), { recursive: true });
|
|
188
|
+
// Defensive: a same-slug leftover from a prior crashed run (same slug can
|
|
189
|
+
// legitimately re-fire after a transient failure) must not collide with
|
|
190
|
+
// `git worktree add`'s own branch/path checks.
|
|
191
|
+
await removeWorktreeDir(cwd, dir);
|
|
192
|
+
try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* didn't exist */ }
|
|
193
|
+
await execGit(['worktree', 'add', '-b', branch, dir, 'HEAD'], { cwd, timeout: 30_000 });
|
|
194
|
+
} catch (e) {
|
|
195
|
+
activeWorktreeCount = Math.max(0, activeWorktreeCount - 1);
|
|
196
|
+
return { ok: false, reason: `git worktree add failed: ${(e && (e.stderrText || e.message)) || e}` };
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, dir, branch, baseCwd: cwd };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Integrate a job's branch back into `cwd`'s current HEAD — fast-forward
|
|
203
|
+
* when possible, a real merge commit when the main tree advanced underneath
|
|
204
|
+
* (a sibling job merged first) since the worktree was created. Returns
|
|
205
|
+
* `{ ok: true, integrated: boolean, ...}` on success (integrated:false means
|
|
206
|
+
* the branch had no new commits — a legitimate no-op job, not a failure), or
|
|
207
|
+
* `{ ok: false, reason }` when neither ff-only nor a real merge could land —
|
|
208
|
+
* e.g. a genuine content conflict between two jobs that touched the same
|
|
209
|
+
* lines. On failure the branch is left un-merged and NOT deleted (see
|
|
210
|
+
* cleanupJobWorktree) so the work is recoverable, never silently discarded.
|
|
211
|
+
*/
|
|
212
|
+
async function integrateJobBranch({ cwd, branch, slug }) {
|
|
213
|
+
if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
|
|
214
|
+
let branchHead;
|
|
215
|
+
try {
|
|
216
|
+
branchHead = (await execGit(['rev-parse', branch], { cwd, timeout: 10_000 })).trim();
|
|
217
|
+
} catch (e) {
|
|
218
|
+
return { ok: false, reason: `branch ${branch} not found: ${(e && (e.stderrText || e.message)) || e}` };
|
|
219
|
+
}
|
|
220
|
+
let mergeBase = '';
|
|
221
|
+
try {
|
|
222
|
+
mergeBase = (await execGit(['merge-base', 'HEAD', branch], { cwd, timeout: 10_000 })).trim();
|
|
223
|
+
} catch {
|
|
224
|
+
mergeBase = '';
|
|
225
|
+
}
|
|
226
|
+
if (mergeBase && mergeBase === branchHead) {
|
|
227
|
+
return { ok: true, integrated: false, reason: 'branch has no new commits' };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await execGit(['merge', '--ff-only', branch], { cwd, timeout: 30_000 });
|
|
232
|
+
return { ok: true, integrated: true, fastForward: true };
|
|
233
|
+
} catch {
|
|
234
|
+
// Main tree advanced since the worktree branched (a sibling job merged
|
|
235
|
+
// first) — a real merge commit still lands the job's own commit(s).
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await execGit(['merge', '--no-ff', '--no-edit', '-m', `merge scheduler job ${slug || branch}`, branch], { cwd, timeout: 30_000 });
|
|
239
|
+
return { ok: true, integrated: true, mergeCommit: true };
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
|
|
242
|
+
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
243
|
+
return { ok: false, reason: `merge failed (likely a real content conflict): ${(e && (e.stderrText || e.message)) || e}` };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Tear down one job's worktree checkout after it has been integrated (or
|
|
249
|
+
* failed to integrate). Always removes the linked-worktree checkout (freeing
|
|
250
|
+
* its disk); only deletes the branch ref when `keepBranch` is falsy — a
|
|
251
|
+
* failed integration keeps the branch around for manual recovery per
|
|
252
|
+
* integrateJobBranch's contract above. Never throws.
|
|
253
|
+
*/
|
|
254
|
+
async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
|
|
255
|
+
if (dir) await removeWorktreeDir(cwd, dir);
|
|
256
|
+
if (branch && !keepBranch) {
|
|
257
|
+
try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
|
|
258
|
+
}
|
|
259
|
+
try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
|
|
260
|
+
activeWorktreeCount = Math.max(0, activeWorktreeCount - 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
|
|
264
|
+
function parseWorktreeListPorcelain(text) {
|
|
265
|
+
const entries = [];
|
|
266
|
+
let cur = null;
|
|
267
|
+
for (const line of String(text || '').split('\n')) {
|
|
268
|
+
if (line.startsWith('worktree ')) {
|
|
269
|
+
cur = { worktree: line.slice('worktree '.length).trim(), branch: null };
|
|
270
|
+
entries.push(cur);
|
|
271
|
+
} else if (line.startsWith('branch ') && cur) {
|
|
272
|
+
cur.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return entries;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Boot reconciliation: a job worktree that survives a process crash (app
|
|
280
|
+
* killed mid-run, host reboot) leaks disk and a dangling branch forever
|
|
281
|
+
* unless something cleans it up — this is that something. For each known
|
|
282
|
+
* project cwd, lists every registered worktree, forcibly removes any that
|
|
283
|
+
* live under WORKTREE_ROOT (ours; never touches a worktree a human created
|
|
284
|
+
* for their own purposes), deletes its branch, and prunes stale registrations.
|
|
285
|
+
* Never throws — a project that isn't a git repo, or has no worktrees, is a
|
|
286
|
+
* silent no-op.
|
|
287
|
+
*/
|
|
288
|
+
async function reconcileWorktreesOnBoot(cwds) {
|
|
289
|
+
const list = Array.isArray(cwds) ? cwds.filter(Boolean) : [];
|
|
290
|
+
for (const cwd of list) {
|
|
291
|
+
if (!(await isGitRepo(cwd))) continue;
|
|
292
|
+
let out = '';
|
|
293
|
+
try {
|
|
294
|
+
out = await execGit(['worktree', 'list', '--porcelain'], { cwd, timeout: 15_000 });
|
|
295
|
+
} catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const entries = parseWorktreeListPorcelain(out);
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
if (!entry.worktree || !entry.worktree.startsWith(WORKTREE_ROOT + path.sep)) continue;
|
|
301
|
+
await removeWorktreeDir(cwd, entry.worktree);
|
|
302
|
+
if (entry.branch) {
|
|
303
|
+
try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
WORKTREE_ROOT,
|
|
312
|
+
DEFAULT_MAX_CONCURRENT_WORKTREES,
|
|
313
|
+
isWorktreeDisabled,
|
|
314
|
+
getMaxConcurrentWorktrees,
|
|
315
|
+
isGitRepo,
|
|
316
|
+
isBaseTreeClean,
|
|
317
|
+
worktreeDirFor,
|
|
318
|
+
branchNameFor,
|
|
319
|
+
createJobWorktree,
|
|
320
|
+
integrateJobBranch,
|
|
321
|
+
cleanupJobWorktree,
|
|
322
|
+
parseWorktreeListPorcelain,
|
|
323
|
+
reconcileWorktreesOnBoot,
|
|
324
|
+
// Test-only escape hatch for the in-memory concurrency counter.
|
|
325
|
+
_resetActiveWorktreeCountForTests(n = 0) { activeWorktreeCount = n; },
|
|
326
|
+
_getActiveWorktreeCountForTests() { return activeWorktreeCount; },
|
|
327
|
+
};
|
|
@@ -50,6 +50,7 @@ const VERDICT_LABELS = {
|
|
|
50
50
|
pass_no_commit: 'PASS sentinel but no commit landed',
|
|
51
51
|
pass_no_commit_already_shipped: 'PASS with no commit — deliverables already shipped',
|
|
52
52
|
pass_no_commit_prior_run_verified: 'PASS with no commit — prior run of this slug already landed the work',
|
|
53
|
+
silent_no_op: 'no commit, clean tree — no evidence of work',
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
function humanVerdict(verdict) {
|
|
@@ -81,7 +82,7 @@ const PREVENTION_HINTS = {
|
|
|
81
82
|
[FAILURE_CLASSES.NO_SENTINEL]:
|
|
82
83
|
'End every run with a truthful `SCHEDULER_VERDICT: PASS`/`FAIL` sentinel as the literal last line, after the finish-protocol commit has landed.',
|
|
83
84
|
[FAILURE_CLASSES.UNCOMMITTED]:
|
|
84
|
-
'
|
|
85
|
+
'Stage the exact paths you created or modified — never a blanket/wildcard git-add of the whole tree, since the queue can run sibling jobs against this same working tree and a blanket add sweeps up their in-flight edits — and `git commit` as the final finish-protocol step before printing the verdict sentinel; never end a run with a dirty working tree.',
|
|
85
86
|
[FAILURE_CLASSES.TRANSCRIPT_ERRORS]:
|
|
86
87
|
'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.',
|
|
87
88
|
[FAILURE_CLASSES.UNKNOWN]:
|
|
@@ -166,7 +167,7 @@ function classifyFailure({ verdict, logTail }) {
|
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
if (verdict === 'uncommitted_changes') return FAILURE_CLASSES.UNCOMMITTED;
|
|
170
|
+
if (verdict === 'uncommitted_changes' || verdict === 'silent_no_op') return FAILURE_CLASSES.UNCOMMITTED;
|
|
170
171
|
if (verdict === 'no_verdict_sentinel' || verdict === 'pass_no_commit') return FAILURE_CLASSES.NO_SENTINEL;
|
|
171
172
|
if (verdict === 'transcript_errors' || verdict === 'verify_unavailable') return FAILURE_CLASSES.TRANSCRIPT_ERRORS;
|
|
172
173
|
return FAILURE_CLASSES.UNKNOWN;
|