claude-code-session-manager 0.35.16 → 0.35.18

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 (37) hide show
  1. package/dist/assets/{TiptapBody-BD2BTBY-.js → TiptapBody-DgFO_m3g.js} +1 -1
  2. package/dist/assets/index-4dJkn9nT.js +3559 -0
  3. package/dist/assets/index-DX2w2YhJ.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/main/__tests__/adminServer.test.cjs +205 -0
  7. package/src/main/__tests__/chat-queue.test.cjs +27 -0
  8. package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
  9. package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
  10. package/src/main/__tests__/prdCreate.test.cjs +82 -0
  11. package/src/main/__tests__/pty-write-result.test.cjs +46 -0
  12. package/src/main/__tests__/runVerify.test.cjs +146 -0
  13. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
  14. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
  15. package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
  16. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +181 -0
  17. package/src/main/adminServer.cjs +87 -2
  18. package/src/main/browserCapture.cjs +21 -7
  19. package/src/main/chatRunner.cjs +5 -1
  20. package/src/main/health.cjs +114 -3
  21. package/src/main/hives.cjs +2 -1
  22. package/src/main/ipcSchemas.cjs +33 -4
  23. package/src/main/lib/kebabCase.cjs +12 -0
  24. package/src/main/lib/memorySlug.cjs +10 -0
  25. package/src/main/lib/prdCreate.cjs +73 -0
  26. package/src/main/memoryAggregate.cjs +1 -1
  27. package/src/main/memoryTool.cjs +2 -1
  28. package/src/main/pty.cjs +7 -6
  29. package/src/main/runVerify.cjs +119 -6
  30. package/src/main/scheduler/prdParser.cjs +88 -0
  31. package/src/main/scheduler.cjs +176 -9
  32. package/src/main/templates/PRD_AUTHORING.md +126 -0
  33. package/src/main/usage.cjs +4 -0
  34. package/src/main/webRemote.cjs +70 -24
  35. package/src/preload/api.d.ts +1 -0
  36. package/dist/assets/index-BwFHVuSk.css +0 -32
  37. package/dist/assets/index-C6ep3yfh.js +0 -3559
