claude-code-session-manager 0.37.1 → 0.38.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 (60) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-GMk5LS9Y.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index--a8m5_ET.js → index-CQqSHpIq.js} +444 -436
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +25 -0
  9. package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
  10. package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
  11. package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
  12. package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
  13. package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
  14. package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
  15. package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
  16. package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
  17. package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
  18. package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
  19. package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
  20. package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
  21. package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
  22. package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
  23. package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
  24. package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
  25. package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
  26. package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
  27. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  28. package/scripts/lib/watchdogHelpers.cjs +296 -166
  29. package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
  30. package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
  31. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  32. package/src/main/__tests__/docEdit.test.cjs +164 -2
  33. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  34. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  35. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  36. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
  37. package/src/main/browserAgentServer.cjs +11 -10
  38. package/src/main/chatRunner.cjs +58 -14
  39. package/src/main/docEdit.cjs +125 -5
  40. package/src/main/health.cjs +15 -0
  41. package/src/main/index.cjs +12 -6
  42. package/src/main/ipcSchemas.cjs +16 -3
  43. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  44. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  45. package/src/main/lib/localAdminHttp.cjs +157 -0
  46. package/src/main/lib/personaImportHealth.cjs +161 -0
  47. package/src/main/lib/prdCreate.cjs +107 -17
  48. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  49. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  50. package/src/main/scheduler.cjs +223 -56
  51. package/src/main/sessionsStore.cjs +4 -5
  52. package/src/main/templates/PRD_AUTHORING.md +4 -4
  53. package/src/main/transcripts.cjs +1 -85
  54. package/src/main/webRemote.cjs +3 -3
  55. package/src/preload/api.d.ts +16 -6
  56. package/src/preload/index.cjs +9 -2
  57. package/dist/assets/index-CTTjT08J.css +0 -32
  58. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  59. package/src/main/__tests__/adminServer.test.cjs +0 -380
  60. package/src/main/adminServer.cjs +0 -242
@@ -1,40 +1,48 @@
1
1
  /**
2
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.
3
+ * gh-issue-6) and, since PRD 689, its registerAdminRoute create-prd HTTP
4
+ * route (moved verbatim out of the former standalone admin HTTP server
5
+ * module's handleRequest).
5
6
  *
6
- * Run: timeout 120 node --test src/main/__tests__/prdCreate.test.cjs
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdCreate.test.cjs
7
8
  */
8
9
 
9
10
  'use strict';
10
11
 
11
- const { test } = require('node:test');
12
- const assert = require('node:assert/strict');
12
+ import { test, expect } from 'vitest';
13
13
  const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const http = require('node:http');
14
18
  const {
15
19
  deriveSlugFromTitle,
16
20
  buildPrdBody,
17
21
  readStandards,
22
+ STANDARDS_PATH,
18
23
  PRD_CREATE_SLUG_RE,
24
+ registerAdminRoute,
19
25
  } = require('../lib/prdCreate.cjs');
26
+ const { createAdminHttp } = require('../lib/localAdminHttp.cjs');
27
+ const config = require('../config.cjs');
28
+ const prdParser = require('../scheduler/prdParser.cjs');
20
29
 
21
30
  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');
31
+ expect(deriveSlugFromTitle('Add Foo Bar!! Baz')).toBe('add-foo-bar-baz');
32
+ expect(deriveSlugFromTitle(' leading/trailing ')).toBe('leading-trailing');
24
33
  });
25
34
 
26
35
  test('deriveSlugFromTitle output always satisfies PRD_CREATE_SLUG_RE', () => {
27
36
  const slug = deriveSlugFromTitle('Some Title 123');
28
- assert.ok(PRD_CREATE_SLUG_RE.test(slug));
37
+ expect(PRD_CREATE_SLUG_RE.test(slug)).toBeTruthy();
29
38
  });
30
39
 
31
40
  test('readStandards reads the real standards.md and returns non-empty text', async () => {
32
41
  const text = await readStandards();
33
- assert.ok(text.includes('Execution discipline'));
42
+ expect(text.includes('Execution discipline')).toBeTruthy();
34
43
  });
