claude-code-session-manager 0.80.0 → 0.82.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-zS3jw_1e.js → AgentLibrary-pwlkAFb3.js} +1 -1
- package/dist/assets/{DataModel-Cy_vxTpi.js → DataModel-BZK9PXFD.js} +1 -1
- package/dist/assets/{History-C6JRuqfT.js → History-BVRjxJjS.js} +1 -1
- package/dist/assets/{Hooks-BafPy9mB.js → Hooks-CNuwHeGx.js} +1 -1
- package/dist/assets/{HostBilko-BZwhQOFt.js → HostBilko-cjwNodhV.js} +1 -1
- package/dist/assets/{Library-C8JDDliz.js → Library-YPNm9W92.js} +1 -1
- package/dist/assets/{ListDetail-CqiOdwLc.js → ListDetail-CY4GM1Om.js} +1 -1
- package/dist/assets/{MarkdownEditor-CyLyP67L.js → MarkdownEditor-BF4y2Jiz.js} +1 -1
- package/dist/assets/{McpServers-BzMv-_84.js → McpServers-CmBdWtX_.js} +1 -1
- package/dist/assets/{Memory-DSBYQdJR.js → Memory-CvkIXNl1.js} +1 -1
- package/dist/assets/{Panel-CLUhkNNA.js → Panel-D93o-sxe.js} +1 -1
- package/dist/assets/{Permissions-BfC2-HN4.js → Permissions-DQipg16I.js} +1 -1
- package/dist/assets/{Plugins-BKi40jT5.js → Plugins-B6NwzPfK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BzFw4KhD.js → ProvenanceBadge-DACVJhrB.js} +1 -1
- package/dist/assets/{SaveBar-avk2p9jv.js → SaveBar-Cg4lbChb.js} +1 -1
- package/dist/assets/{Scheduler-Bf_6MdJo.js → Scheduler-Dr5ZcBLe.js} +1 -1
- package/dist/assets/{ScopeSwitcher-C-RwYUVZ.js → ScopeSwitcher-C-locvy0.js} +1 -1
- package/dist/assets/{Settings-Djd8OoBA.js → Settings-BVrAle90.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DuogY6s7.js → SkillReferenceGraph-DDzuYgSK.js} +1 -1
- package/dist/assets/{Skills-D_qAqxZ_.js → Skills-DFvhiOAQ.js} +1 -1
- package/dist/assets/{SystemPrompt-DbHFLQV3.js → SystemPrompt-8PiTyUyL.js} +1 -1
- package/dist/assets/{TagLibrary-C2y91BT0.js → TagLibrary-DruUYaAc.js} +1 -1
- package/dist/assets/{TiptapBody-D9iz4xQx.js → TiptapBody-Dr4a--42.js} +1 -1
- package/dist/assets/{Toggle-BGnFL2E5.js → Toggle-B_EH2TFb.js} +1 -1
- package/dist/assets/{index-_2ARyFDj.js → index-DApB4DHS.js} +4 -4
- package/dist/assets/{settingsSchema-JK15eJU8.js → settingsSchema-BKa-xk8g.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/scripts/project-pages-logic/dist/logic.cjs +12 -12
- package/scripts/render-project-pages/dist/renderer.cjs +22 -22
- package/src/main/__tests__/machineProfile.test.cjs +134 -0
- package/src/main/__tests__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +85 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- package/src/main/__tests__/telemetrySettings.test.cjs +178 -0
- package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/branchSweep.cjs +127 -0
- package/src/main/lib/gitWorktree.cjs +20 -0
- package/src/main/lib/landedSinceRun.cjs +41 -1
- package/src/main/lib/machineProfile.cjs +144 -0
- package/src/main/lib/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +16 -3
- package/src/main/lib/telemetrySettings.cjs +171 -0
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler.cjs +768 -34
|
@@ -19,7 +19,12 @@ const assert = require('node:assert/strict');
|
|
|
19
19
|
const fs = require('node:fs');
|
|
20
20
|
const os = require('node:os');
|
|
21
21
|
const path = require('node:path');
|
|
22
|
-
const {
|
|
22
|
+
const { spawn } = require('node:child_process');
|
|
23
|
+
const {
|
|
24
|
+
selectReapableJobs, mapOutcomeToGateOutcome, classifyRunOutcome,
|
|
25
|
+
findLiveProcessForJob, logHasOutput, resolvePidlessGateOutcome,
|
|
26
|
+
isAlreadySatisfiedOnMain,
|
|
27
|
+
} = require('../reaperHelpers.cjs');
|
|
23
28
|
const { detectRateLimitInLog } = require('../rateLimitDetect.cjs');
|
|
24
29
|
|
|
25
30
|
const NOW = Date.parse('2026-09-01T12:00:00.000Z');
|
|
@@ -101,6 +106,33 @@ test('non-running rows are never considered, pidless or not', () => {
|
|
|
101
106
|
assert.deepStrictEqual(warnings, []);
|
|
102
107
|
});
|
|
103
108
|
|
|
109
|
+
test('pidless + past grace + findLiveProcess finds a pid → recovered, never reaped', () => {
|
|
110
|
+
const jobs = [{ slug: 'zombie-alive', status: 'running', startedAt: agoMin(464) }];
|
|
111
|
+
const findLiveProcess = () => 2174739;
|
|
112
|
+
const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE, findLiveProcess });
|
|
113
|
+
assert.deepStrictEqual(reapable, []);
|
|
114
|
+
assert.deepStrictEqual(warnings, []);
|
|
115
|
+
assert.strictEqual(recovered.length, 1);
|
|
116
|
+
assert.strictEqual(recovered[0].slug, 'zombie-alive');
|
|
117
|
+
assert.strictEqual(recovered[0].pid, 2174739);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('pidless + past grace + findLiveProcess finds nothing → reaped exactly as today', () => {
|
|
121
|
+
const jobs = [{ slug: 'zombie-dead', status: 'running', startedAt: agoMin(464) }];
|
|
122
|
+
const findLiveProcess = () => null;
|
|
123
|
+
const { reapable, recovered } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE, findLiveProcess });
|
|
124
|
+
assert.deepStrictEqual(recovered, []);
|
|
125
|
+
assert.strictEqual(reapable.length, 1);
|
|
126
|
+
assert.strictEqual(reapable[0].pidless, true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('pidless + past grace + no findLiveProcess supplied → reaped exactly as before this PRD', () => {
|
|
130
|
+
const jobs = [{ slug: 'zombie', status: 'running', startedAt: agoMin(464) }];
|
|
131
|
+
const { reapable, recovered } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
|
|
132
|
+
assert.strictEqual(reapable.length, 1);
|
|
133
|
+
assert.deepStrictEqual(recovered, []);
|
|
134
|
+
});
|
|
135
|
+
|
|
104
136
|
test('mixed batch: each row classified independently', () => {
|
|
105
137
|
const jobs = [
|
|
106
138
|
{ slug: 'zombie', status: 'running', startedAt: agoMin(464) },
|
|
@@ -220,3 +252,90 @@ test('classifyRunOutcome: a clean success log still classifies as success', () =
|
|
|
220
252
|
const p = writeTmpLog('{"type":"result","subtype":"success","is_error":false,"result":"done"}\n');
|
|
221
253
|
assert.strictEqual(classifyRunOutcome(p), 'success');
|
|
222
254
|
});
|
|
255
|
+
|
|
256
|
+
// findLiveProcessForJob — the /proc positive liveness scan behind the
|
|
257
|
+
// 2026-09-06 stranded-branch incident (234-uranus-eight-tails-ox: marked
|
|
258
|
+
// failed/never_ran while its process was alive and still writing to its
|
|
259
|
+
// worktree). Linux-only, and a safe no-op everywhere else per its own header.
|
|
260
|
+
|
|
261
|
+
test('findLiveProcessForJob: finds a live process via /proc/<pid>/cwd match', async () => {
|
|
262
|
+
if (process.platform !== 'linux' || !fs.existsSync('/proc')) return;
|
|
263
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reaper-liveness-'));
|
|
264
|
+
const child = spawn('sleep', ['5'], { cwd: dir, stdio: 'ignore' });
|
|
265
|
+
try {
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
267
|
+
const pid = findLiveProcessForJob({ slug: 'irrelevant-slug' }, { worktreeDir: dir });
|
|
268
|
+
assert.strictEqual(pid, child.pid);
|
|
269
|
+
} finally {
|
|
270
|
+
child.kill('SIGKILL');
|
|
271
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('findLiveProcessForJob: no live process anywhere → null', () => {
|
|
276
|
+
if (process.platform !== 'linux' || !fs.existsSync('/proc')) return;
|
|
277
|
+
const pid = findLiveProcessForJob(
|
|
278
|
+
{ slug: 'definitely-not-a-real-running-slug-xyz123' },
|
|
279
|
+
{ worktreeDir: '/nonexistent/worktree/dir/xyz123' },
|
|
280
|
+
);
|
|
281
|
+
assert.strictEqual(pid, null);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// logHasOutput — the literal "did the run dir produce any log output" check
|
|
285
|
+
// that gates whether a pidless reap may assert gateOutcome: 'never_ran'.
|
|
286
|
+
|
|
287
|
+
test('logHasOutput: missing file → false', () => {
|
|
288
|
+
assert.strictEqual(logHasOutput('/nonexistent/path/does-not-exist.log'), false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('logHasOutput: empty file → false', () => {
|
|
292
|
+
const p = writeTmpLog('');
|
|
293
|
+
assert.strictEqual(logHasOutput(p), false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('logHasOutput: file with content → true', () => {
|
|
297
|
+
const p = writeTmpLog('some real log output\n');
|
|
298
|
+
assert.strictEqual(logHasOutput(p), true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('logHasOutput: null path → false', () => {
|
|
302
|
+
assert.strictEqual(logHasOutput(null), false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// resolvePidlessGateOutcome — never_ran is asserted ONLY when the run dir has
|
|
306
|
+
// no log output; a run dir WITH output must not claim never_ran even if no
|
|
307
|
+
// clean result event was found in it.
|
|
308
|
+
|
|
309
|
+
test('resolvePidlessGateOutcome: no output → never_ran, regardless of raw outcome (terminalizes as today)', () => {
|
|
310
|
+
assert.strictEqual(resolvePidlessGateOutcome('no_result', false), 'never_ran');
|
|
311
|
+
assert.strictEqual(resolvePidlessGateOutcome('success', false), 'never_ran');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('resolvePidlessGateOutcome: output present + no_result → failed, never never_ran', () => {
|
|
315
|
+
assert.strictEqual(resolvePidlessGateOutcome('no_result', true), 'failed');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('resolvePidlessGateOutcome: output present + success → passed', () => {
|
|
319
|
+
assert.strictEqual(resolvePidlessGateOutcome('success', true), 'passed');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('resolvePidlessGateOutcome: output present + failed → failed', () => {
|
|
323
|
+
assert.strictEqual(resolvePidlessGateOutcome('failed', true), 'failed');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// isAlreadySatisfiedOnMain — PRD 1136. Pure decision layer: given the
|
|
327
|
+
// already-git-queried list of satisfying commits, decide whether the
|
|
328
|
+
// commit-guard's 'silent_no_op' verdict should be overridden to 'completed'.
|
|
329
|
+
|
|
330
|
+
test('isAlreadySatisfiedOnMain: a satisfying commit → completed-shaped verdict naming the sha', () => {
|
|
331
|
+
const verdict = isAlreadySatisfiedOnMain(['5de4134abc123', 'a96de98def456']);
|
|
332
|
+
assert.strictEqual(verdict.sha, '5de4134abc123');
|
|
333
|
+
assert.strictEqual(verdict.verdict, 'already_satisfied_on_main');
|
|
334
|
+
assert.match(verdict.reason, /5de4134abc123/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('isAlreadySatisfiedOnMain: no satisfying commits → null — the gate must not be weakened into always-passing', () => {
|
|
338
|
+
assert.strictEqual(isAlreadySatisfiedOnMain([]), null);
|
|
339
|
+
assert.strictEqual(isAlreadySatisfiedOnMain(null), null);
|
|
340
|
+
assert.strictEqual(isAlreadySatisfiedOnMain(undefined), null);
|
|
341
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branchSweep.cjs — periodic recovery for `sm-job/<slug>` branches whose
|
|
3
|
+
* owning job row was terminalized (or lost entirely) without the branch ever
|
|
4
|
+
* being merged back into its project's HEAD (PRD 1135).
|
|
5
|
+
*
|
|
6
|
+
* The reaper's own liveness check (reaperHelpers.cjs findLiveProcessForJob)
|
|
7
|
+
* stops NEW stranding at the source; this module recovers anything already
|
|
8
|
+
* stranded before that fix landed, or by a crash the liveness check can't
|
|
9
|
+
* cover (app killed between a job's commit and its own integrateJobBranch
|
|
10
|
+
* call). Two halves of one invariant — see the PRD body.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately never deletes a branch (`git branch -D` or otherwise) —
|
|
13
|
+
* reclaiming disk is out of scope; this module only ever merges forward.
|
|
14
|
+
*
|
|
15
|
+
* Kept free of scheduler.cjs / electron / queueStore coupling (same reason
|
|
16
|
+
* reaperHelpers.cjs is separate — importable and unit-testable without
|
|
17
|
+
* requiring electron) — callers own persisting a `conflict` result onto a
|
|
18
|
+
* job row.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { execFile } = require('node:child_process');
|
|
23
|
+
const gitWorktree = require('./gitWorktree.cjs');
|
|
24
|
+
|
|
25
|
+
function execGit(cwd, args, timeout = 20_000) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
execFile('git', ['-C', cwd, ...args], { timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
28
|
+
if (err) {
|
|
29
|
+
err.stderrText = stderr;
|
|
30
|
+
reject(err);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve(stdout || '');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** List every local branch under `refs/heads/sm-job/*`. Never throws. */
|
|
39
|
+
async function listSmJobBranches(cwd) {
|
|
40
|
+
try {
|
|
41
|
+
const out = await execGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/sm-job/*']);
|
|
42
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Branch names currently checked out in any linked worktree (incl. main). Never throws. */
|
|
49
|
+
async function listCheckedOutBranches(cwd) {
|
|
50
|
+
try {
|
|
51
|
+
const out = await execGit(cwd, ['worktree', 'list', '--porcelain']);
|
|
52
|
+
return gitWorktree.parseWorktreeListPorcelain(out)
|
|
53
|
+
.map((e) => e.branch)
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'skipped']);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* sweepStrandedJobBranches({ cwd, jobs, attemptedBranches }) → { results }
|
|
64
|
+
*
|
|
65
|
+
* `jobs` is the full in-memory queue snapshot (any project's rows may be
|
|
66
|
+
* present; matched by slug only, since a slug is globally unique). `attemptedBranches`
|
|
67
|
+
* is a caller-owned `Set` of `"<cwd>::<branch>"` keys — persisted across calls
|
|
68
|
+
* (in-memory is enough: the goal is "not retried every cycle forever" while
|
|
69
|
+
* the process is up, not durability across a restart) so a branch that
|
|
70
|
+
* conflicts once is never retried again in the same process lifetime.
|
|
71
|
+
*
|
|
72
|
+
* Each result is `{ branch, slug, action }` where action is one of:
|
|
73
|
+
* 'skip-checked-out' — branch is checked out in a live worktree, left alone
|
|
74
|
+
* 'no-op-merged' — branch has no commits beyond HEAD, nothing to do
|
|
75
|
+
* 'skip-active-row' — job row exists and is NOT terminal, left alone
|
|
76
|
+
* 'skip-already-attempted' — a prior sweep already tried and failed this branch
|
|
77
|
+
* 'integrated' — merged into HEAD this pass (`integration` attached)
|
|
78
|
+
* 'conflict' — one bounded integrateBranch attempt failed (`integration` attached)
|
|
79
|
+
*
|
|
80
|
+
* SM_BRANCH_SWEEP_DISABLE=1 restores today's behaviour exactly (no-op),
|
|
81
|
+
* mirroring SM_RESUME_RECOVERY_DISABLE.
|
|
82
|
+
*/
|
|
83
|
+
async function sweepStrandedJobBranches({ cwd, jobs, attemptedBranches }) {
|
|
84
|
+
if (process.env.SM_BRANCH_SWEEP_DISABLE === '1') return { results: [] };
|
|
85
|
+
if (!cwd || !(await gitWorktree.isGitRepo(cwd))) return { results: [] };
|
|
86
|
+
|
|
87
|
+
const attempted = attemptedBranches instanceof Set ? attemptedBranches : new Set();
|
|
88
|
+
const branches = await listSmJobBranches(cwd);
|
|
89
|
+
if (!branches.length) return { results: [] };
|
|
90
|
+
const checkedOut = new Set(await listCheckedOutBranches(cwd));
|
|
91
|
+
const jobsBySlug = new Map((Array.isArray(jobs) ? jobs : []).map((j) => [j.slug, j]));
|
|
92
|
+
|
|
93
|
+
const results = [];
|
|
94
|
+
for (const branch of branches) {
|
|
95
|
+
const slug = gitWorktree.keyFromBranch('job', branch);
|
|
96
|
+
if (checkedOut.has(branch)) {
|
|
97
|
+
results.push({ branch, slug, action: 'skip-checked-out' });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const merged = await gitWorktree.isBranchMergedIntoHead(cwd, branch);
|
|
101
|
+
if (merged) {
|
|
102
|
+
results.push({ branch, slug, action: 'no-op-merged' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const row = slug ? jobsBySlug.get(slug) : null;
|
|
106
|
+
const terminalOrMissing = !row || TERMINAL_JOB_STATUSES.has(row.status);
|
|
107
|
+
if (!terminalOrMissing) {
|
|
108
|
+
results.push({ branch, slug, action: 'skip-active-row' });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const attemptKey = `${cwd}::${branch}`;
|
|
112
|
+
if (attempted.has(attemptKey)) {
|
|
113
|
+
results.push({ branch, slug, action: 'skip-already-attempted' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
attempted.add(attemptKey);
|
|
117
|
+
const integration = await gitWorktree.integrateJobBranch({ cwd, branch, slug: slug || branch });
|
|
118
|
+
results.push({ branch, slug, action: integration.ok ? 'integrated' : 'conflict', integration });
|
|
119
|
+
}
|
|
120
|
+
return { results };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
listSmJobBranches,
|
|
125
|
+
listCheckedOutBranches,
|
|
126
|
+
sweepStrandedJobBranches,
|
|
127
|
+
};
|
|
@@ -859,6 +859,25 @@ async function salvageDirtyDelta({ cwd, paths, outFile }) {
|
|
|
859
859
|
}
|
|
860
860
|
}
|
|
861
861
|
|
|
862
|
+
/**
|
|
863
|
+
* True when every commit on `branch` is already an ancestor of (or equal to)
|
|
864
|
+
* `cwd`'s current HEAD — i.e. the branch holds no work that isn't already
|
|
865
|
+
* landed. Used by the stranded-branch sweep (branchSweep.cjs) to tell a
|
|
866
|
+
* genuinely stranded `sm-job/*` branch from one that's already a no-op.
|
|
867
|
+
* Never throws; a branch that doesn't exist, or any other git failure, is
|
|
868
|
+
* conservatively reported as NOT merged so a caller never treats an
|
|
869
|
+
* unreadable branch as safe to skip.
|
|
870
|
+
*/
|
|
871
|
+
async function isBranchMergedIntoHead(cwd, branch) {
|
|
872
|
+
try {
|
|
873
|
+
const branchHead = (await execGit(['rev-parse', '--verify', branch], { cwd, timeout: 10_000 })).trim();
|
|
874
|
+
const mergeBase = (await execGit(['merge-base', 'HEAD', branch], { cwd, timeout: 10_000 })).trim();
|
|
875
|
+
return mergeBase !== '' && mergeBase === branchHead;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
862
881
|
/** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
|
|
863
882
|
function parseWorktreeListPorcelain(text) {
|
|
864
883
|
const entries = [];
|
|
@@ -1003,6 +1022,7 @@ module.exports = {
|
|
|
1003
1022
|
cleanupWorktree,
|
|
1004
1023
|
salvageWorktreeDiff,
|
|
1005
1024
|
salvageDirtyDelta,
|
|
1025
|
+
isBranchMergedIntoHead,
|
|
1006
1026
|
parseWorktreeListPorcelain,
|
|
1007
1027
|
reconcileWorktreesOnBoot,
|
|
1008
1028
|
sweepStaleWorktreeCheckouts,
|
|
@@ -52,4 +52,44 @@ function landedSinceRun(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIM
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Commits reachable from `main` in `cwd` since `sinceIso` that touch any of
|
|
57
|
+
* `paths` — the evidence query behind PRD 1136's already-satisfied-on-main
|
|
58
|
+
* commit-guard exemption. Deliberately narrower than landedSinceRun in one
|
|
59
|
+
* axis (scoped to `main`, not `--all` — a commit on a stray branch is not
|
|
60
|
+
* "satisfied") and identical in the other (path-scoped, so an unrelated
|
|
61
|
+
* commit elsewhere in the repo is never credited to this job).
|
|
62
|
+
*
|
|
63
|
+
* Falls back from local `main` to `origin/main` when the local branch ref
|
|
64
|
+
* doesn't exist (a worktree/CI checkout that never checked main out
|
|
65
|
+
* locally) — never throws either way; git-unavailable, a non-repo cwd, an
|
|
66
|
+
* unresolvable ref, or an empty `paths` list all resolve to `[]`.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd
|
|
69
|
+
* @param {string} sinceIso
|
|
70
|
+
* @param {string[]} paths
|
|
71
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
72
|
+
* @returns {Promise<string[]>} full commit SHAs, newest first
|
|
73
|
+
*/
|
|
74
|
+
function landedOnMainSince(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIMEOUT_MS } = {}) {
|
|
75
|
+
const runLog = (ref) => new Promise((resolve) => {
|
|
76
|
+
execFile(
|
|
77
|
+
'git',
|
|
78
|
+
['-C', cwd, 'log', ref, `--since=${sinceIso}`, '--format=%H', '--', ...paths],
|
|
79
|
+
{ timeout: timeoutMs, windowsHide: true },
|
|
80
|
+
(err, stdout) => {
|
|
81
|
+
if (err) { resolve(null); return; }
|
|
82
|
+
resolve(String(stdout || '').trim().split('\n').filter(Boolean));
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
return (async () => {
|
|
87
|
+
if (!cwd || !sinceIso || !Array.isArray(paths) || paths.length === 0) return [];
|
|
88
|
+
const viaLocal = await runLog('main');
|
|
89
|
+
if (viaLocal !== null) return viaLocal;
|
|
90
|
+
const viaRemote = await runLog('origin/main');
|
|
91
|
+
return viaRemote ?? [];
|
|
92
|
+
})();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { landedSinceRun, landedOnMainSince, LANDED_SINCE_RUN_TIMEOUT_MS };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* machineProfile — builds the "once per machine" telemetry payload: which
|
|
3
|
+
* build (appVersion) this install id is running, plus basic hardware/OS specs.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately anonymous: no hostname, no OS username, no homedir, no MAC
|
|
6
|
+
* address, no hardware serial, no absolute filesystem path, no IANA timezone
|
|
7
|
+
* name (offset only). machineDigest is a coarse hash of stable spec fields —
|
|
8
|
+
* a hardware-CLASS fingerprint, never a device id — so individual records
|
|
9
|
+
* can be joined back to a machine class without repeating the whole profile.
|
|
10
|
+
*
|
|
11
|
+
* Pure data plumbing: no network code, no call sites. Later PRDs consume it.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const claudeBin = require('./claudeBin.cjs');
|
|
19
|
+
|
|
20
|
+
function resolveElectronApp() {
|
|
21
|
+
try {
|
|
22
|
+
// eslint-disable-next-line global-require
|
|
23
|
+
const electron = require('electron');
|
|
24
|
+
if (electron && typeof electron === 'object' && typeof electron.app === 'object') {
|
|
25
|
+
return electron.app;
|
|
26
|
+
}
|
|
27
|
+
} catch { /* no electron runtime (plain vitest, or electron require resolved to a path string) */ }
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolvePackageVersion() {
|
|
32
|
+
try {
|
|
33
|
+
// src/main/lib/machineProfile.cjs -> src/main/lib -> src/main -> src -> repo root
|
|
34
|
+
const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json'));
|
|
35
|
+
return (pkg && typeof pkg.version === 'string') ? pkg.version : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 'npx' when the app path lives inside an npm/npx cache dir, 'dev' when
|
|
43
|
+
* SM_DEV is set, else 'unknown'. Injectable so tests never depend on real
|
|
44
|
+
* process state.
|
|
45
|
+
*/
|
|
46
|
+
function resolveInstallChannel({ appPath = null, devFlag = !!process.env.SM_DEV } = {}) {
|
|
47
|
+
if (devFlag) return 'dev';
|
|
48
|
+
if (typeof appPath === 'string' && /[\\/](\.npm|_npx|npm-cache|npx-cache)[\\/]/i.test(appPath)) {
|
|
49
|
+
return 'npx';
|
|
50
|
+
}
|
|
51
|
+
return 'unknown';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** sha256 of the concatenated stable spec fields, truncated to 12 hex chars. */
|
|
55
|
+
function computeMachineDigest(specs) {
|
|
56
|
+
const material = [
|
|
57
|
+
specs.platform,
|
|
58
|
+
specs.osRelease,
|
|
59
|
+
specs.arch,
|
|
60
|
+
specs.cpuModel || '',
|
|
61
|
+
String(specs.cpuCount),
|
|
62
|
+
String(specs.totalMemMb),
|
|
63
|
+
].join('|');
|
|
64
|
+
return crypto.createHash('sha256').update(material).digest('hex').slice(0, 12);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Reads basic, non-identifying specs from node:os. Null-safe when os.cpus() is empty. */
|
|
68
|
+
function readSpecs(osModule) {
|
|
69
|
+
const cpus = osModule.cpus() || [];
|
|
70
|
+
const cpu0 = cpus[0] || null;
|
|
71
|
+
return {
|
|
72
|
+
platform: osModule.platform(),
|
|
73
|
+
osRelease: osModule.release(),
|
|
74
|
+
arch: osModule.arch(),
|
|
75
|
+
cpuModel: cpu0 && typeof cpu0.model === 'string' ? cpu0.model.trim() : null,
|
|
76
|
+
cpuSpeedMhz: cpu0 && typeof cpu0.speed === 'number' ? cpu0.speed : null,
|
|
77
|
+
cpuCount: cpus.length,
|
|
78
|
+
totalMemMb: Math.round(osModule.totalmem() / 1048576),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Builds the machine profile. All external dependencies are injectable so
|
|
84
|
+
* this is fully testable under plain vitest with no Electron runtime and no
|
|
85
|
+
* real Claude CLI.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} [opts]
|
|
88
|
+
* @param {object} [opts.osModule] - defaults to node:os
|
|
89
|
+
* @param {function} [opts.probeClaudeVersion] - defaults to claudeBin.probeClaudeVersion; must resolve to a string or null and never block startup
|
|
90
|
+
* @param {object|null} [opts.electronApp] - override for tests; when omitted, 'electron' is required lazily
|
|
91
|
+
* @param {string} [opts.firstSeenAt] - ISO timestamp; defaults to now (callers that track a persisted first-seen value should pass it)
|
|
92
|
+
*/
|
|
93
|
+
async function buildMachineProfile(opts = {}) {
|
|
94
|
+
const osModule = opts.osModule || os;
|
|
95
|
+
const probeClaudeVersion = opts.probeClaudeVersion || claudeBin.probeClaudeVersion;
|
|
96
|
+
const electronApp = Object.prototype.hasOwnProperty.call(opts, 'electronApp')
|
|
97
|
+
? opts.electronApp
|
|
98
|
+
: resolveElectronApp();
|
|
99
|
+
|
|
100
|
+
let appVersion = null;
|
|
101
|
+
let appPath = null;
|
|
102
|
+
if (electronApp) {
|
|
103
|
+
try { appVersion = electronApp.getVersion(); } catch { /* not ready / not a real app object */ }
|
|
104
|
+
try { appPath = electronApp.getAppPath(); } catch { /* not ready / not a real app object */ }
|
|
105
|
+
}
|
|
106
|
+
if (!appVersion) appVersion = resolvePackageVersion();
|
|
107
|
+
|
|
108
|
+
const specs = readSpecs(osModule);
|
|
109
|
+
const machineDigest = computeMachineDigest(specs);
|
|
110
|
+
|
|
111
|
+
let claudeCliVersion = null;
|
|
112
|
+
try {
|
|
113
|
+
claudeCliVersion = await probeClaudeVersion();
|
|
114
|
+
} catch {
|
|
115
|
+
claudeCliVersion = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
appVersion,
|
|
120
|
+
installChannel: resolveInstallChannel({ appPath, devFlag: !!process.env.SM_DEV }),
|
|
121
|
+
machineDigest,
|
|
122
|
+
platform: specs.platform,
|
|
123
|
+
osRelease: specs.osRelease,
|
|
124
|
+
arch: specs.arch,
|
|
125
|
+
cpuModel: specs.cpuModel,
|
|
126
|
+
cpuCount: specs.cpuCount,
|
|
127
|
+
cpuSpeedMhz: specs.cpuSpeedMhz,
|
|
128
|
+
totalMemMb: specs.totalMemMb,
|
|
129
|
+
nodeVersion: process.versions.node || null,
|
|
130
|
+
electronVersion: process.versions.electron || null,
|
|
131
|
+
chromeVersion: process.versions.chrome || null,
|
|
132
|
+
v8Version: process.versions.v8 || null,
|
|
133
|
+
claudeCliVersion,
|
|
134
|
+
locale: Intl.DateTimeFormat().resolvedOptions().locale || null,
|
|
135
|
+
timezoneOffsetMinutes: new Date().getTimezoneOffset(),
|
|
136
|
+
firstSeenAt: opts.firstSeenAt || new Date().toISOString(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
buildMachineProfile,
|
|
142
|
+
computeMachineDigest,
|
|
143
|
+
resolveInstallChannel,
|
|
144
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* rateLimitWindow.cjs — reads the BINDING rate-limit window's reset time
|
|
5
|
+
* straight off a run's own log, instead of trusting the billing usage
|
|
6
|
+
* endpoint's five_hour window (PRD 1118). A run can be 429'd by a
|
|
7
|
+
* different window than five_hour (seven_day, seven_day_overage_included)
|
|
8
|
+
* while five_hour utilization is still 0 — pausing against five_hour's
|
|
9
|
+
* reset in that case arms the resume timer against the wrong clock, and
|
|
10
|
+
* the billing endpoint can itself be 429'ing when this is needed most.
|
|
11
|
+
*
|
|
12
|
+
* Pure: log path in, reset value out. No electron import, so it is
|
|
13
|
+
* unit-testable without scheduler.cjs (mirrors reaperHelpers.cjs's split).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { readTail } = require('./fileTail.cjs');
|
|
17
|
+
|
|
18
|
+
// Authoritative window block rides on the LAST rate_limit_event in the log
|
|
19
|
+
// (often the one attached to the final result), so mirror classifyRunOutcome's
|
|
20
|
+
// 64 KB tail rather than detectRateLimitInLog's smaller 16 KB one.
|
|
21
|
+
const TAIL_BYTES = 64 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Scans a run log's tail for rate_limit_event entries and returns the
|
|
25
|
+
* UNIX-seconds resetsAt of the window that actually bound (utilization >= 1.0)
|
|
26
|
+
* in the LAST such event. When multiple windows bind in that event, the
|
|
27
|
+
* latest resetsAt wins. Returns null when no rate_limit_event is found, or
|
|
28
|
+
* none of its windows are at/above 1.0 utilization.
|
|
29
|
+
*/
|
|
30
|
+
function resolveBindingRateLimitReset(logPath) {
|
|
31
|
+
const text = readTail(logPath, TAIL_BYTES);
|
|
32
|
+
if (!text) return null;
|
|
33
|
+
|
|
34
|
+
let lastWindows = null;
|
|
35
|
+
for (const line of text.split('\n')) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed || trimmed[0] !== '{') continue;
|
|
38
|
+
let event;
|
|
39
|
+
try {
|
|
40
|
+
event = JSON.parse(trimmed);
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const windows = event?.rate_limit_info?.unifiedWindows;
|
|
45
|
+
if (event?.type === 'rate_limit_event' && windows && typeof windows === 'object') {
|
|
46
|
+
lastWindows = windows;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!lastWindows) return null;
|
|
50
|
+
|
|
51
|
+
let latestBoundReset = null;
|
|
52
|
+
for (const window of Object.values(lastWindows)) {
|
|
53
|
+
if (!window || typeof window.utilization !== 'number' || typeof window.resetsAt !== 'number') continue;
|
|
54
|
+
if (window.utilization < 1.0) continue;
|
|
55
|
+
if (latestBoundReset === null || window.resetsAt > latestBoundReset) {
|
|
56
|
+
latestBoundReset = window.resetsAt;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return latestBoundReset;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { resolveBindingRateLimitReset };
|
|
@@ -52,6 +52,7 @@ const VERDICT_LABELS = {
|
|
|
52
52
|
pass_no_commit_already_shipped: 'PASS with no commit — deliverables already shipped',
|
|
53
53
|
pass_no_commit_prior_run_verified: 'PASS with no commit — prior run of this slug already landed the work',
|
|
54
54
|
silent_no_op: 'no commit, clean tree — no evidence of work',
|
|
55
|
+
blocked_by_foreign_wip_streak: 'blocked by a sibling job\'s foreign WIP 3x in a row',
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
function humanVerdict(verdict) {
|
|
@@ -69,14 +70,18 @@ const FAILURE_CLASSES = {
|
|
|
69
70
|
UNCOMMITTED: 'uncommitted-changes',
|
|
70
71
|
TRANSCRIPT_ERRORS: 'transcript-errors',
|
|
71
72
|
ABANDONED_BACKGROUND_TASK: 'abandoned-background-task',
|
|
73
|
+
BLOCKED_BY_FOREIGN_WIP: 'blocked-by-foreign-wip',
|
|
72
74
|
UNKNOWN: 'unknown',
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
// ─── Recovery actions — one per failure class, machine-readable ────────────
|
|
76
78
|
// Closed set the scheduler routes on: 'archive' (stale re-run, do not
|
|
77
|
-
// re-queue
|
|
78
|
-
//
|
|
79
|
-
// '
|
|
79
|
+
// re-queue — also the closest fit for BLOCKED_BY_FOREIGN_WIP: there is
|
|
80
|
+
// nothing to investigate, and re-queuing is already owned by
|
|
81
|
+
// reconcile()'s requeueForeignWipBlockedJobs, not this action), 'resume-and-commit'
|
|
82
|
+
// (PRD 1111's --resume dispatch owns this), 'verify-and-close' (the work
|
|
83
|
+
// likely landed, just missing its sentinel), 'investigate' (the only class
|
|
84
|
+
// that still buys a fix-plan investigation).
|
|
80
85
|
const RECOVERY_ACTIONS = {
|
|
81
86
|
[FAILURE_CLASSES.ALREADY_SHIPPED]: 'archive',
|
|
82
87
|
[FAILURE_CLASSES.SELF_QUEUE]: 'investigate',
|
|
@@ -96,6 +101,7 @@ const RECOVERY_ACTIONS = {
|
|
|
96
101
|
// carries the "check for salvaged work, commit before re-implementing"
|
|
97
102
|
// guidance into that cold-read investigation.
|
|
98
103
|
[FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]: 'investigate',
|
|
104
|
+
[FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]: 'archive',
|
|
99
105
|
[FAILURE_CLASSES.UNKNOWN]: 'investigate',
|
|
100
106
|
};
|
|
101
107
|
|
|
@@ -121,6 +127,8 @@ const PREVENTION_HINTS = {
|
|
|
121
127
|
'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.',
|
|
122
128
|
[FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]:
|
|
123
129
|
'A headless run cannot receive a background-task completion notification — never let a long Bash command auto-background past its foreground timeout and then wait for it. Run long commands with an explicit bound (`timeout <N> <cmd>`) so they finish in the foreground, or poll their output file directly instead of waiting on Monitor/notification.',
|
|
130
|
+
[FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]:
|
|
131
|
+
'This job correctly reported `SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP` against a sibling job\'s in-flight, uncommitted files three times in a row — auto-requeue is exhausted (reconcile() only clears this once the blocked paths go clean). This is not a defect in this PRD\'s own work: check on the sibling job/human that owns the still-dirty path(s) named on the job row, and either wait for them to commit or manually reset this job to `pending` once the tree is clear. Do not author a fix-plan PRD against it.',
|
|
124
132
|
[FAILURE_CLASSES.UNKNOWN]:
|
|
125
133
|
'Re-run the acceptance criteria gate locally against the failure log to pin down the specific break before re-queuing.',
|
|
126
134
|
};
|
|
@@ -159,6 +167,13 @@ const POST_AC_OVERRUN_MIN_TAIL_FRACTION = 0.3;
|
|
|
159
167
|
function classifyFailure({ verdict, logTail }) {
|
|
160
168
|
const lines = (logTail || '').split('\n');
|
|
161
169
|
|
|
170
|
+
// Checked before every other rule: an unambiguous, materially-checked
|
|
171
|
+
// verdict (the scheduler itself validated the executor's claimed paths
|
|
172
|
+
// against the job's disclosed foreign-WIP manifest before ever landing
|
|
173
|
+
// this verdict — see validateForeignWipBlockClaim in scheduler.cjs) needs
|
|
174
|
+
// no log-tail heuristics to classify.
|
|
175
|
+
if (verdict === 'blocked_by_foreign_wip_streak') return FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP;
|
|
176
|
+
|
|
162
177
|
// Checked first, before SELF_QUEUE/STUCK_LOOP: a correct executor that finds
|
|
163
178
|
// its acceptance criteria already satisfied by a prior commit makes no
|
|
164
179
|
// change and truthfully prints a PASS sentinel, so the run lands
|