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.
Files changed (59) hide show
  1. package/dist/assets/{AgentLibrary-zS3jw_1e.js → AgentLibrary-pwlkAFb3.js} +1 -1
  2. package/dist/assets/{DataModel-Cy_vxTpi.js → DataModel-BZK9PXFD.js} +1 -1
  3. package/dist/assets/{History-C6JRuqfT.js → History-BVRjxJjS.js} +1 -1
  4. package/dist/assets/{Hooks-BafPy9mB.js → Hooks-CNuwHeGx.js} +1 -1
  5. package/dist/assets/{HostBilko-BZwhQOFt.js → HostBilko-cjwNodhV.js} +1 -1
  6. package/dist/assets/{Library-C8JDDliz.js → Library-YPNm9W92.js} +1 -1
  7. package/dist/assets/{ListDetail-CqiOdwLc.js → ListDetail-CY4GM1Om.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-CyLyP67L.js → MarkdownEditor-BF4y2Jiz.js} +1 -1
  9. package/dist/assets/{McpServers-BzMv-_84.js → McpServers-CmBdWtX_.js} +1 -1
  10. package/dist/assets/{Memory-DSBYQdJR.js → Memory-CvkIXNl1.js} +1 -1
  11. package/dist/assets/{Panel-CLUhkNNA.js → Panel-D93o-sxe.js} +1 -1
  12. package/dist/assets/{Permissions-BfC2-HN4.js → Permissions-DQipg16I.js} +1 -1
  13. package/dist/assets/{Plugins-BKi40jT5.js → Plugins-B6NwzPfK.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BzFw4KhD.js → ProvenanceBadge-DACVJhrB.js} +1 -1
  15. package/dist/assets/{SaveBar-avk2p9jv.js → SaveBar-Cg4lbChb.js} +1 -1
  16. package/dist/assets/{Scheduler-Bf_6MdJo.js → Scheduler-Dr5ZcBLe.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-C-RwYUVZ.js → ScopeSwitcher-C-locvy0.js} +1 -1
  18. package/dist/assets/{Settings-Djd8OoBA.js → Settings-BVrAle90.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DuogY6s7.js → SkillReferenceGraph-DDzuYgSK.js} +1 -1
  20. package/dist/assets/{Skills-D_qAqxZ_.js → Skills-DFvhiOAQ.js} +1 -1
  21. package/dist/assets/{SystemPrompt-DbHFLQV3.js → SystemPrompt-8PiTyUyL.js} +1 -1
  22. package/dist/assets/{TagLibrary-C2y91BT0.js → TagLibrary-DruUYaAc.js} +1 -1
  23. package/dist/assets/{TiptapBody-D9iz4xQx.js → TiptapBody-Dr4a--42.js} +1 -1
  24. package/dist/assets/{Toggle-BGnFL2E5.js → Toggle-B_EH2TFb.js} +1 -1
  25. package/dist/assets/{index-_2ARyFDj.js → index-DApB4DHS.js} +4 -4
  26. package/dist/assets/{settingsSchema-JK15eJU8.js → settingsSchema-BKa-xk8g.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  30. package/scripts/project-pages-logic/dist/logic.cjs +12 -12
  31. package/scripts/render-project-pages/dist/renderer.cjs +22 -22
  32. package/src/main/__tests__/machineProfile.test.cjs +134 -0
  33. package/src/main/__tests__/rcaReport.test.cjs +24 -0
  34. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
  35. package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
  36. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
  37. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
  38. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
  39. package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
  40. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +85 -0
  41. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
  42. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
  43. package/src/main/__tests__/telemetrySettings.test.cjs +178 -0
  44. package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
  45. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
  46. package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
  47. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
  48. package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
  49. package/src/main/lib/branchSweep.cjs +127 -0
  50. package/src/main/lib/gitWorktree.cjs +20 -0
  51. package/src/main/lib/landedSinceRun.cjs +41 -1
  52. package/src/main/lib/machineProfile.cjs +144 -0
  53. package/src/main/lib/rateLimitWindow.cjs +62 -0
  54. package/src/main/lib/rcaReport.cjs +18 -3
  55. package/src/main/lib/reaperHelpers.cjs +169 -3
  56. package/src/main/lib/scheduleJobTransitions.cjs +16 -3
  57. package/src/main/lib/telemetrySettings.cjs +171 -0
  58. package/src/main/runVerify.cjs +71 -3
  59. 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 { selectReapableJobs, mapOutcomeToGateOutcome, classifyRunOutcome } = require('../reaperHelpers.cjs');
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
- module.exports = { landedSinceRun, LANDED_SINCE_RUN_TIMEOUT_MS };
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), 'resume-and-commit' (PRD 1111's --resume dispatch owns this),
78
- // 'verify-and-close' (the work likely landed, just missing its sentinel),
79
- // 'investigate' (the only class that still buys a fix-plan investigation).
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