35
44
 
36
45
  test('buildPrdBody emits required frontmatter keys and body sections in order', async () => {
37
- const standards = await readStandards();
38
46
  const body = buildPrdBody({
39
47
  title: 'Do the thing',
40
48
  cwd: '~/Projects/session-manager',
@@ -43,40 +51,272 @@ test('buildPrdBody emits required frontmatter keys and body sections in order',
43
51
  acceptanceCriteria: ['thing exists', 'tests pass'],
44
52
  implementationNotes: 'See file.cjs:10.',
45
53
  outOfScope: ['not this'],
46
- }, standards);
54
+ });
47
55
 
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/);
56
+ expect(body.startsWith('---\n')).toBeTruthy();
57
+ expect(body).toMatch(/title: Do the thing/);
58
+ expect(body).toMatch(/cwd: ~\/Projects\/session-manager/);
59
+ expect(body).toMatch(/estimateMinutes: 15/);
52
60
 
53
61
  const goalIdx = body.indexOf('# Goal');
54
62
  const acIdx = body.indexOf('# Acceptance criteria');
55
63
  const implIdx = body.indexOf('# Implementation notes');
56
64
  const oosIdx = body.indexOf('# Out of scope');
57
65
  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');
66
+ // sections must appear in Goal -> AC -> Implementation notes -> Out of scope -> Engineering standards order
67
+ expect(goalIdx > 0 && goalIdx < acIdx && acIdx < implIdx && implIdx < oosIdx && oosIdx < standardsIdx).toBeTruthy();
60
68
 
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');
69
+ expect(body).toMatch(/- \[ \] thing exists/);
70
+ expect(body).toMatch(/- \[ \] tests pass/);
71
+ expect(body).toMatch(/- not this/);
72
+ // must point at STANDARDS_PATH rather than inline the standards.md content
73
+ expect(body.includes(STANDARDS_PATH)).toBeTruthy();
74
+ expect(body).toMatch(/Before writing any code, read/);
75
+ // the pointer block mentions "Execution discipline" by name, but must not
76
+ // inline the full standards.md prose (e.g. its Performance-section rules)
77
+ expect(body.includes('Lay out hot data contiguously')).toBeFalsy();
65
78
  });
66
79
 
67
80
  test('buildPrdBody omits parallelGroup frontmatter key when not supplied', () => {
68
81
  const body = buildPrdBody({
69
82
  title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
70
83
  acceptanceCriteria: ['a'], implementationNotes: 'n',
71
- }, 'standards text');
72
- assert.ok(!/parallelGroup:/.test(body));
84
+ });
85
+ expect(!/parallelGroup:/.test(body)).toBeTruthy();
73
86
  });
74
87
 