@@ -0,0 +1,119 @@
1
+ /**
2
+ * prd-group-allocator.test.cjs — regression test for gh-issue-4 / PRD
3
+ * (allocateParallelGroup): two projects authoring PRDs concurrently under
4
+ * the shared prds/ directory both computed the same max-NN and allocated
5
+ * the same new parallel group (05-11 vs 06-09 on 2026-07-14, then 545 vs
6
+ * 545 again on 2026-07-15 between session-manager and sigma). NN is both
7
+ * the filename counter and the parallel-group key the scheduler parses,
8
+ * so a collision silently merges two unrelated projects' PRDs into one
9
+ * group.
10
+ *
11
+ * Run: timeout 120 node --test src/main/__tests__/prd-group-allocator.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const fs = require('node:fs');
19
+ const fsp = require('node:fs/promises');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const {
23
+ allocateParallelGroup,
24
+ maxParallelGroupInUse,
25
+ parsePrdRaw,
26
+ _resetCache,
27
+ } = require('../scheduler/prdParser.cjs');
28
+
29
+ function mkTmpPrdsDir() {
30
+ return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prd-alloc-'));
31
+ }
32
+
33
+ test('allocateParallelGroup returns max+1 on an empty directory', async () => {
34
+ const dir = await mkTmpPrdsDir();
35
+ const nn = await allocateParallelGroup(dir);
36
+ assert.strictEqual(nn, 1);
37
+ });
38
+
39
+ test('allocateParallelGroup scans the FULL directory, not a narrowed glob — reproduces the 577-PRD/max-544 live directory shape', async () => {
40
+ const dir = await mkTmpPrdsDir();
41
+ // Live directory (2026-07-18) holds an unpadded legacy name and a max of 544.
42
+ await fsp.writeFile(path.join(dir, '54-scheduler-memory-guard-cap3.md'), '# x\n');
43
+ await fsp.writeFile(path.join(dir, '110-some-slug.md'), '# x\n'); // would be missed by '^10[0-9]'
44
+ await fsp.writeFile(path.join(dir, '544-latest-prd.md'), '# x\n');
45
+ await fsp.writeFile(path.join(dir, '9-old-single-digit.md'), '# x\n');
46
+
47
+ const max = await maxParallelGroupInUse(dir);
48
+ assert.strictEqual(max, 544, 'must see 544 as the max even though 110 sorts before it lexically and 54/9 are unpadded');
49
+
50
+ const nn = await allocateParallelGroup(dir);
51
+ assert.strictEqual(nn, 545);
52
+ });
53
+
54
+ test('two concurrent allocateParallelGroup callers never receive the same new NN (the core race from the incident)', async () => {
55
+ const dir = await mkTmpPrdsDir();
56
+ await fsp.writeFile(path.join(dir, '544-latest-prd.md'), '# x\n');
57
+
58
+ const CONCURRENCY = 12;
59
+ const results = await Promise.all(
60
+ Array.from({ length: CONCURRENCY }, () => allocateParallelGroup(dir)),
61
+ );
62
+
63
+ const distinct = new Set(results);
64
+ assert.strictEqual(distinct.size, CONCURRENCY, `expected ${CONCURRENCY} distinct NNs, got collisions: ${results.join(',')}`);
65
+ for (const nn of results) {
66
+ assert.ok(nn > 544, `allocated NN ${nn} must not collide with existing on-disk NN 544`);
67
+ }
68
+ });
69
+
70
+ test('reservation markers are counted toward max-NN so a crash mid-allocation cannot be re-issued by a later caller', async () => {
71
+ const dir = await mkTmpPrdsDir();
72
+ await fsp.writeFile(path.join(dir, '544-latest-prd.md'), '# x\n');
73
+
74
+ const first = await allocateParallelGroup(dir); // 545, leaves .reserved-545 on disk
75
+ assert.strictEqual(first, 545);
76
+
77
+ // Simulate the caller never authoring 545-slug.md (crash between reserve
78
+ // and author). A later allocation must still move past 545, not reissue it.
79
+ const second = await allocateParallelGroup(dir);
80
+ assert.strictEqual(second, 546);
81
+ });
82
+
83
+ test('deliberate parallel siblings sharing one existing NN are unaffected by the allocator (main regression risk)', async () => {
84
+ const dir = await mkTmpPrdsDir();
85
+ // Three independent PRDs deliberately opting into the SAME group, exactly
86
+ // as /develop's SKILL.md contract allows — authored directly by filename,
87
+ // never through allocateParallelGroup.
88
+ await fsp.writeFile(path.join(dir, '545-scheduler-tick-stall-after-job-failure.md'), '---\ncwd: ~/Projects/session-manager\n---\nBody A\n');
89
+ await fsp.writeFile(path.join(dir, '545-filetree-show-hidden-default-and-persist.md'), '---\ncwd: ~/Projects/session-manager\n---\nBody B\n');
90
+ await fsp.writeFile(path.join(dir, '545-editor-tab-close-shortcut-and-close-right.md'), '---\ncwd: ~/Projects/session-manager\n---\nBody C\n');
91
+
92
+ const files = await fsp.readdir(dir);
93
+ const parsed = await Promise.all(
94
+ files.filter((f) => f.endsWith('.md')).map((f) => parsePrdRaw(path.join(dir, f))),
95
+ );
96
+ assert.strictEqual(parsed.length, 3);
97
+ for (const p of parsed) {
98
+ assert.strictEqual(p.parallelGroup, 545, 'siblings must keep sharing group 545 — the allocator must not renumber existing files');
99
+ }
100
+
101
+ // A brand-new PRD (not a deliberate sibling) allocated afterward must NOT
102
+ // land in 545 — it gets the next free group instead.
103
+ const nn = await allocateParallelGroup(dir);
104
+ assert.strictEqual(nn, 546);
105
+ });
106
+
107
+ test('module-level PRD dir-mtime cache in prdParser is unaffected by reservation markers (hidden files stay invisible to listPrdFiles)', async () => {
108
+ const dir = await mkTmpPrdsDir();
109
+ _resetCache();
110
+ await fsp.writeFile(path.join(dir, '1-a.md'), '# x\n');
111
+ const { listPrdFiles } = require('../scheduler/prdParser.cjs');
112
+ const before = await listPrdFiles(dir);
113
+ assert.strictEqual(before.length, 1);
114
+
115
+ await allocateParallelGroup(dir); // writes a .reserved-2 marker
116
+ _resetCache();
117
+ const after = await listPrdFiles(dir);
118
+ assert.strictEqual(after.length, 1, 'reservation markers must not appear as PRD files');
119
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * prdCreate.test.cjs — unit tests for the create-PRD body builder (PRD 549,
3
+ * gh-issue-6). Pure-function tests only; the HTTP route + auth/cwd-boundary
4
+ * behavior is covered by adminServer.test.cjs.
5
+ *
6
+ * Run: timeout 120 node --test src/main/__tests__/prdCreate.test.cjs
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { test } = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+ const fs = require('node:fs');
14
+ const {
15
+ deriveSlugFromTitle,
16
+ buildPrdBody,
17
+ readStandards,
18
+ PRD_CREATE_SLUG_RE,
19
+ } = require('../lib/prdCreate.cjs');
20
+
21
+ test('deriveSlugFromTitle lowercases, kebab-cases, and strips non-alnum runs', () => {
22
+ assert.strictEqual(deriveSlugFromTitle('Add Foo Bar!! Baz'), 'add-foo-bar-baz');
23
+ assert.strictEqual(deriveSlugFromTitle(' leading/trailing '), 'leading-trailing');
24
+ });
25
+
26
+ test('deriveSlugFromTitle output always satisfies PRD_CREATE_SLUG_RE', () => {
27
+ const slug = deriveSlugFromTitle('Some Title 123');
28
+ assert.ok(PRD_CREATE_SLUG_RE.test(slug));
29
+ });
30
+
31
+ test('readStandards reads the real standards.md and returns non-empty text', async () => {
32
+ const text = await readStandards();
33
+ assert.ok(text.includes('Execution discipline'));
34
+ });
35
+
36
+ test('buildPrdBody emits required frontmatter keys and body sections in order', async () => {
37
+ const standards = await readStandards();
38
+ const body = buildPrdBody({
39
+ title: 'Do the thing',
40
+ cwd: '~/Projects/session-manager',
41
+ estimateMinutes: 15,
42
+ goal: 'Build the thing.',
43
+ acceptanceCriteria: ['thing exists', 'tests pass'],
44
+ implementationNotes: 'See file.cjs:10.',
45
+ outOfScope: ['not this'],
46
+ }, standards);
47
+
48
+ assert.ok(body.startsWith('---\n'));
49
+ assert.match(body, /title: Do the thing/);
50
+ assert.match(body, /cwd: ~\/Projects\/session-manager/);
51
+ assert.match(body, /estimateMinutes: 15/);
52
+
53
+ const goalIdx = body.indexOf('# Goal');
54
+ const acIdx = body.indexOf('# Acceptance criteria');
55
+ const implIdx = body.indexOf('# Implementation notes');
56
+ const oosIdx = body.indexOf('# Out of scope');
57
+ const standardsIdx = body.indexOf('## Engineering standards');
58
+ assert.ok(goalIdx > 0 && goalIdx < acIdx && acIdx < implIdx && implIdx < oosIdx && oosIdx < standardsIdx,
59
+ 'sections must appear in Goal -> AC -> Implementation notes -> Out of scope -> Engineering standards order');
60
+
61
+ assert.match(body, /- \[ \] thing exists/);
62
+ assert.match(body, /- \[ \] tests pass/);
63
+ assert.match(body, /- not this/);
64
+ assert.ok(body.includes('Execution discipline'), 'must inline the standards.md content verbatim');
65
+ });
66
+
67
+ test('buildPrdBody omits parallelGroup frontmatter key when not supplied', () => {
68
+ const body = buildPrdBody({
69
+ title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
70
+ acceptanceCriteria: ['a'], implementationNotes: 'n',
71
+ }, 'standards text');
72
+ assert.ok(!/parallelGroup:/.test(body));
73
+ });
74
+
75
+ test('readStandards result is byte-identical to the on-disk file (single source of truth)', async () => {
76
+ const path = require('node:path');
77
+ const onDisk = fs.readFileSync(
78
+ path.join(__dirname, '..', '..', '..', 'plugins', 'session-manager-dev', 'skills', 'develop', 'standards.md'),
79
+ 'utf8',
80
+ );
81
+ assert.strictEqual(await readStandards(), onDisk);
82
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * pty-write-result.test.cjs — unit tests for PtyManager.write()'s return contract.
3
+ *
4
+ * Run: timeout 300 npx vitest run src/main/__tests__/pty-write-result.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { test, expect } from 'vitest';
10
+ const { manager } = require('../pty.cjs');
11
+
12
+ test('write to an existing session succeeds', () => {
13
+ const tabId = 'tab-existing';
14
+ const proc = { write: () => {} };
15
+ manager.sessions.set(tabId, { proc, cwd: '/tmp', created: 0 });
16
+ try {
17
+ const result = manager.write({ tabId, data: 'hello' });
18
+ expect(result).toEqual({ ok: true });
19
+ } finally {
20
+ manager.sessions.delete(tabId);
21
+ }
22
+ });
23
+
24
+ test('write to a nonexistent tabId returns no-pty', () => {
25
+ const result = manager.write({ tabId: 'tab-does-not-exist', data: 'hello' });
26
+ expect(result).toEqual({ ok: false, reason: 'no-pty' });
27
+ });
28
+
29
+ test('write where proc.write throws returns the error reason without throwing', () => {
30
+ const tabId = 'tab-throws';
31
+ const proc = {
32
+ write: () => {
33
+ throw new Error('EPIPE: write after end');
34
+ },
35
+ };
36
+ manager.sessions.set(tabId, { proc, cwd: '/tmp', created: 0 });
37
+ try {
38
+ let result;
39
+ expect(() => {
40
+ result = manager.write({ tabId, data: 'hello' });
41
+ }).not.toThrow();
42
+ expect(result).toEqual({ ok: false, reason: 'EPIPE: write after end' });
43
+ } finally {
44
+ manager.sessions.delete(tabId);
45
+ }
46
+ });
@@ -973,3 +973,149 @@ test('halt result + sentinel PASS + committedDuringRun:true → still halt', asy
973
973
  rmdir(tmp);
974
974
  }
975
975
  });
976
+
977
+ // ─── -merge-main postcondition exemption tests (2026-07-18 feedback) ──────────
978
+
979
+ const { isMergeMainSlug, extractMergeMainPrNumber } = require('../runVerify.cjs');
980
+
981
+ test('isMergeMainSlug matches the NN-prXXX-merge-main and NN-fix-prXXX-merge-main conventions', () => {
982
+ assert.equal(isMergeMainSlug('565-pr188-merge-main'), true);
983
+ assert.equal(isMergeMainSlug('571-fix-pr141-merge-main'), true);
984
+ assert.equal(isMergeMainSlug('523-fix-bounded-fix-plan-retry'), false);
985
+ assert.equal(isMergeMainSlug('406-browser-capture-panel-ui'), false);
986
+ assert.equal(isMergeMainSlug(''), false);
987
+ assert.equal(isMergeMainSlug(undefined), false);
988
+ });
989
+
990
+ test('extractMergeMainPrNumber prefers "PR #<n>" in text, falls back to slug', () => {
991
+ assert.equal(extractMergeMainPrNumber('565-pr188-merge-main', 'title: Merge current main into sigma PR #188 (round 2)'), 188);
992
+ assert.equal(extractMergeMainPrNumber('565-pr188-merge-main', 'no pr mention here'), 188);
993
+ assert.equal(extractMergeMainPrNumber('non-standard-slug', 'no pr mention here'), null);
994
+ });
995
+
996
+ // (1) -merge-main + PASS + no commit + gh reports MERGEABLE/non-CONFLICTING → verified, no issue
997
+ test('merge-main exemption: gh reports MERGEABLE → pass_no_commit_target_verified, no issue pushed', async () => {
998
+ const tmp = makeTmpDir();
999
+ try {
1000
+ const slug = '565-pr188-merge-main';
1001
+ writeLog(tmp, slug, noOpRunEvents('PR #188 was already merged into main by another actor.\nSCHEDULER_VERDICT: PASS'));
1002
+ const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
1003
+ let ghCalls = 0;
1004
+ const ghExecImpl = (cmd, args, opts) => {
1005
+ ghCalls++;
1006
+ assert.equal(cmd, 'gh');
1007
+ assert.deepEqual(args, ['pr', 'view', '188', '--json', 'mergeable,mergeStateStatus']);
1008
+ assert.equal(opts.cwd, '/tmp/sigma');
1009
+ return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'BEHIND' });
1010
+ };
1011
+ const verdict = await verifyRun({
1012
+ runDir: tmp,
1013
+ prdPath,
1014
+ queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
1015
+ allJobs: [],
1016
+ committedDuringRun: false,
1017
+ ghExecImpl,
1018
+ });
1019
+ assert.equal(ghCalls, 1);
1020
+ assert.equal(verdict.verdict, 'pass_no_commit_target_verified', `expected verified verdict, got ${verdict.verdict}: ${verdict.reason}`);
1021
+ assert.equal(verdict.downgradeTo, null);
1022
+ assert.equal(verdict.verifiedPrNumber, 188);
1023
+ } finally {
1024
+ rmdir(tmp);
1025
+ }
1026
+ });
1027
+
1028
+ // (2) -merge-main + PASS + no commit + gh reports CONFLICTING → falls through to pass_no_commit
1029
+ test('merge-main exemption: gh reports CONFLICTING → falls through to pass_no_commit', async () => {
1030
+ const tmp = makeTmpDir();
1031
+ try {
1032
+ const slug = '565-pr188-merge-main';
1033
+ writeLog(tmp, slug, noOpRunEvents('PR #188 needs no further work.\nSCHEDULER_VERDICT: PASS'));
1034
+ const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
1035
+ const ghExecImpl = () => JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CONFLICTING' });
1036
+ const verdict = await verifyRun({
1037
+ runDir: tmp,
1038
+ prdPath,
1039
+ queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
1040
+ allJobs: [],
1041
+ committedDuringRun: false,
1042
+ ghExecImpl,
1043
+ });
1044
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1045
+ assert.equal(verdict.downgradeTo, 'needs_review');
1046
+ } finally {
1047
+ rmdir(tmp);
1048
+ }
1049
+ });
1050
+
1051
+ // (3) -merge-main + PASS + no commit + gh call fails/times out → falls through to pass_no_commit, never throws
1052
+ test('merge-main exemption: gh call throws → falls through to pass_no_commit, never throws', async () => {
1053
+ const tmp = makeTmpDir();
1054
+ try {
1055
+ const slug = '565-pr188-merge-main';
1056
+ writeLog(tmp, slug, noOpRunEvents('PR #188 needs no further work.\nSCHEDULER_VERDICT: PASS'));
1057
+ const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
1058
+ const ghExecImpl = () => { throw new Error('gh: command not found'); };
1059
+ const verdict = await verifyRun({
1060
+ runDir: tmp,
1061
+ prdPath,
1062
+ queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
1063
+ allJobs: [],
1064
+ committedDuringRun: false,
1065
+ ghExecImpl,
1066
+ });
1067
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1068
+ assert.equal(verdict.downgradeTo, 'needs_review');
1069
+ } finally {
1070
+ rmdir(tmp);
1071
+ }
1072
+ });
1073
+
1074
+ // (4) non-merge-main slug, PASS + no commit → unchanged pass_no_commit behavior, zero gh invocations
1075
+ test('merge-main exemption: non-merge-main slug never attempts a gh call', async () => {
1076
+ const tmp = makeTmpDir();
1077
+ try {
1078
+ const slug = '406-browser-capture-panel-ui';
1079
+ writeLog(tmp, slug, noOpRunEvents('All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS'));
1080
+ const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
1081
+ let ghCalls = 0;
1082
+ const ghExecImpl = () => { ghCalls++; return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' }); };
1083
+ const verdict = await verifyRun({
1084
+ runDir: tmp,
1085
+ prdPath,
1086
+ queueEntry: { slug, status: 'running', cwd: '/tmp/proj' },
1087
+ allJobs: [],
1088
+ committedDuringRun: false,
1089
+ ghExecImpl,
1090
+ });
1091
+ assert.equal(ghCalls, 0, 'gh must never be invoked for a non-merge-main slug');
1092
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected unchanged pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1093
+ assert.equal(verdict.downgradeTo, 'needs_review');
1094
+ } finally {
1095
+ rmdir(tmp);
1096
+ }
1097
+ });
1098
+
1099
+ // (5) -merge-main slug but a real commit DID land → unaffected, path unreachable, zero gh invocations
1100
+ test('merge-main exemption: commit landed → path unreachable, zero gh invocations, verdict clean', async () => {
1101
+ const tmp = makeTmpDir();
1102
+ try {
1103
+ const slug = '565-pr188-merge-main';
1104
+ writeLog(tmp, slug, noOpRunEvents('Merged and pushed.\nSCHEDULER_VERDICT: PASS'));
1105
+ const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
1106
+ let ghCalls = 0;
1107
+ const ghExecImpl = () => { ghCalls++; return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' }); };
1108
+ const verdict = await verifyRun({
1109
+ runDir: tmp,
1110
+ prdPath,
1111
+ queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
1112
+ allJobs: [],
1113
+ committedDuringRun: true,
1114
+ ghExecImpl,
1115
+ });
1116
+ assert.equal(ghCalls, 0, 'gh must never be invoked when a commit already landed');
1117
+ assert.equal(verdict.verdict, 'clean', `expected clean (commit landed, sentinel PASS), got ${verdict.verdict}: ${verdict.reason}`);
1118
+ } finally {
1119
+ rmdir(tmp);
1120
+ }
1121
+ });
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * scheduler-committed-in-window.test.cjs — unit tests for committedInWindow.
3
3
  *
4
- * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-committed-in-window.test.cjs
4
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-committed-in-window.test.cjs
5
5
  */
