claude-code-session-manager 0.84.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 (61) hide show
  1. package/dist/assets/{AgentLibrary-CCVIpoSz.js → AgentLibrary-DTFL7y8G.js} +1 -1
  2. package/dist/assets/{DataModel-BltREYde.js → DataModel-Q4jhl24R.js} +1 -1
  3. package/dist/assets/{History-BZxFkOp6.js → History-Cj2FejEo.js} +1 -1
  4. package/dist/assets/{Hooks-Bznlfaaa.js → Hooks-CaelQI6t.js} +1 -1
  5. package/dist/assets/{HostBilko-DUG5YHA_.js → HostBilko--v7cMR8I.js} +1 -1
  6. package/dist/assets/{Library-Bi2Fn3w9.js → Library-DgI9oCCZ.js} +1 -1
  7. package/dist/assets/{ListDetail-4VBvXKrz.js → ListDetail-DYUZN-x-.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-D2ft_v5j.js → MarkdownEditor-DCIubYWf.js} +1 -1
  9. package/dist/assets/{McpServers-FDekKkyE.js → McpServers-ypCYURh3.js} +1 -1
  10. package/dist/assets/{Memory-Dkl80uj_.js → Memory-C2qYp-3M.js} +1 -1
  11. package/dist/assets/{Panel-LurG5VfD.js → Panel-Cj2kw-Zv.js} +1 -1
  12. package/dist/assets/{Permissions-D2wHBCFA.js → Permissions-BiYZNGYW.js} +1 -1
  13. package/dist/assets/{Plugins-CTw_wwbI.js → Plugins-C1Vj8_dU.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CW6HNUv6.js → ProvenanceBadge-DczPNM5U.js} +1 -1
  15. package/dist/assets/{SaveBar-alDHG6cP.js → SaveBar-Cd_7U6Gb.js} +1 -1
  16. package/dist/assets/{Scheduler-DxiPcaiW.js → Scheduler-DcLBiJBq.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-DzFXUKLZ.js → ScopeSwitcher-DVSyI44-.js} +1 -1
  18. package/dist/assets/{Settings-B6H3v2am.js → Settings-Cv-pRyms.js} +3 -3
  19. package/dist/assets/{SkillReferenceGraph-B8EZalpN.js → SkillReferenceGraph-CUv1_Q2c.js} +1 -1
  20. package/dist/assets/{Skills-DwuHuA08.js → Skills-C_YHkAy-.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CMMqpGYn.js → SystemPrompt-B8R7T9xn.js} +1 -1
  22. package/dist/assets/{TagLibrary-C2N5C1n9.js → TagLibrary-dj9YHWyy.js} +1 -1
  23. package/dist/assets/{TiptapBody-btlID-dQ.js → TiptapBody-DnSBUjHE.js} +1 -1
  24. package/dist/assets/{Toggle-BGOn3DZj.js → Toggle-CjV_BJn6.js} +1 -1
  25. package/dist/assets/{index-mnjNDpb1.css → index-CDo9xBR9.css} +1 -1
  26. package/dist/assets/{index-QLRf0epp.js → index-CXFQIPhO.js} +3 -3
  27. package/dist/assets/{settingsSchema-DA3N2Up3.js → settingsSchema-BJVciriw.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +3 -2
  30. package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
  31. package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
  32. package/src/main/__tests__/machineProfile.test.cjs +19 -1
  33. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +6 -0
  34. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
  35. package/src/main/__tests__/queue-starvation-per-project.test.cjs +135 -0
  36. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
  37. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
  38. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
  39. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
  40. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +144 -0
  41. package/src/main/__tests__/telemetryClient.test.cjs +250 -5
  42. package/src/main/__tests__/telemetryContract.test.cjs +74 -27
  43. package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
  44. package/src/main/health.cjs +75 -2
  45. package/src/main/index.cjs +2 -2
  46. package/src/main/lib/__tests__/crashTelemetry.test.cjs +6 -0
  47. package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
  48. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +6 -0
  49. package/src/main/lib/__tests__/telemetryBoot.test.cjs +22 -13
  50. package/src/main/lib/__tests__/telemetryConsent.test.cjs +6 -0
  51. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +6 -0
  52. package/src/main/lib/loadGate.cjs +23 -1
  53. package/src/main/lib/machineProfile.cjs +15 -0
  54. package/src/main/lib/queueStore.cjs +8 -2
  55. package/src/main/lib/schedulerBatch.cjs +12 -1
  56. package/src/main/lib/schedulerConfig.cjs +13 -0
  57. package/src/main/lib/telemetryBoot.cjs +18 -10
  58. package/src/main/lib/telemetryClient.cjs +162 -6
  59. package/src/main/lib/telemetrySettings.cjs +20 -3
  60. package/src/main/pty.cjs +9 -0
  61. package/src/main/scheduler.cjs +600 -66
@@ -0,0 +1,135 @@
1
+ /**
2
+ * queue-starvation-per-project.test.cjs — classifyQueueStarvation's
3
+ * runningCount used to be fed the MACHINE-WIDE runningSet.size, so one
4
+ * long-lived job in ANY project disarmed the starvation watchdog for EVERY
5
+ * other project on the box. Observed live 2026-09-12: a job in
6
+ * starry-night-ships ran 80+ minutes while two other projects sat
7
+ * starved/blocked for hours — the watchdog never fired once because "work is
8
+ * flowing" was true somewhere else.
9
+ *
10
+ * classifyQueueStarvationByProject partitions jobs by cwd (the same grouping
11
+ * computeBlockedChains already uses) and evaluates each project on its OWN
12
+ * running/pending/idle evidence.
13
+ *
14
+ * Run: timeout 180 npx vitest run src/main/__tests__/queue-starvation-per-project.test.cjs
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ import { test } from 'vitest';
20
+ const assert = require('node:assert/strict');
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+
25
+ // HOME before require: every state path is baked from os.homedir() at load.
26
+ process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'starve-per-project-test-'));
27
+
28
+ const {
29
+ classifyQueueStarvation,
30
+ classifyQueueStarvationByProject,
31
+ QUEUE_STARVATION_MS,
32
+ } = require('../scheduler.cjs');
33
+
34
+ const PROJECT_A = '/home/bilko/Projects/starry-night-ships';
35
+ const PROJECT_B = '/home/bilko/Projects/session-manager';
36
+ const NOW = Date.parse('2026-09-12T20:00:00.000Z');
37
+ const LONG = QUEUE_STARVATION_MS + 60_000;
38
+ const idleFor = (ms) => NOW - ms;
39
+
40
+ test('one long-running job in project A no longer masks project B being starved', () => {
41
+ const jobs = [
42
+ { slug: '246-neptune', cwd: PROJECT_A, status: 'running', dependsOn: [] },
43
+ { slug: '1169-x', cwd: PROJECT_B, status: 'pending', dependsOn: [] },
44
+ { slug: '1170-y', cwd: PROJECT_B, status: 'pending', dependsOn: [] },
45
+ ];
46
+
47
+ // Pre-change shape: the machine-wide runningCount (1, from project A) hides
48
+ // project B's starvation entirely. This is the bug this PRD fixes — assert
49
+ // it still holds for the old, single-project entry point so a revert of
50
+ // the new driver is caught by this test going red.
51
+ const globalVerdict = classifyQueueStarvation({
52
+ jobs,
53
+ paused: false,
54
+ runningCount: 1,
55
+ lastRunAtMs: idleFor(LONG),
56
+ now: NOW,
57
+ });
58
+ assert.equal(globalVerdict, null);
59
+
60
+ // Per-project: project B has 0 of its OWN rows running and 2 dispatchable
61
+ // pending rows idle past the threshold — it must be reported as starved
62
+ // regardless of project A's running job.
63
+ const verdicts = classifyQueueStarvationByProject({
64
+ jobs,
65
+ paused: false,
66
+ runningSet: new Set(),
67
+ lastRunAtMs: idleFor(LONG),
68
+ now: NOW,
69
+ });
70
+ const forB = verdicts.find((v) => v.cwd === PROJECT_B);
71
+ assert.ok(forB, 'expected a verdict naming project B cwd');
72
+ assert.equal(forB.kind, 'starved');
73
+ assert.equal(forB.pending, 2);
74
+ assert.equal(forB.dispatchable, 2);
75
+
76
+ // Project A itself has a running row — never starved on its own account.
77
+ const forA = verdicts.find((v) => v.cwd === PROJECT_A);
78
+ assert.equal(forA, undefined);
79
+ });
80
+
81
+ test('a project fully blocked behind a terminal dependency is "blocked", not "starved", even while another project is starved in the same pass', () => {
82
+ const jobs = [
83
+ // Project A: every pending row sits behind a failed dependency — a tick
84
+ // cannot help this project, it needs a human/heal pass.
85
+ { slug: '206-lava-ape', cwd: PROJECT_A, status: 'failed', dependsOn: [] },
86
+ { slug: '207-body', cwd: PROJECT_A, status: 'pending', dependsOn: ['206-lava-ape'] },
87
+ // Project B: dispatchable right now — genuinely starved.
88
+ { slug: '1171-z', cwd: PROJECT_B, status: 'pending', dependsOn: [] },
89
+ ];
90
+
91
+ const verdicts = classifyQueueStarvationByProject({
92
+ jobs,
93
+ paused: false,
94
+ runningSet: new Set(),
95
+ lastRunAtMs: idleFor(LONG),
96
+ now: NOW,
97
+ });
98
+
99
+ const forA = verdicts.find((v) => v.cwd === PROJECT_A);
100
+ assert.ok(forA, 'expected a verdict for the blocked project');
101
+ assert.equal(forA.kind, 'blocked');
102
+
103
+ const forB = verdicts.find((v) => v.cwd === PROJECT_B);
104
+ assert.ok(forB, 'expected a verdict for the starved project');
105
+ assert.equal(forB.kind, 'starved');
106
+ });
107
+
108
+ test('the whole-machine paused short-circuit is preserved for every project', () => {
109
+ const jobs = [
110
+ { slug: '1169-x', cwd: PROJECT_A, status: 'pending', dependsOn: [] },
111
+ { slug: '1170-y', cwd: PROJECT_B, status: 'pending', dependsOn: [] },
112
+ ];
113
+ const verdicts = classifyQueueStarvationByProject({
114
+ jobs,
115
+ paused: { reason: 'rate_limit', resumeAt: null },
116
+ runningSet: new Set(),
117
+ lastRunAtMs: idleFor(LONG),
118
+ now: NOW,
119
+ });
120
+ assert.deepEqual(verdicts, []);
121
+ });
122
+
123
+ test('a running-set membership (not just a status:"running" row) counts as that project having flowing work', () => {
124
+ const jobs = [
125
+ { slug: '9001-a', cwd: PROJECT_A, status: 'pending', dependsOn: [] },
126
+ ];
127
+ const verdicts = classifyQueueStarvationByProject({
128
+ jobs,
129
+ paused: false,
130
+ runningSet: new Set(['9001-a']),
131
+ lastRunAtMs: idleFor(LONG),
132
+ now: NOW,
133
+ });
134
+ assert.deepEqual(verdicts, []);
135
+ });
@@ -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
+ });