75
88
  test('readStandards result is byte-identical to the on-disk file (single source of truth)', async () => {
76
- const path = require('node:path');
77
89
  const onDisk = fs.readFileSync(
78
90
  path.join(__dirname, '..', '..', '..', 'plugins', 'session-manager-dev', 'skills', 'develop', 'standards.md'),
79
91
  'utf8',
80
92
  );
81
- assert.strictEqual(await readStandards(), onDisk);
93
+ expect(await readStandards()).toBe(onDisk);
94
+ });
95
+
96
+ // ──────────────────────────────────────────── create-prd admin route
97
+
98
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
99
+ return new Promise((resolve, reject) => {
100
+ const headers = {};
101
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
102
+ let payload;
103
+ if (body !== undefined) {
104
+ payload = JSON.stringify(body);
105
+ headers['Content-Type'] = 'application/json';
106
+ headers['Content-Length'] = Buffer.byteLength(payload);
107
+ }
108
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
109
+ const chunks = [];
110
+ res.on('data', (c) => chunks.push(c));
111
+ res.on('end', () => {
112
+ const text = Buffer.concat(chunks).toString('utf8');
113
+ let json = null;
114
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
115
+ resolve({ status: res.statusCode, json, text });
116
+ });
117
+ });
118
+ req.on('error', reject);
119
+ if (payload) req.write(payload);
120
+ req.end();
121
+ });
122
+ }
123
+
124
+ // config.cjs's validateWrite only allows writes under a registered non-home
125
+ // allowedRoot's `.claude/` subtree (mirrors the real ROOT layout, which is
126
+ // $HOME/.claude/session-manager/...) — so the temp prdsDir must live under
127
+ // `<root>/.claude/...`, not directly under the temp root itself.
128
+ async function mkTmpPrdsDir() {
129
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-admin-create-prd-'));
130
+ config.addAllowedRoot(root);
131
+ const prdsDir = path.join(root, '.claude', 'prds');
132
+ await fsp.mkdir(prdsDir, { recursive: true });
133
+ return prdsDir;
134
+ }
135
+
136
+ // Mirrors scheduler.cjs's real `remote.allocateParallelGroup` / `remote.writePrd`
137
+ // (same underlying prdParser.allocateParallelGroup + config.writeTextAtomic
138
+ // calls) but pointed at a throwaway temp dir instead of the user's real
139
+ // scheduled-plans/prds — so these tests never touch $HOME/.claude.
140
+ function makeFakeRemoteWithPrdsDir(prdsDir) {
141
+ return {
142
+ async allocateParallelGroup() {
143
+ return prdParser.allocateParallelGroup(prdsDir);
144
+ },
145
+ async readPrd(slug) {
146
+ try {
147
+ const text = await fsp.readFile(path.join(prdsDir, `${slug}.md`), 'utf8');
148
+ return { ok: true, text };
149
+ } catch (e) {
150
+ return { ok: false, error: e?.message };
151
+ }
152
+ },
153
+ async writePrd(slug, body) {
154
+ try {
155
+ const filePath = path.join(prdsDir, `${slug}.md`);
156
+ await config.writeTextAtomic(filePath, body);
157
+ return { ok: true };
158
+ } catch (e) {
159
+ return { ok: false, error: e?.message };
160
+ }
161
+ },
162
+ };
163
+ }
164
+
165
+ function validCreateBody(overrides = {}) {
166
+ return {
167
+ title: 'Add widget frobnication',
168
+ cwd: os.homedir(),
169
+ estimateMinutes: 15,
170
+ goal: 'Add frobnication to the widget subsystem.',
171
+ acceptanceCriteria: ['widget frobnicates on click', 'timeout 300 npm run typecheck passes'],
172
+ implementationNotes: 'See src/widget.cjs:10.',
173
+ outOfScope: ['not touching gadgets'],
174
+ ...overrides,
175
+ };
176
+ }
177
+
178
+ async function startWithRemote(remote) {
179
+ const admin = createAdminHttp();
180
+ registerAdminRoute(admin, remote);
181
+ const { port, token } = await admin.start();
182
+ return { admin, port, token };
183
+ }
184
+
185
+ test('POST /admin/scheduler/create-prd without token returns 401', async () => {
186
+ const prdsDir = await mkTmpPrdsDir();
187
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
188
+ const { admin, port } = await startWithRemote(remote);
189
+ try {
190
+ const res = await request(port, {
191
+ method: 'POST', path: '/admin/scheduler/create-prd', body: validCreateBody(),
192
+ });
193
+ expect(res.status).toBe(401);
194
+ } finally {
195
+ await admin.stop();
196
+ }
197
+ });
198
+
199
+ test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, status}', async () => {
200
+ const prdsDir = await mkTmpPrdsDir();
201
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
202
+ const { admin, port, token } = await startWithRemote(remote);
203
+ try {
204
+ const res = await request(port, {
205
+ method: 'POST', path: '/admin/scheduler/create-prd', token, body: validCreateBody(),
206
+ });
207
+ expect(res.status).toBe(200);
208
+ expect(res.json.status).toBe('queued');
209
+ expect(typeof res.json.nn).toBe('number');
210
+ expect(res.json.filename).toMatch(/^\d+-add-widget-frobnication\.md$/);
211
+
212
+ const written = await fsp.readFile(path.join(prdsDir, res.json.filename), 'utf8');
213
+ expect(written).toMatch(/^---\n/);
214
+ expect(written).toMatch(/title: Add widget frobnication/);
215
+ expect(written).toMatch(/cwd: .+/);
216
+ expect(written).toMatch(/estimateMinutes: 15/);
217
+ expect(written).toMatch(/# Goal/);
218
+ expect(written).toMatch(/# Acceptance criteria/);
219
+ expect(written).toMatch(/- \[ \] widget frobnicates on click/);
220
+ expect(written).toMatch(/# Implementation notes/);
221
+ expect(written).toMatch(/# Out of scope/);
222
+ expect(written).toMatch(/## Engineering standards/);
223
+ // must point at STANDARDS_PATH rather than inline standards.md
224
+ expect(written.includes(STANDARDS_PATH)).toBeTruthy();
225
+ expect(written.includes('Lay out hot data contiguously')).toBeFalsy();
226
+ } finally {
227
+ await admin.stop();
228
+ }
229
+ });
230
+
231
+ test('POST /admin/scheduler/create-prd with malformed frontmatter (missing acceptanceCriteria) is rejected and writes no file', async () => {
232
+ const prdsDir = await mkTmpPrdsDir();
233
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
234
+ const { admin, port, token } = await startWithRemote(remote);
235
+ try {
236
+ const body = validCreateBody();
237
+ delete body.acceptanceCriteria;
238
+ const res = await request(port, {
239
+ method: 'POST', path: '/admin/scheduler/create-prd', token, body,
240
+ });
241
+ expect(res.status).toBe(400);
242
+ expect(res.json.ok).toBe(false);
243
+ const entries = await fsp.readdir(prdsDir);
244
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
245
+ } finally {
246
+ await admin.stop();
247
+ }
248
+ });
249
+
250
+ test('POST /admin/scheduler/create-prd with cwd outside allowed roots is rejected and writes no file', async () => {
251
+ const prdsDir = await mkTmpPrdsDir();
252
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
253
+ const { admin, port, token } = await startWithRemote(remote);
254
+ try {
255
+ const res = await request(port, {
256
+ method: 'POST', path: '/admin/scheduler/create-prd', token,
257
+ body: validCreateBody({ cwd: '/etc/passwd-adjacent-outside-home' }),
258
+ });
259
+ expect(res.status).toBe(400);
260
+ expect(res.json.ok).toBe(false);
261
+ const entries = await fsp.readdir(prdsDir);
262
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
263
+ } finally {
264
+ await admin.stop();
265
+ }
266
+ });
267
+
268
+ test('POST /admin/scheduler/create-prd rejects a title containing a newline (frontmatter-injection guard) and writes no file', async () => {
269
+ const prdsDir = await mkTmpPrdsDir();
270
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
271
+ const { admin, port, token } = await startWithRemote(remote);
272
+ try {
273
+ const res = await request(port, {
274
+ method: 'POST', path: '/admin/scheduler/create-prd', token,
275
+ body: validCreateBody({ title: 'Legit title\n---\nEvil: injected' }),
276
+ });
277
+ expect(res.status).toBe(400);
278
+ expect(res.json.ok).toBe(false);
279
+ const entries = await fsp.readdir(prdsDir);
280
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
281
+ } finally {
282
+ await admin.stop();
283
+ }
284
+ });
285
+
286
+ test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that already exists on disk returns 409 and does not clobber the file', async () => {
287
+ const prdsDir = await mkTmpPrdsDir();
288
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
289
+ const { admin, port, token } = await startWithRemote(remote);
290
+ try {
291
+ await fsp.writeFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'ORIGINAL CONTENT\n');
292
+ const res = await request(port, {
293
+ method: 'POST', path: '/admin/scheduler/create-prd', token,
294
+ body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
295
+ });
296
+ expect(res.status).toBe(409);
297
+ expect(res.json.ok).toBe(false);
298
+ const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
299
+ // existing PRD file must not be overwritten
300
+ expect(stillThere).toBe('ORIGINAL CONTENT\n');
301
+ } finally {
302
+ await admin.stop();
303
+ }
304
+ });
305
+
306
+ test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup instead of deriving/allocating', async () => {
307
+ const prdsDir = await mkTmpPrdsDir();
308
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
309
+ const { admin, port, token } = await startWithRemote(remote);
310
+ try {
311
+ const res = await request(port, {
312
+ method: 'POST', path: '/admin/scheduler/create-prd', token,
313
+ body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
314
+ });
315
+ expect(res.status).toBe(200);
316
+ expect(res.json.nn).toBe(777);
317
+ expect(res.json.filename).toBe('777-my-explicit-slug.md');
318
+ expect(fs.existsSync(path.join(prdsDir, '777-my-explicit-slug.md'))).toBeTruthy();
319
+ } finally {
320
+ await admin.stop();
321
+ }
82
322
  });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * scheduler-admin-routes.test.cjs — unit tests for scheduler.cjs's
