claude-code-session-manager 0.85.0 → 0.86.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 (52) hide show
  1. package/dist/assets/{AgentLibrary-Bkv-HcP1.js → AgentLibrary-DTFL7y8G.js} +1 -1
  2. package/dist/assets/{DataModel-DRH-Ty20.js → DataModel-Q4jhl24R.js} +1 -1
  3. package/dist/assets/{History-CfRhT1Im.js → History-Cj2FejEo.js} +1 -1
  4. package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-CaelQI6t.js} +1 -1
  5. package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko--v7cMR8I.js} +1 -1
  6. package/dist/assets/{Library-CpArQ-OJ.js → Library-DgI9oCCZ.js} +1 -1
  7. package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DYUZN-x-.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-Xc141kjj.js → MarkdownEditor-DCIubYWf.js} +1 -1
  9. package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-ypCYURh3.js} +1 -1
  10. package/dist/assets/{Memory-ChMWkNd0.js → Memory-C2qYp-3M.js} +1 -1
  11. package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Cj2kw-Zv.js} +1 -1
  12. package/dist/assets/{Permissions-DKoNVgzj.js → Permissions-BiYZNGYW.js} +1 -1
  13. package/dist/assets/{Plugins-BtChISho.js → Plugins-C1Vj8_dU.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-DczPNM5U.js} +1 -1
  15. package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-Cd_7U6Gb.js} +1 -1
  16. package/dist/assets/{Scheduler-CbES7MC8.js → Scheduler-DcLBiJBq.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-DVSyI44-.js} +1 -1
  18. package/dist/assets/{Settings-BX3FElXk.js → Settings-Cv-pRyms.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CUv1_Q2c.js} +1 -1
  20. package/dist/assets/{Skills-DJB6-bBM.js → Skills-C_YHkAy-.js} +1 -1
  21. package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-B8R7T9xn.js} +1 -1
  22. package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-dj9YHWyy.js} +1 -1
  23. package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-DnSBUjHE.js} +1 -1
  24. package/dist/assets/{Toggle-B122N0HL.js → Toggle-CjV_BJn6.js} +1 -1
  25. package/dist/assets/{index-DhvuQL4C.js → index-CXFQIPhO.js} +3 -3
  26. package/dist/assets/{settingsSchema-sGoCTd7J.js → settingsSchema-BJVciriw.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +3 -2
  29. package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
  30. package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
  31. package/src/main/__tests__/machineProfile.test.cjs +19 -1
  32. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
  33. package/src/main/__tests__/queue-starvation-per-project.test.cjs +135 -0
  34. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
  35. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
  36. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
  37. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
  38. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +144 -0
  39. package/src/main/__tests__/telemetryClient.test.cjs +75 -5
  40. package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
  41. package/src/main/health.cjs +75 -2
  42. package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
  43. package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
  44. package/src/main/lib/loadGate.cjs +23 -1
  45. package/src/main/lib/machineProfile.cjs +15 -0
  46. package/src/main/lib/schedulerBatch.cjs +12 -1
  47. package/src/main/lib/schedulerConfig.cjs +13 -0
  48. package/src/main/lib/telemetryBoot.cjs +11 -8
  49. package/src/main/lib/telemetryClient.cjs +44 -2
  50. package/src/main/lib/telemetrySettings.cjs +20 -3
  51. package/src/main/pty.cjs +9 -0
  52. package/src/main/scheduler.cjs +600 -66
@@ -0,0 +1,121 @@
1
+ /**
2
+ * scheduler-failed-autoreset.test.cjs
3
+ *
4
+ * Covers selectFailedAutoResetTargets (the bounded failed -> pending
5
+ * auto-reset selector, PRD 1151) and failedAutoResetDisabled (its kill
6
+ * switch) in isolation. selectFailedAutoResetTargets is pure — no IO, no
7
+ * `require` inside the function — so unlike findStuckFailedJobs' own test
8
+ * file, there is no run-log fixture to set up here.
9
+ *
10
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — see
11
+ * scheduler-reap-dead-running-jobs.test.cjs's comment for why.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-failed-autoreset.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const assert = require('node:assert/strict');
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'failed-autoreset-test-'));
24
+ process.env.HOME = tmpHome;
25
+
26
+ const {
27
+ selectFailedAutoResetTargets,
28
+ FAILED_AUTORESET_CAP,
29
+ failedAutoResetDisabled,
30
+ } = require('../scheduler.cjs');
31
+
32
+ const MIN_MS = 60_000;
33
+ const THRESHOLD_MS = 10 * MIN_MS;
34
+
35
+ test('a fresh failed row (under threshold) is not selected', () => {
36
+ const now = Date.now();
37
+ const job = {
38
+ slug: 'fresh-failure',
39
+ status: 'failed',
40
+ statusHistory: [{ to: 'failed', at: new Date(now - 2 * MIN_MS).toISOString() }],
41
+ };
42
+ assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0);
43
+ });
44
+
45
+ test('a row past threshold is selected, with its current attempt count', () => {
46
+ const now = Date.now();
47
+ const job = {
48
+ slug: 'ripe-for-reset',
49
+ cwd: '/home/user/project',
50
+ status: 'failed',
51
+ failedAutoResetAttempts: 1,
52
+ statusHistory: [{ to: 'failed', at: new Date(now - 15 * MIN_MS).toISOString() }],
53
+ };
54
+ const found = selectFailedAutoResetTargets([job], now, THRESHOLD_MS);
55
+ assert.equal(found.length, 1);
56
+ assert.equal(found[0].slug, 'ripe-for-reset');
57
+ assert.equal(found[0].cwd, '/home/user/project');
58
+ assert.equal(found[0].attempts, 1);
59
+ assert.ok(found[0].ageMs >= 15 * MIN_MS - 1000);
60
+ });
61
+
62
+ test('a row at the cap (failedAutoResetAttempts: 3) is not selected', () => {
63
+ const now = Date.now();
64
+ const job = {
65
+ slug: 'cap-exhausted',
66
+ status: 'failed',
67
+ failedAutoResetAttempts: FAILED_AUTORESET_CAP,
68
+ statusHistory: [{ to: 'failed', at: new Date(now - 15 * MIN_MS).toISOString() }],
69
+ };
70
+ assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0);
71
+ });
72
+
73
+ test('a non-failed status is never selected', () => {
74
+ const now = Date.now();
75
+ const job = {
76
+ slug: 'needs-review-row',
77
+ status: 'needs_review',
78
+ statusHistory: [{ to: 'failed', at: new Date(now - 15 * MIN_MS).toISOString() }],
79
+ };
80
+ assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0);
81
+ });
82
+
83
+ test('the newest failed entry gates the threshold, not the first one', () => {
84
+ const now = Date.now();
85
+ const job = {
86
+ slug: 'refailed-recently',
87
+ status: 'failed',
88
+ failedAutoResetAttempts: 1,
89
+ statusHistory: [
90
+ { to: 'failed', at: new Date(now - 2 * 24 * 60 * MIN_MS).toISOString() },
91
+ { to: 'pending', at: new Date(now - 23 * 60 * MIN_MS).toISOString() },
92
+ { to: 'failed', at: new Date(now - 2 * MIN_MS).toISOString() },
93
+ ],
94
+ };
95
+ assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0, 'the old failed entry must not be used to gate this row');
96
+ });
97
+
98
+ test('a row whose newest failed entry came from spawnJob:fail-dirty is never selected — that source is a deliberate do-not-auto-requeue decision', () => {
99
+ const now = Date.now();
100
+ const job = {
101
+ slug: 'dirty-worktree',
102
+ status: 'failed',
103
+ statusHistory: [
104
+ { to: 'failed', at: new Date(now - 15 * MIN_MS).toISOString(), source: 'spawnJob:fail-dirty' },
105
+ ],
106
+ };
107
+ assert.equal(selectFailedAutoResetTargets([job], now, THRESHOLD_MS).length, 0);
108
+ });
109
+
110
+ test('failedAutoResetDisabled reflects SM_FAILED_AUTORESET_DISABLE', () => {
111
+ const saved = process.env.SM_FAILED_AUTORESET_DISABLE;
112
+ try {
113
+ delete process.env.SM_FAILED_AUTORESET_DISABLE;
114
+ assert.equal(failedAutoResetDisabled(), false);
115
+ process.env.SM_FAILED_AUTORESET_DISABLE = '1';
116
+ assert.equal(failedAutoResetDisabled(), true);
117
+ } finally {
118
+ if (saved === undefined) delete process.env.SM_FAILED_AUTORESET_DISABLE;
119
+ else process.env.SM_FAILED_AUTORESET_DISABLE = saved;
120
+ }
121
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * scheduler-needs-review-autoresolve.test.cjs
3
+ *
4
+ * Covers the bounded automatic terminal decision for an EXHAUSTED
5
+ * needs_review row (isExhaustedAutoFix === true): selectExhaustedNeedsReviewTargets
6
+ * (the pure selector), applyNeedsReviewAutoResolve (the three-branch policy
7
+ * applier, extracted from the 10-minute interval body so it's testable
8
+ * without queue.json IO), needsReviewAutoResolveDisabled (its kill switch),
9
+ * and the dependsOn carve-out in schedulerBatch.cjs's findBlockingDep that
10
+ * lets a chain actually drain once this pass auto-skips a row.
11
+ *
12
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
13
+ * reason as scheduler-failed-autoreset.test.cjs (appendAuditEvent writes
14
+ * under $HOME/.claude/session-manager/audit-log.jsonl).
15
+ *
16
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const assert = require('node:assert/strict');
22
+ const fs = require('node:fs');
23
+ const os = require('node:os');
24
+ const path = require('node:path');
25
+
26
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'needs-review-autoresolve-test-'));
27
+ process.env.HOME = tmpHome;
28
+
29
+ const {
30
+ selectExhaustedNeedsReviewTargets,
31
+ applyNeedsReviewAutoResolve,
32
+ NEEDS_REVIEW_RESOLVE_CAP,
33
+ needsReviewAutoResolveDisabled,
34
+ } = require('../scheduler.cjs');
35
+
36
+ const { pickForProject } = require('../lib/schedulerBatch.cjs');
37
+
38
+ const MIN_MS = 60_000;
39
+ const THRESHOLD_MS = 30 * MIN_MS;
40
+
41
+ function exhaustedJob(overrides = {}) {
42
+ return {
43
+ slug: 'exhausted-row',
44
+ cwd: '/home/user/project',
45
+ status: 'needs_review',
46
+ autoFixAttempted: true,
47
+ autoFixOutcome: 'no-plan',
48
+ autoFixRetries: 1, // spent, per isExhaustedAutoFix
49
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 45 * MIN_MS).toISOString() }],
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ // --- selectExhaustedNeedsReviewTargets ---
55
+
56
+ test('a fresh exhausted needs_review row (under threshold) is not selected', () => {
57
+ const job = exhaustedJob({
58
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 2 * MIN_MS).toISOString() }],
59
+ });
60
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
61
+ });
62
+
63
+ test('a non-exhausted needs_review row (still eligible for a real auto-fix investigation) is left alone', () => {
64
+ // autoFixRetries: 0 — isExhaustedAutoFix is false here (an unspent retry
65
+ // remains); this pass must never steal a row selectAutoFixTargets still owns.
66
+ const job = exhaustedJob({ autoFixRetries: 0 });
67
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
68
+ });
69
+
70
+ test('a non-needs_review status is never selected', () => {
71
+ const job = exhaustedJob({ status: 'failed' });
72
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
73
+ });
74
+
75
+ test('an exhausted row past threshold, under the cap, is selected', () => {
76
+ const job = exhaustedJob({ exhaustedResolveAttempts: 0 });
77
+ const found = selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS);
78
+ assert.equal(found.length, 1);
79
+ assert.equal(found[0].slug, 'exhausted-row');
80
+ assert.equal(found[0].attempts, 0);
81
+ });
82
+
83
+ test('an exhausted row AT the cap is still selected — this is the pass that must terminally skip it', () => {
84
+ const job = exhaustedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP });
85
+ const found = selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS);
86
+ assert.equal(found.length, 1);
87
+ });
88
+
89
+ test('a row already past the cap (defensive — should never occur once skipped) is excluded', () => {
90
+ const job = exhaustedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP + 1 });
91
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
92
+ });
93
+
94
+ test('needsReviewAutoResolveDisabled reflects SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE', () => {
95
+ const saved = process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE;
96
+ try {
97
+ delete process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE;
98
+ assert.equal(needsReviewAutoResolveDisabled(), false);
99
+ process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE = '1';
100
+ assert.equal(needsReviewAutoResolveDisabled(), true);
101
+ } finally {
102
+ if (saved === undefined) delete process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE;
103
+ else process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE = saved;
104
+ }
105
+ });
106
+
107
+ // --- applyNeedsReviewAutoResolve: the three policy branches ---
108
+
109
+ test('branch 1: a looksDone annotation resolves the row to completed', () => {
110
+ const job = exhaustedJob({
111
+ exhaustedResolveAttempts: 1,
112
+ looksDone: { commits: ['abc1234'], paths: ['src/main/scheduler.cjs'], detectedAt: new Date().toISOString() },
113
+ });
114
+ const outcome = applyNeedsReviewAutoResolve(job);
115
+ assert.equal(outcome, 'completed');
116
+ assert.equal(job.status, 'completed');
117
+ });
118
+
119
+ test('branch 2: no looksDone, under the cap, requeues to pending and increments the attempt counter', () => {
120
+ const job = exhaustedJob({ exhaustedResolveAttempts: 0 });
121
+ const outcome = applyNeedsReviewAutoResolve(job);
122
+ assert.equal(outcome, 'requeued');
123
+ assert.equal(job.status, 'pending');
124
+ assert.equal(job.exhaustedResolveAttempts, 1);
125
+ });
126
+
127
+ test('branch 2 again: a second requeue at attempts=1 also succeeds (cap is 2)', () => {
128
+ const job = exhaustedJob({ exhaustedResolveAttempts: 1 });
129
+ const outcome = applyNeedsReviewAutoResolve(job);
130
+ assert.equal(outcome, 'requeued');
131
+ assert.equal(job.status, 'pending');
132
+ assert.equal(job.exhaustedResolveAttempts, 2);
133
+ });
134
+
135
+ test('branch 3: once the cap is spent, the row is auto-skipped with the reason recorded in job.error', () => {
136
+ const job = exhaustedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP });
137
+ const outcome = applyNeedsReviewAutoResolve(job);
138
+ assert.equal(outcome, 'skipped');
139
+ assert.equal(job.status, 'skipped');
140
+ assert.ok(typeof job.error === 'string' && job.error.length > 0, 'job.error must name the exhausted auto-fix path');
141
+ assert.match(job.error, /exhausted auto-fix/i);
142
+ assert.equal(job.needsReviewAutoResolvedSkip, true);
143
+ });
144
+
145
+ test('applyNeedsReviewAutoResolve race-guards against a row that moved off needs_review', () => {
146
+ const job = exhaustedJob({ status: 'completed', exhaustedResolveAttempts: 0 });
147
+ assert.equal(applyNeedsReviewAutoResolve(job), null);
148
+ assert.equal(job.status, 'completed'); // untouched
149
+ });
150
+
151
+ // --- chain drain: the actual point of this PRD ---
152
+
153
+ test('a 3-row dependsOn chain drains: the exhausted middle row auto-skips and the third row becomes eligible', () => {
154
+ const now = Date.now();
155
+ const rowA = {
156
+ slug: 'a-first', cwd: '/home/user/project', status: 'completed',
157
+ };
158
+ const rowB = exhaustedJob({
159
+ slug: 'b-middle',
160
+ cwd: '/home/user/project',
161
+ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP,
162
+ dependsOn: ['a-first'],
163
+ });
164
+ const rowC = {
165
+ slug: 'c-last', cwd: '/home/user/project', status: 'pending', dependsOn: ['b-middle'],
166
+ };
167
+ const jobs = [rowA, rowB, rowC];
168
+
169
+ // Before resolution: C is blocked behind B's live needs_review row.
170
+ const before = pickForProject(jobs, new Set(), 5);
171
+ assert.equal(before.batch.length, 0, 'C must not be eligible while B still sits in needs_review');
172
+
173
+ const targets = selectExhaustedNeedsReviewTargets(jobs, now, THRESHOLD_MS);
174
+ assert.equal(targets.length, 1);
175
+ assert.equal(targets[0].slug, 'b-middle');
176
+ const outcome = applyNeedsReviewAutoResolve(rowB);
177
+ assert.equal(outcome, 'skipped');
178
+ assert.equal(rowB.status, 'skipped');
179
+
180
+ const after = pickForProject(jobs, new Set(), 5);
181
+ assert.ok(after.batch.some((j) => j.slug === 'c-last'), 'C must become eligible once B is auto-skipped');
182
+ });
183
+
184
+ test('a generic (non-auto-resolved) skipped dep still blocks forever — the carve-out is narrow', () => {
185
+ const rowB = { slug: 'b-middle', cwd: '/home/user/project', status: 'skipped' }; // no needsReviewAutoResolvedSkip marker
186
+ const rowC = { slug: 'c-last', cwd: '/home/user/project', status: 'pending', dependsOn: ['b-middle'] };
187
+ const { batch } = pickForProject([rowB, rowC], new Set(), 5);
188
+ assert.equal(batch.length, 0, 'a PRD-source-vanished skip must remain a permanent block, unaffected by this carve-out');
189
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * scheduler-no-dead-end-status.test.cjs
3
+ *
4
+ * Structural guard for the no-human-intervention guarantee (this PRD): every
5
+ * value in `JOB_STATUSES` (imported from scheduleJobSchema.cjs — the real
6
+ * array, never a hand-written copy) must either be genuinely final
7
+ * (completed/skipped) or have at least one AUTOMATED selector that can move
8
+ * a row parked in it, with no human action required.
9
+ *
10
+ * Each non-final status is asserted by CONSTRUCTING a representative parked
11
+ * row (a stale timestamp + a counter positioned so the row is still
12
+ * selectable) and showing the named selector actually returns it — not by
13
+ * asserting a hand-maintained status -> selector-name string map, which
14
+ * would pass even if the real wiring silently broke. If a future change adds
15
+ * a new status to JOB_STATUSES with no automated exit wired up here, this
16
+ * test fails loudly instead of the row becoming a new, silent dead end.
17
+ *
18
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
19
+ * reason as scheduler-failed-autoreset.test.cjs (appendAuditEvent writes
20
+ * under $HOME/.claude/session-manager/audit-log.jsonl).
21
+ *
22
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-no-dead-end-status.test.cjs
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const assert = require('node:assert/strict');
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+
32
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'no-dead-end-status-test-'));
33
+ process.env.HOME = tmpHome;
34
+
35
+ const { JOB_STATUSES } = require('../lib/scheduleJobSchema.cjs');
36
+ const {
37
+ selectFailedAutoResetTargets,
38
+ FAILED_AUTORESET_CAP,
39
+ selectExhaustedNeedsReviewTargets,
40
+ NEEDS_REVIEW_RESOLVE_CAP,
41
+ selectQuarantineAutoResolveTargets,
42
+ QUARANTINE_RESOLVE_CAP,
43
+ findStrandedInvestigations,
44
+ INVESTIGATION_MAX_MS,
45
+ } = require('../scheduler.cjs');
46
+ const { selectReapableJobs } = require('../lib/reaperHelpers.cjs');
47
+ const { pickForProject } = require('../lib/schedulerBatch.cjs');
48
+
49
+ const MIN_MS = 60_000;
50
+
51
+ // Every terminal status (no automated exit needed — nothing should ever
52
+ // move a row out of these) and every non-terminal status's proof that a
53
+ // row parked there is reachable by an automated selector, keyed only for
54
+ // this test's own assertions (never treated as authoritative wiring below).
55
+ const FINAL_STATUSES = new Set(['completed', 'skipped']);
56
+
57
+ test('JOB_STATUSES contains exactly the statuses this test accounts for (fails loudly on drift)', () => {
58
+ // Not a hand-maintained selector map — just a manifest of what this file
59
+ // below actually exercises, so an entirely new, unhandled status trips
60
+ // this assertion instead of silently passing the loop below with zero
61
+ // coverage.
62
+ const accountedFor = new Set([
63
+ 'pending', 'running', 'investigating', 'completed', 'skipped', 'failed', 'needs_review', 'quarantined',
64
+ ]);
65
+ const unaccounted = JOB_STATUSES.filter((s) => !accountedFor.has(s));
66
+ if (unaccounted.length > 0) {
67
+ throw new Error(
68
+ `HALT: JOB_STATUSES has status(es) with no automated-exit test coverage: ${unaccounted.join(', ')} — `
69
+ + 'add a case to scheduler-no-dead-end-status.test.cjs before shipping this status.',
70
+ );
71
+ }
72
+ });
73
+
74
+ test('every terminal status in JOB_STATUSES is genuinely final (completed/skipped only)', () => {
75
+ for (const status of JOB_STATUSES) {
76
+ if (status === 'completed' || status === 'skipped') continue;
77
+ assert.ok(!FINAL_STATUSES.has(status), `${status} must not be treated as final`);
78
+ }
79
+ });
80
+
81
+ test('pending: a fresh pending row with no blockers is picked for dispatch by pickForProject', () => {
82
+ assert.ok(JOB_STATUSES.includes('pending'));
83
+ const job = { slug: 'pending-row', cwd: '/home/user/project', status: 'pending' };
84
+ const { batch } = pickForProject([job], new Set(), 5);
85
+ assert.ok(batch.some((j) => j.slug === 'pending-row'), 'pending row must be dispatch-eligible with no blockers');
86
+ });
87
+
88
+ test('running: a running row whose pid is provably dead is reaped by selectReapableJobs', () => {
89
+ assert.ok(JOB_STATUSES.includes('running'));
90
+ const job = {
91
+ slug: 'dead-running-row',
92
+ cwd: '/home/user/project',
93
+ status: 'running',
94
+ runtime: { pid: 12345 },
95
+ };
96
+ const { reapable } = selectReapableJobs([job], Date.now(), { pidAlive: () => false, grace: 5 * MIN_MS });
97
+ assert.ok(reapable.some((r) => r.slug === 'dead-running-row'), 'a running row with a dead pid must be reapable');
98
+ });
99
+
100
+ test('investigating: a stranded investigation (stale, no live pid) is found by findStrandedInvestigations', () => {
101
+ assert.ok(JOB_STATUSES.includes('investigating'));
102
+ const job = {
103
+ slug: 'stranded-investigation',
104
+ cwd: '/home/user/project',
105
+ status: 'investigating',
106
+ statusHistory: [{ to: 'investigating', from: 'failed', at: new Date(Date.now() - (INVESTIGATION_MAX_MS + 5 * MIN_MS)).toISOString() }],
107
+ };
108
+ const found = findStrandedInvestigations([job], Date.now(), INVESTIGATION_MAX_MS, () => false);
109
+ assert.ok(found.some((f) => f.slug === 'stranded-investigation'), 'a stale investigating row with no live pid must be found');
110
+ });
111
+
112
+ test('failed: a stale failed row under its auto-reset cap is selected by selectFailedAutoResetTargets', () => {
113
+ assert.ok(JOB_STATUSES.includes('failed'));
114
+ const job = {
115
+ slug: 'stale-failed-row',
116
+ cwd: '/home/user/project',
117
+ status: 'failed',
118
+ failedAutoResetAttempts: FAILED_AUTORESET_CAP - 1,
119
+ statusHistory: [{ to: 'failed', at: new Date(Date.now() - 60 * MIN_MS).toISOString() }],
120
+ };
121
+ const found = selectFailedAutoResetTargets([job], Date.now(), 10 * MIN_MS);
122
+ assert.ok(found.some((f) => f.slug === 'stale-failed-row'), 'a stale failed row under the cap must be auto-reset-selectable');
123
+ });
124
+
125
+ test('needs_review: an exhausted, stale needs_review row is selected by selectExhaustedNeedsReviewTargets', () => {
126
+ assert.ok(JOB_STATUSES.includes('needs_review'));
127
+ const job = {
128
+ slug: 'exhausted-needs-review-row',
129
+ cwd: '/home/user/project',
130
+ status: 'needs_review',
131
+ autoFixAttempted: true,
132
+ autoFixOutcome: 'no-plan',
133
+ autoFixRetries: 1,
134
+ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP,
135
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 60 * MIN_MS).toISOString() }],
136
+ };
137
+ const found = selectExhaustedNeedsReviewTargets([job], Date.now(), 30 * MIN_MS);
138
+ assert.ok(found.some((f) => f.slug === 'exhausted-needs-review-row'), 'an exhausted, stale needs_review row must be auto-resolve-selectable');
139
+ });
140
+
141
+ test('quarantined: a stale quarantined row under its resolve cap is selected by selectQuarantineAutoResolveTargets', () => {
142
+ assert.ok(JOB_STATUSES.includes('quarantined'));
143
+ const job = {
144
+ slug: 'stale-quarantined-row',
145
+ cwd: '/home/user/project',
146
+ status: 'quarantined',
147
+ quarantineResolveAttempts: QUARANTINE_RESOLVE_CAP - 1,
148
+ statusHistory: [{ to: 'quarantined', at: new Date(Date.now() - 60 * MIN_MS).toISOString() }],
149
+ };
150
+ const found = selectQuarantineAutoResolveTargets([job], Date.now(), 30 * MIN_MS);
151
+ assert.ok(found.some((f) => f.slug === 'stale-quarantined-row'), 'a stale quarantined row under the cap must be auto-resolve-selectable');
152
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * scheduler-quarantine-autoresolve.test.cjs
3
+ *
4
+ * Covers the bounded automatic exit for a quarantined row that never
5
+ * received a `createdVia` provenance stamp (this PRD): the pure selector
6
+ * `selectQuarantineAutoResolveTargets`, the async applier
7
+ * `autoResolveQuarantine` (which re-reads the PRD file's frontmatter fresh
8
+ * from disk immediately before transitioning, so it can never race
9
+ * reconcile()'s own adopt path), and the kill switch
10
+ * `quarantineAutoResolveDisabled`.
11
+ *
12
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
13
+ * reason as scheduler-failed-autoreset.test.cjs (appendAuditEvent writes
14
+ * under $HOME/.claude/session-manager/audit-log.jsonl).
15
+ *
16
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const assert = require('node:assert/strict');
22
+ const fs = require('node:fs');
23
+ const os = require('node:os');
24
+ const path = require('node:path');
25
+
26
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'quarantine-autoresolve-test-'));
27
+ process.env.HOME = tmpHome;
28
+
29
+ const {
30
+ selectQuarantineAutoResolveTargets,
31
+ autoResolveQuarantine,
32
+ QUARANTINE_RESOLVE_CAP,
33
+ quarantineAutoResolveDisabled,
34
+ } = require('../scheduler.cjs');
35
+
36
+ const MIN_MS = 60_000;
37
+ const THRESHOLD_MS = 60 * MIN_MS;
38
+
39
+ function quarantinedJob(overrides = {}) {
40
+ return {
41
+ slug: 'quarantined-row',
42
+ cwd: '/home/user/project',
43
+ status: 'quarantined',
44
+ statusHistory: [{ to: 'quarantined', at: new Date(Date.now() - 90 * MIN_MS).toISOString() }],
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ function registerActiveProject(cwd) {
50
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
51
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
52
+ fs.mkdirSync(slugDir, { recursive: true });
53
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
54
+ }
55
+
56
+ function makeFixtureProject(prefix) {
57
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
58
+ registerActiveProject(cwd);
59
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'test-epic-1', 'prds');
60
+ fs.mkdirSync(prdsDir, { recursive: true });
61
+ return { cwd, prdsDir };
62
+ }
63
+
64
+ // --- selectQuarantineAutoResolveTargets ---
65
+
66
+ test('a fresh quarantined row (under threshold) is not selected', () => {
67
+ const job = quarantinedJob({
68
+ statusHistory: [{ to: 'quarantined', at: new Date(Date.now() - 2 * MIN_MS).toISOString() }],
69
+ });
70
+ assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
71
+ });
72
+
73
+ test('a non-quarantined status is never selected', () => {
74
+ const job = quarantinedJob({ status: 'failed' });
75
+ assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
76
+ });
77
+
78
+ test('a quarantined row with no statusHistory entry cannot have its age proven, and is skipped', () => {
79
+ const job = quarantinedJob({ statusHistory: [] });
80
+ assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
81
+ });
82
+
83
+ test('a quarantined row past threshold, under the cap, is selected', () => {
84
+ const job = quarantinedJob({ quarantineResolveAttempts: 0 });
85
+ const found = selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS);
86
+ assert.equal(found.length, 1);
87
+ assert.equal(found[0].slug, 'quarantined-row');
88
+ assert.ok(found[0].ageMs >= 90 * MIN_MS - 1000);
89
+ });
90
+
91
+ test('a quarantined row that already spent its resolve cap is excluded', () => {
92
+ const job = quarantinedJob({ quarantineResolveAttempts: QUARANTINE_RESOLVE_CAP });
93
+ assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
94
+ });
95
+
96
+ test('quarantineAutoResolveDisabled reflects SM_QUARANTINE_AUTORESOLVE_DISABLE', () => {
97
+ const saved = process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
98
+ try {
99
+ delete process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
100
+ assert.equal(quarantineAutoResolveDisabled(), false);
101
+ process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE = '1';
102
+ assert.equal(quarantineAutoResolveDisabled(), true);
103
+ } finally {
104
+ if (saved === undefined) delete process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
105
+ else process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE = saved;
106
+ }
107
+ });
108
+
109
+ // --- autoResolveQuarantine: the applier ---
110
+
111
+ test('a quarantined row whose PRD file still has no createdVia is auto-resolved to skipped', async () => {
112
+ const { cwd, prdsDir } = makeFixtureProject('sm-quarantine-autoresolve-noprovenance-');
113
+ try {
114
+ fs.writeFileSync(
115
+ path.join(prdsDir, 'quarantined-row.md'),
116
+ `---\ntitle: Unstamped PRD\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nDo the thing.\n`,
117
+ 'utf8',
118
+ );
119
+ const job = quarantinedJob({ cwd, quarantineResolveAttempts: 0 });
120
+ const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
121
+ assert.equal(outcome, 'skipped');
122
+ assert.equal(job.status, 'skipped');
123
+ assert.equal(job.quarantineResolveAttempts, 1);
124
+ assert.ok(typeof job.error === 'string' && /createdVia/.test(job.error));
125
+ } finally {
126
+ fs.rmSync(cwd, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ test('a quarantined row whose PRD file HAS been stamped with createdVia is left alone — reconcile owns it now', async () => {
131
+ const { cwd, prdsDir } = makeFixtureProject('sm-quarantine-autoresolve-stamped-');
132
+ try {
133
+ fs.writeFileSync(
134
+ path.join(prdsDir, 'quarantined-row.md'),
135
+ `---\ntitle: Stamped PRD\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
136
+ 'utf8',
137
+ );
138
+ const job = quarantinedJob({ cwd, quarantineResolveAttempts: 0 });
139
+ const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
140
+ assert.equal(outcome, null);
141
+ assert.equal(job.status, 'quarantined'); // untouched — the adopt path owns the next pass
142
+ assert.equal(job.quarantineResolveAttempts, 0);
143
+ } finally {
144
+ fs.rmSync(cwd, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test('autoResolveQuarantine race-guards against a row that moved off quarantined', async () => {
149
+ const job = quarantinedJob({ status: 'pending', quarantineResolveAttempts: 0 });
150
+ assert.equal(await autoResolveQuarantine(job, 90 * MIN_MS), null);
151
+ assert.equal(job.status, 'pending'); // untouched
152
+ });
153
+
154
+ test('autoResolveQuarantine race-guards against a row whose resolve cap is already spent', async () => {
155
+ const job = quarantinedJob({ quarantineResolveAttempts: QUARANTINE_RESOLVE_CAP });
156
+ assert.equal(await autoResolveQuarantine(job, 90 * MIN_MS), null);
157
+ assert.equal(job.status, 'quarantined'); // untouched
158
+ });
159
+
160
+ test('a quarantined row whose PRD file is missing entirely is treated as still lacking provenance and auto-resolved', async () => {
161
+ const job = quarantinedJob({ cwd: '/nonexistent/project/path', quarantineResolveAttempts: 0 });
162
+ const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
163
+ assert.equal(outcome, 'skipped');
164
+ assert.equal(job.status, 'skipped');
165
+ });