6
6
 
7
7
  'use strict';
8
8
 
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
9
11
  const fs = require('node:fs');
10
12
  const os = require('node:os');
11
13
  const path = require('node:path');
@@ -42,7 +44,7 @@ test('committedInWindow returns true for a commit landed on a non-checked-out br
42
44
  git(dir, ['checkout', '-q', '-b', 'other-branch-at-exit']);
43
45
 
44
46
  const result = await committedInWindow(dir, startedAt, finishedAt);
45
- expect(result).toBe(true);
47
+ assert.strictEqual(result, true);
46
48
  } finally {
47
49
  fs.rmSync(dir, { recursive: true, force: true });
48
50
  }
@@ -55,7 +57,7 @@ test('committedInWindow returns false when the window covers no commit', async (
55
57
  const farFutureEnd = new Date(Date.now() + 366 * 24 * 60 * 60 * 1000).toISOString();
56
58
 
57
59
  const result = await committedInWindow(dir, farFutureStart, farFutureEnd);
58
- expect(result).toBe(false);
60
+ assert.strictEqual(result, false);
59
61
  } finally {
60
62
  fs.rmSync(dir, { recursive: true, force: true });
61
63
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * scheduler-tick-cancel-token.test.cjs — regression test for the 2026-07-14
3
+ * scheduler stall (PRD 543/544): the tick loop stopped picking up pending
4
+ * jobs after a job's run paused the queue (e.g. a rate limit), because the
5
+ * pause's cancelToken.cancelled flag was only ever reset inside
6
+ * runDueJobs() — clearPause() (used by the poll-loop's auth/network
7
+ * auto-recovery and the manual "Resume" button) never reset it, so every
8
+ * subsequent tickQueue() call short-circuited on the stale flag forever,
9
+ * even though queue.json's `paused` field was correctly null.
10
+ *
11
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-tick-cancel-token.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const { applyPauseCleared, pickNextBatch } = require('../scheduler.cjs');
19
+
20
+ test('clearPause un-cancels the tick guard so the next pending job still starts', () => {
21
+ // A job's run pauses the queue (e.g. rate limit) — setPaused() cancels the
22
+ // in-flight tick batch.
23
+ const cancelToken = { cancelled: true };
24
+
25
+ // The pause is cleared via a recovery path OTHER than runDueJobs() (the
26
+ // poll-loop's auth/network auto-recovery, or the manual "Resume" button —
27
+ // both call clearPause() directly). Before the fix, this left
28
+ // cancelToken.cancelled stuck at true forever.
29
+ applyPauseCleared(true, cancelToken);
30
+
31
+ assert.strictEqual(
32
+ cancelToken.cancelled,
33
+ false,
34
+ 'clearPause must un-cancel the tick guard, or every future tick short-circuits forever even though paused=null',
35
+ );
36
+
37
+ // With the guard cleared, the next tick's batch-picking proceeds normally
38
+ // and starts the next pending job after the failed one.
39
+ const jobs = [
40
+ { slug: '543-chat-mode-data-table-rendering', status: 'failed', cwd: '/proj' },
41
+ { slug: '544-committed-in-window-test-runner-consistency', status: 'pending', cwd: '/proj' },
42
+ ];
43
+ const { batch } = pickNextBatch(jobs, new Set(), 3);
44
+ assert.strictEqual(batch.length, 1);
45
+ assert.strictEqual(batch[0].slug, '544-committed-in-window-test-runner-consistency');
46
+ });
47
+
48
+ test('applyPauseCleared is a no-op when the queue was not actually paused', () => {
49
+ const cancelToken = { cancelled: false };
50
+ applyPauseCleared(false, cancelToken);
51
+ assert.strictEqual(cancelToken.cancelled, false);
52
+ });
53
+
54
+ console.log('scheduler-tick-cancel-token tests: PASS');
@@ -0,0 +1,141 @@
1
+ /**
2
+ * scheduler-transient-failure.test.cjs — classifying network-outage vs. real
3
+ * code failures so a transient outage (PRD 543/545: ENOTFOUND) gets a bounded
4
+ * requeue instead of burning a whole run + an auto-fix investigation on an
5
+ * outage that has nothing to do with the code.
6
+ *
7
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-transient-failure.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { test } = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const {
18
+ detectNetworkErrorInLog,
19
+ detectRateLimitInLog,
20
+ classifyFailureOutcome,
21
+ TRANSIENT_RETRY_CAP,
22
+ } = require('../scheduler.cjs');
23
+
24
+ // Real tail captured from the 543 incident run (2026-07-14T14-40-39-306Z),
25
+ // per the PRD's "reuse it, don't re-derive" instruction.
26
+ const NETWORK_OUTAGE_LOG_TAIL = String.raw`
27
+ {"type":"result","subtype":"success","is_error":true,"api_error_status":null,"duration_ms":592749,"duration_api_ms":83313,"num_turns":19,"result":"API Error: Unable to connect to API (ENOTFOUND)","stop_reason":"stop_sequence","session_id":"85b1a3f4-3625-4f2d-a9f0-b3994f062b39","total_cost_usd":0.75,"terminal_reason":"api_error","uuid":"cc041889-4497-4fca-9f83-73e3dae6de54"}
28
+ `;
29
+
30
+ const RATE_LIMIT_LOG_TAIL = String.raw`
31
+ {"type":"result","subtype":"error","is_error":true,"api_error_status":429,"rateLimitType":"five_hour","result":"You've hit your limit","uuid":"abc"}
32
+ `;
33
+
34
+ const REAL_CODE_FAILURE_LOG_TAIL = String.raw`
35
+ {"type":"result","subtype":"error","is_error":true,"result":"Error: expected 2 but got 3\n at test/foo.test.ts:12:5","terminal_reason":"error_max_turns","uuid":"def"}
36
+ `;
37
+
38
+ function writeTmpLog(contents) {
39
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transient-log-'));
40
+ const p = path.join(dir, 'run.log');
41
+ fs.writeFileSync(p, contents);
42
+ return p;
43
+ }
44
+
45
+ test('detectNetworkErrorInLog fires only on terminal_reason:api_error + a network-class error', () => {
46
+ const p = writeTmpLog(NETWORK_OUTAGE_LOG_TAIL);
47
+ try {
48
+ assert.strictEqual(detectNetworkErrorInLog(p), true);
49
+ } finally {
50
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test('detectNetworkErrorInLog does not fire on a genuine code failure (no terminal_reason:api_error)', () => {
55
+ const p = writeTmpLog(REAL_CODE_FAILURE_LOG_TAIL);
56
+ try {
57
+ assert.strictEqual(detectNetworkErrorInLog(p), false);
58
+ } finally {
59
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('detectNetworkErrorInLog does not fire on a rate-limit log (rateLimited handling stays separate)', () => {
64
+ const p = writeTmpLog(RATE_LIMIT_LOG_TAIL);
65
+ try {
66
+ assert.strictEqual(detectNetworkErrorInLog(p), false);
67
+ // And the reverse: the rate-limit detector doesn't fire on the network log.
68
+ assert.strictEqual(detectRateLimitInLog(p), true);
69
+ } finally {
70
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test('detectRateLimitInLog does not fire on a network-outage log', () => {
75
+ const p = writeTmpLog(NETWORK_OUTAGE_LOG_TAIL);
76
+ try {
77
+ assert.strictEqual(detectRateLimitInLog(p), false);
78
+ } finally {
79
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ test('classifyFailureOutcome: ENOTFOUND-class network error with a clean tree retries (bounded)', () => {
84
+ const decision = classifyFailureOutcome({
85
+ exitCode: 1,
86
+ networkError: true,
87
+ durationMs: 594_000,
88
+ transientRetries: 0,
89
+ newlyDirtyCount: 0,
90
+ });
91
+ assert.strictEqual(decision.action, 'retry');
92
+ assert.strictEqual(decision.transientKind, 'network');
93
+ assert.strictEqual(decision.retries, 0);
94
+ });
95
+
96
+ test('classifyFailureOutcome: network error with uncommitted work left behind refuses to requeue', () => {
97
+ const decision = classifyFailureOutcome({
98
+ exitCode: 1,
99
+ networkError: true,
100
+ durationMs: 594_000,
101
+ transientRetries: 0,
102
+ newlyDirtyCount: 2,
103
+ });
104
+ assert.strictEqual(decision.action, 'fail-dirty');
105
+ assert.strictEqual(decision.newlyDirtyCount, 2);
106
+ });
107
+
108
+ test('classifyFailureOutcome: retry cap exhausted fails without auto-fix, never loops unboundedly', () => {
109
+ const decision = classifyFailureOutcome({
110
+ exitCode: 1,
111
+ networkError: true,
112
+ durationMs: 594_000,
113
+ transientRetries: TRANSIENT_RETRY_CAP,
114
+ newlyDirtyCount: 0,
115
+ });
116
+ assert.strictEqual(decision.action, 'fail-cap');
117
+ assert.strictEqual(decision.retries, TRANSIENT_RETRY_CAP);
118
+ });
119
+
120
+ test('classifyFailureOutcome: a genuine code failure (non-network exit 1) is still terminal + investigated', () => {
121
+ const decision = classifyFailureOutcome({
122
+ exitCode: 1,
123
+ networkError: false,
124
+ durationMs: 30_000,
125
+ transientRetries: 0,
126
+ newlyDirtyCount: 0,
127
+ });
128
+ assert.strictEqual(decision.action, 'investigate');
129
+ });
130
+
131
+ test('classifyFailureOutcome: signal-kill transient (exit 143, short run) still retries as before', () => {
132
+ const decision = classifyFailureOutcome({
133
+ exitCode: 143,
134
+ networkError: false,
135
+ durationMs: 60_000,
136
+ transientRetries: 0,
137
+ newlyDirtyCount: 0,
138
+ });
139
+ assert.strictEqual(decision.action, 'retry');
140
+ assert.strictEqual(decision.transientKind, 'exit=143');
141
+ });