3
+ * registerAdminRoutes (PRD 689 — moved out of the former standalone admin
4
+ * HTTP server module's handleRequest, no behavior change). Exercises the
5
+ * two job-management admin
6
+ * HTTP routes against a fake `remote` object, wired through the real
7
+ * localAdminHttp.cjs transport.
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-admin-routes.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect } from 'vitest';
15
+ const http = require('node:http');
16
+ const { createAdminHttp } = require('../lib/localAdminHttp.cjs');
17
+ const { registerAdminRoutes } = require('../scheduler.cjs');
18
+
19
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
20
+ return new Promise((resolve, reject) => {
21
+ const headers = {};
22
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
23
+ let payload;
24
+ if (body !== undefined) {
25
+ payload = JSON.stringify(body);
26
+ headers['Content-Type'] = 'application/json';
27
+ headers['Content-Length'] = Buffer.byteLength(payload);
28
+ }
29
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
30
+ const chunks = [];
31
+ res.on('data', (c) => chunks.push(c));
32
+ res.on('end', () => {
33
+ const text = Buffer.concat(chunks).toString('utf8');
34
+ let json = null;
35
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
36
+ resolve({ status: res.statusCode, json, text });
37
+ });
38
+ });
39
+ req.on('error', reject);
40
+ if (payload) req.write(payload);
41
+ req.end();
42
+ });
43
+ }
44
+
45
+ function makeFakeRemote({ jobs = [] } = {}) {
46
+ return {
47
+ async listJobs() {
48
+ return jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
49
+ },
50
+ async resetJob(slug) {
51
+ const job = jobs.find((j) => j.slug === slug);
52
+ if (!job) return { ok: false, error: 'not found' };
53
+ job.status = 'pending';
54
+ job.runId = null;
55
+ job.startedAt = null;
56
+ job.finishedAt = null;
57
+ job.exitCode = null;
58
+ job.error = null;
59
+ delete job.runtime;
60
+ delete job.verifierVerdict;
61
+ return { ok: true, slug, status: 'pending' };
62
+ },
63
+ };
64
+ }
65
+
66
+ async function startWithRemote(remote) {
67
+ const admin = createAdminHttp();
68
+ registerAdminRoutes(admin, remote);
69
+ const { port, token } = await admin.start();
70
+ return { admin, port, token };
71
+ }
72
+
73
+ test('GET /admin/scheduler/jobs with correct token returns 200 + array', async () => {
74
+ const remote = makeFakeRemote({
75
+ jobs: [{ slug: '10-foo', title: 'Foo', status: 'completed', cwd: '/tmp/foo' }],
76
+ });
77
+ const { admin, port, token } = await startWithRemote(remote);
78
+ try {
79
+ const res = await request(port, { path: '/admin/scheduler/jobs', token });
80
+ expect(res.status).toBe(200);
81
+ expect(Array.isArray(res.json)).toBeTruthy();
82
+ expect(res.json.length).toBe(1);
83
+ expect(res.json[0].slug).toBe('10-foo');
84
+ } finally {
85
+ await admin.stop();
86
+ }
87
+ });
88
+
89
+ test('POST /admin/scheduler/reset-job with unknown slug returns ok:false', async () => {
90
+ const remote = makeFakeRemote({ jobs: [] });
91
+ const { admin, port, token } = await startWithRemote(remote);
92
+ try {
93
+ const res = await request(port, {
94
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: 'does-not-exist' },
95
+ });
96
+ expect(res.status).toBe(200);
97
+ expect(res.json.ok).toBe(false);
98
+ } finally {
99
+ await admin.stop();
100
+ }
101
+ });
102
+
103
+ test('POST /admin/scheduler/reset-job without slug returns 400', async () => {
104
+ const remote = makeFakeRemote({ jobs: [] });
105
+ const { admin, port, token } = await startWithRemote(remote);
106
+ try {
107
+ const res = await request(port, {
108
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: {},
109
+ });
110
+ expect(res.status).toBe(400);
111
+ expect(res.json.ok).toBe(false);
112
+ } finally {
113
+ await admin.stop();
114
+ }
115
+ });
116
+
117
+ test('POST /admin/scheduler/reset-job with known slug flips job to pending and clears run fields', async () => {
118
+ const job = {
119
+ slug: '10-foo',
120
+ title: 'Foo',
121
+ status: 'completed',
122
+ cwd: '/tmp/foo',
123
+ runId: 'run-1',
124
+ startedAt: 100,
125
+ finishedAt: 200,
126
+ exitCode: 0,
127
+ error: null,
128
+ runtime: { pid: 1234 },
129
+ verifierVerdict: 'PASS',
130
+ };
131
+ const remote = makeFakeRemote({ jobs: [job] });
132
+ const { admin, port, token } = await startWithRemote(remote);
133
+ try {
134
+ const res = await request(port, {
135
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: '10-foo' },
136
+ });
137
+ expect(res.status).toBe(200);
138
+ expect(res.json).toEqual({ ok: true, slug: '10-foo', status: 'pending' });
139
+ expect(job.status).toBe('pending');
140
+ expect(job.runId).toBe(null);
141
+ expect(job.startedAt).toBe(null);
142
+ expect(job.finishedAt).toBe(null);
143
+ expect(job.exitCode).toBe(null);
144
+ expect(job.error).toBe(null);
145
+ expect('runtime' in job).toBe(false);
146
+ expect('verifierVerdict' in job).toBe(false);
147
+ } finally {
148
+ await admin.stop();
149
+ }
150
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * scheduler-boot-orphans.test.cjs — boot-time reconciliation of 'running' jobs
3
+ * left behind by an app crash/restart (PRD 686: consolidated in from the
4
+ * external watchdog's reconcileQueueOffline(), which is now deleted from
5
+ * scripts/lib/watchdogHelpers.cjs).
6
+ *
7
+ * Covers the two behaviors most likely to drift in the move:
8
+ * - a still-alive orphaned pid must be DEFERRED, never classified from its
9
+ * (possibly still-being-written) log in the same pass (partitionBootOrphans)
10
+ * - the ORPHAN_REQUEUE_CAP re-queue/exhaustion boundary (applyOrphanOutcome)
11
+ *
12
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-boot-orphans.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const { test } = require('node:test');
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 {
24
+ partitionBootOrphans,
25
+ applyOrphanOutcome,
26
+ feedbackSweepDue,
27
+ FEEDBACK_SWEEP_TICK_INTERVAL,
28
+ sweepFeedback,
29
+ } = require('../scheduler.cjs');
30
+ const { ORPHAN_REQUEUE_CAP } = require('../lib/reaperHelpers.cjs');
31
+
32
+ test('partitionBootOrphans defers a running job whose pid is still alive', () => {
33
+ const jobs = [
34
+ { slug: 'alive-orphan', status: 'running', runtime: { pid: 111 } },
35
+ { slug: 'dead-orphan', status: 'running', runtime: { pid: 222 } },
36
+ { slug: 'no-pid-orphan', status: 'running', runtime: {} },
37
+ { slug: 'not-running', status: 'pending' },
38
+ ];
39
+ const isAlive = (pid) => pid === 111;
40
+
41
+ const { immediate, deferred } = partitionBootOrphans(jobs, isAlive);
42
+
43
+ assert.deepEqual(deferred, ['alive-orphan']);
44
+ assert.deepEqual(immediate.sort(), ['dead-orphan', 'no-pid-orphan']);
45
+ });
46
+
47
+ test('partitionBootOrphans ignores non-running jobs entirely', () => {
48
+ const jobs = [
49
+ { slug: 'a', status: 'completed', runtime: { pid: 1 } },
50
+ { slug: 'b', status: 'failed', runtime: { pid: 2 } },
51
+ ];
52
+ const { immediate, deferred } = partitionBootOrphans(jobs, () => true);
53
+ assert.deepEqual(immediate, []);
54
+ assert.deepEqual(deferred, []);
55
+ });
56
+
57
+ test('applyOrphanOutcome: success finalizes to completed, clears runtime', () => {
58
+ const job = { slug: 's', status: 'running', runtime: { pid: 1 }, exitCode: null };
59
+ applyOrphanOutcome(job, 'success');
60
+ assert.equal(job.status, 'completed');
61
+ assert.equal(job.exitCode, 0);
62
+ assert.equal(job.error, null);
63
+ assert.ok(job.finishedAt);
64
+ assert.equal(job.runtime, undefined);
65
+ });
66
+
67
+ test('applyOrphanOutcome: failed finalizes to failed with orphan note', () => {
68
+ const job = { slug: 's', status: 'running', runtime: { pid: 1 }, exitCode: null };
69
+ applyOrphanOutcome(job, 'failed', ' (orphan pid=1: killed)');
70
+ assert.equal(job.status, 'failed');
71
+ assert.equal(job.exitCode, 1);
72
+ assert.match(job.error, /orphaned: app restarted while running \(orphan pid=1: killed\)/);
73
+ assert.equal(job.runtime, undefined);
74
+ });
75
+
76
+ test('applyOrphanOutcome: no_result re-queues to pending under the cap and increments orphanRetries', () => {
77
+ const job = { slug: 's', status: 'running', runtime: { pid: 1 }, orphanRetries: 0 };
78
+ applyOrphanOutcome(job, 'no_result');
79
+ assert.equal(job.status, 'pending');
80
+ assert.equal(job.orphanRetries, 1);
81
+ assert.match(job.error, /re-queued \(attempt 1/);
82
+ assert.equal(job.runtime, undefined);
83
+ });
84
+
85
+ test('applyOrphanOutcome: no_result at the cap fails terminally instead of re-queuing again', () => {
86
+ const job = { slug: 's', status: 'running', runtime: { pid: 1 }, orphanRetries: ORPHAN_REQUEUE_CAP };
87
+ applyOrphanOutcome(job, 'unknown');
88
+ assert.equal(job.status, 'failed');
89
+ assert.match(job.error, new RegExp(`exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts`));
90
+ assert.equal(job.runtime, undefined);
91
+ });
92
+
93
+ // ── feedback sweep moved into the 60s poll tick (PRD 686) ───────────────────
94
+
95
+ test('feedbackSweepDue gates on the tick interval, not every tick', () => {
96
+ for (let i = 1; i < FEEDBACK_SWEEP_TICK_INTERVAL; i++) {
97
+ assert.equal(feedbackSweepDue(i), false, `tick ${i} should not be due yet`);
98
+ }
99
+ assert.equal(feedbackSweepDue(FEEDBACK_SWEEP_TICK_INTERVAL), true);
100
+ assert.equal(feedbackSweepDue(FEEDBACK_SWEEP_TICK_INTERVAL + 1), true);
101
+ });
102
+
103
+ test('scheduler.cjs reuses watchdogHelpers.sweep verbatim (no forked copy)', () => {
104
+ const { sweep } = require('../../../scripts/lib/watchdogHelpers.cjs');
105
+ assert.equal(sweepFeedback, sweep, 'scheduler.cjs must import the same sweep function, not reimplement it');
106
+ });
107
+
108
+ function makeTmpDir() {
109
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'sched-boot-sweep-'));
110
+ }
111
+
112
+ function makeQueue(base) {
113
+ const queuePath = path.join(base, 'queue.json');
114
+ fs.writeFileSync(queuePath, JSON.stringify({ version: 1, jobs: [], paused: null }, null, 2));
115
+ return queuePath;
116
+ }
117
+
118
+ const FAKE_SKILL = '---\nname: process-feedback\ndescription: x\n---\n\n# process-feedback\n\nbody\n';
119
+ const FAKE_STANDARDS = '# Engineering standards\n\nbody\n';
120
+
121
+ test('the poll-tick sweep call emits a PRD for an active project with open feedback, and skips one outside the 90-min window', () => {
122
+ const base = makeTmpDir();
123
+ try {
124
+ // Active project (10 min ago) with open feedback → should emit.
125
+ const activeProj = path.join(base, 'active-proj');
126
+ fs.mkdirSync(path.join(activeProj, 'session-manager-operations', 'feedback'), { recursive: true });
127
+ fs.writeFileSync(path.join(activeProj, 'session-manager-operations', 'feedback', '2026-01-01-item.md'), '# Item\n');
128
+
129
+ // Stale project (120 min ago, outside the 90-min window) with open feedback → should NOT be scanned at all.
130
+ const staleProj = path.join(base, 'stale-proj');
131
+ fs.mkdirSync(path.join(staleProj, 'session-manager-operations', 'feedback'), { recursive: true });
132
+ fs.writeFileSync(path.join(staleProj, 'session-manager-operations', 'feedback', '2026-01-01-item.md'), '# Item\n');
133
+
134
+ // Active project with no open feedback → scanned but not emitted.
135
+ const noFeedbackProj = path.join(base, 'no-feedback-proj');
136
+ fs.mkdirSync(noFeedbackProj, { recursive: true });
137
+
138
+ const projectsDir = path.join(base, 'projects');
139
+ const recent = new Date(Date.now() - 10 * 60 * 1000);
140
+ const stale = new Date(Date.now() - 120 * 60 * 1000);
141
+ for (const [slug, cwd, mtime] of [
142
+ ['slug-active', activeProj, recent],
143
+ ['slug-stale', staleProj, stale],
144
+ ['slug-no-feedback', noFeedbackProj, recent],
145
+ ]) {
146
+ const projDir = path.join(projectsDir, slug);
147
+ fs.mkdirSync(projDir, { recursive: true });
148
+ const fp = path.join(projDir, 'a.jsonl');
149
+ fs.writeFileSync(fp, JSON.stringify({ cwd }) + '\n');
150
+ fs.utimesSync(fp, mtime, mtime);
151
+ }
152
+
153
+ const prdsDir = path.join(base, 'prds');
154
+ fs.mkdirSync(prdsDir);
155
+ const queuePath = makeQueue(base);
156
+ const skillPath = path.join(base, 'SKILL.md');
157
+ const standardsPath = path.join(base, 'standards.md');
158
+ fs.writeFileSync(skillPath, FAKE_SKILL);
159
+ fs.writeFileSync(standardsPath, FAKE_STANDARDS);
160
+
161
+ const result = sweepFeedback({ projectsDir, prdsDir, queuePath, skillPath, standardsPath });
162
+
163
+ assert.equal(result.scanned, 2, 'only the two in-window projects should be scanned');
164
+ assert.equal(result.emitted, 1, 'only the active project with open feedback should get a PRD');
165
+
166
+ const emittedFiles = fs.readdirSync(prdsDir).filter((f) => f.endsWith('.md'));
167
+ assert.equal(emittedFiles.length, 1);
168
+ assert.match(emittedFiles[0], /feedback-active-proj\.md$/);
169
+ } finally {
170
+ fs.rmSync(base, { recursive: true, force: true });
171
+ }
172
+ });