claude-code-session-manager 0.39.0 → 0.39.2

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 (40) hide show
  1. package/dist/assets/{TiptapBody-DrqOSx6h.js → TiptapBody-MCzz_6Zm.js} +1 -1
  2. package/dist/assets/{index-C_w1NLLg.js → index-BPbLVT6E.js} +490 -488
  3. package/dist/assets/{index-B6JNrbpD.css → index-DVlD8N1X.css} +2 -2
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  7. package/scripts/lib/activeSessions.cjs +26 -3
  8. package/scripts/lib/watchdogHelpers.cjs +13 -6
  9. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +84 -0
  10. package/src/main/__tests__/prdCreate.test.cjs +6 -0
  11. package/src/main/__tests__/prdLocations.test.cjs +41 -0
  12. package/src/main/__tests__/prdMigration.test.cjs +41 -0
  13. package/src/main/__tests__/promptSessionEvents.test.cjs +106 -0
  14. package/src/main/__tests__/queueHistory.test.cjs +2 -2
  15. package/src/main/__tests__/rcaFeedbackHook.test.cjs +108 -0
  16. package/src/main/__tests__/runVerify.test.cjs +459 -4
  17. package/src/main/__tests__/scheduler-admin-routes.test.cjs +52 -3
  18. package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +102 -0
  19. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +126 -0
  20. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +58 -3
  21. package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +42 -0
  22. package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +63 -0
  23. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +23 -0
  24. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +68 -0
  25. package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +77 -0
  26. package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +62 -0
  27. package/src/main/browserView.cjs +5 -4
  28. package/src/main/chatRunner.cjs +90 -7
  29. package/src/main/config.cjs +9 -0
  30. package/src/main/ipcSchemas.cjs +1 -1
  31. package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +118 -0
  32. package/src/main/lib/prdLocations.cjs +43 -6
  33. package/src/main/lib/prdMigration.cjs +5 -3
  34. package/src/main/lib/queueHistory.cjs +1 -1
  35. package/src/main/lib/rcaFeedbackHook.cjs +55 -5
  36. package/src/main/lib/terminalRunOutcome.cjs +100 -0
  37. package/src/main/promptSessionEvents.cjs +87 -0
  38. package/src/main/runVerify.cjs +195 -3
  39. package/src/main/scheduler.cjs +436 -80
  40. package/src/preload/api.d.ts +5 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * runVerify.test.cjs — unit tests for the post-run reconciliation verifier.
3
3
  *
4
- * Run standalone: node src/main/__tests__/runVerify.test.cjs
4
+ * Run standalone: timeout 180 npx vitest run src/main/__tests__/runVerify.test.cjs
5
5
  *
6
6
  * Three fixture scenarios (matching the 2026-05-23→24 incident window):
7
7
  *
@@ -19,13 +19,12 @@
19
19
  * Expected: clean → null (caller may stamp 'completed')
20
20
  */
21
21
 
22
- 'use strict';
23
-
24
- const { test } = require('node:test');
22
+ import { test } from 'vitest';
25
23
  const assert = require('node:assert/strict');
26
24
  const os = require('node:os');
27
25
  const fs = require('node:fs');
28
26
  const path = require('node:path');
27
+ const { execFileSync } = require('node:child_process');
29
28
  const { verifyRun } = require('../runVerify.cjs');
30
29
 
31
30
  // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -389,6 +388,220 @@ test('FAIL recovered within 30 events → clean', async () => {
389
388
  }
390
389
  });
391
390
 
391
+ // ─── self-recovery: sleep-prefix normalization (RCA 745-pr188-ci-lint-docs-integrity) ──
392
+
393
+ /**
394
+ * Build N innocuous tool_use/tool_result pairs, used to push a later error
395
+ * event into the last-20%-of-transcript window that the is_error scan
396
+ * requires (parseLog counts every tool_use/tool_result as its own event).
397
+ */
398
+ function paddingEvents(n) {
399
+ const out = [];
400
+ for (let i = 0; i < n; i++) {
401
+ out.push({
402
+ type: 'assistant',
403
+ message: { role: 'assistant', content: [{ type: 'tool_use', id: `toolu_pad${i}`, name: 'Bash', input: { command: 'echo ok', description: `padding step ${i}` } }] },
404
+ });
405
+ out.push({
406
+ type: 'user',
407
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `toolu_pad${i}`, content: 'ok', is_error: false }] },
408
+ });
409
+ }
410
+ return out;
411
+ }
412
+
413
+ test('sleep-prefixed FAIL recovered by a bare retry of the same command → clean', async () => {
414
+ const tmp = makeTmpDir();
415
+ try {
416
+ const slug = '812-sleep-prefix-recovery';
417
+ const logEvents = [
418
+ ...paddingEvents(8),
419
+ // First attempt: polls too early with a `sleep 20 &&` prefix, gets a
420
+ // pending/error result.
421
+ {
422
+ type: 'assistant',
423
+ message: {
424
+ role: 'assistant',
425
+ content: [{
426
+ type: 'tool_use',
427
+ id: 'toolu_r1',
428
+ name: 'Bash',
429
+ input: {
430
+ command: 'sleep 20 && gh pr checks 188 --repo midt-bg/sigma 2>&1',
431
+ description: 'sleep 20 && gh pr checks 188 --repo midt-bg/sigma',
432
+ },
433
+ }],
434
+ },
435
+ },
436
+ {
437
+ type: 'user',
438
+ message: {
439
+ role: 'user',
440
+ content: [{
441
+ type: 'tool_result',
442
+ tool_use_id: 'toolu_r1',
443
+ content: 'Exit code 8\ncheck\tpending\t0\thttps://github.com/midt-bg/sigma/actions/runs/1/job/1',
444
+ is_error: true,
445
+ }],
446
+ },
447
+ },
448
+ // Later retry: same underlying command, no sleep prefix, succeeds.
449
+ {
450
+ type: 'assistant',
451
+ message: {
452
+ role: 'assistant',
453
+ content: [{
454
+ type: 'tool_use',
455
+ id: 'toolu_r2',
456
+ name: 'Bash',
457
+ input: {
458
+ command: 'gh pr checks 188 --repo midt-bg/sigma 2>&1',
459
+ description: 'gh pr checks 188 --repo midt-bg/sigma',
460
+ },
461
+ }],
462
+ },
463
+ },
464
+ {
465
+ type: 'user',
466
+ message: {
467
+ role: 'user',
468
+ content: [{
469
+ type: 'tool_result',
470
+ tool_use_id: 'toolu_r2',
471
+ content: 'check\tpass\t2m16s\thttps://github.com/midt-bg/sigma/actions/runs/1/job/1',
472
+ is_error: false,
473
+ }],
474
+ },
475
+ },
476
+ {
477
+ type: 'result',
478
+ subtype: 'success',
479
+ result: 'CI is green.\nSCHEDULER_VERDICT: PASS',
480
+ },
481
+ ];
482
+
483
+ writeLog(tmp, slug, logEvents);
484
+ const prdPath = writePrd(tmp, slug, '# Sleep-prefix recovery test');
485
+
486
+ const verdict = await verifyRun({
487
+ runDir: tmp,
488
+ prdPath,
489
+ queueEntry: { slug, status: 'running' },
490
+ allJobs: [],
491
+ committedDuringRun: true,
492
+ });
493
+
494
+ assert.equal(verdict.verdict, 'clean', `sleep-prefix recovery should yield clean, got ${verdict.verdict}: ${verdict.reason}`);
495
+ } finally {
496
+ rmdir(tmp);
497
+ }
498
+ });
499
+
500
+ test('normalizeDescForRecovery only strips the sleep wrapper, not unrelated text', () => {
501
+ const { normalizeDescForRecovery } = require('../runVerify.cjs');
502
+ assert.equal(
503
+ normalizeDescForRecovery('sleep 20 && gh pr checks 188 --repo midt-bg/sigma'),
504
+ 'gh pr checks 188 --repo midt-bg/sigma',
505
+ );
506
+ assert.equal(
507
+ normalizeDescForRecovery('sleep 90; gh pr checks 188 --repo midt-bg/sigma'),
508
+ 'gh pr checks 188 --repo midt-bg/sigma',
509
+ );
510
+ assert.equal(
511
+ normalizeDescForRecovery('gh pr checks 188 --repo midt-bg/sigma'),
512
+ 'gh pr checks 188 --repo midt-bg/sigma',
513
+ );
514
+ });
515
+
516
+ test('two different commands, one sleep-prefixed, are not paired as recovered', () => {
517
+ // Exercise isSelfRecovered directly (via parseLog) rather than the full
518
+ // verifyRun pipeline: a SCHEDULER_VERDICT: PASS + landed commit legitimately
519
+ // overrides a transcript_errors finding at the verifyRun layer by design
520
+ // (see verifyRun's sentinel-override comment), which would mask the
521
+ // narrower claim this test makes — that the sleep-prefix strip alone must
522
+ // not pair two genuinely different commands.
523
+ const { isSelfRecovered, parseLog } = require('../runVerify.cjs');
524
+ const tmp = makeTmpDir();
525
+ try {
526
+ const slug = '812-sleep-prefix-no-false-pair';
527
+ const logEvents = [
528
+ // First attempt: sleep-prefixed poll of PR 188, fails.
529
+ {
530
+ type: 'assistant',
531
+ message: {
532
+ role: 'assistant',
533
+ content: [{
534
+ type: 'tool_use',
535
+ id: 'toolu_r1',
536
+ name: 'Bash',
537
+ input: {
538
+ command: 'sleep 20 && gh pr checks 188 --repo midt-bg/sigma 2>&1',
539
+ description: 'sleep 20 && gh pr checks 188 --repo midt-bg/sigma',
540
+ },
541
+ }],
542
+ },
543
+ },
544
+ {
545
+ type: 'user',
546
+ message: {
547
+ role: 'user',
548
+ content: [{
549
+ type: 'tool_result',
550
+ tool_use_id: 'toolu_r1',
551
+ content: 'Exit code 8\ncheck\tpending\t0\thttps://github.com/midt-bg/sigma/actions/runs/1/job/1',
552
+ is_error: true,
553
+ }],
554
+ },
555
+ },
556
+ // "Retry" is actually a different command (different PR number) —
557
+ // after stripping the sleep wrapper it must NOT be treated as the
558
+ // same underlying command.
559
+ {
560
+ type: 'assistant',
561
+ message: {
562
+ role: 'assistant',
563
+ content: [{
564
+ type: 'tool_use',
565
+ id: 'toolu_r2',
566
+ name: 'Bash',
567
+ input: {
568
+ command: 'gh pr checks 999 --repo midt-bg/sigma 2>&1',
569
+ description: 'gh pr checks 999 --repo midt-bg/sigma',
570
+ },
571
+ }],
572
+ },
573
+ },
574
+ {
575
+ type: 'user',
576
+ message: {
577
+ role: 'user',
578
+ content: [{
579
+ type: 'tool_result',
580
+ tool_use_id: 'toolu_r2',
581
+ content: 'check\tpass\t2m16s\thttps://github.com/midt-bg/sigma/actions/runs/2/job/2',
582
+ is_error: false,
583
+ }],
584
+ },
585
+ },
586
+ {
587
+ type: 'result',
588
+ subtype: 'success',
589
+ result: 'Done.\nSCHEDULER_VERDICT: PASS',
590
+ },
591
+ ];
592
+
593
+ writeLog(tmp, slug, logEvents);
594
+ const { events } = parseLog(path.join(tmp, `${slug}.log`));
595
+ const failedToolUse = events.find((ev) => ev.kind === 'tool_use' && ev.toolUseId === 'toolu_r1');
596
+ assert.ok(failedToolUse, 'fixture should contain the failing tool_use event');
597
+
598
+ const recovered = isSelfRecovered(events, failedToolUse.seq, failedToolUse.description);
599
+ assert.equal(recovered, false, 'a differently-numbered PR check must not be treated as a recovered retry of the sleep-prefixed one');
600
+ } finally {
601
+ rmdir(tmp);
602
+ }
603
+ });
604
+
392
605
  // ─── fixtures: feedback 2026-06-10-01 — quoted-error false positives ─────────
393
606
 
394
607
  /** Build a one-Bash-call log: tool_use → tool_result(content) → result event. */
@@ -922,6 +1135,248 @@ test('pass_no_commit: fix-plan job (slug ^\\d+-fix-) with PASS but no commit is
922
1135
  }
923
1136
  });
924
1137
 
1138
+ // ─── pass_no_commit_already_shipped: original PRD re-run after landing ─────
1139
+ // (incident: 655-needs-review-rca-feedback-hook, 2026-07-31)
1140
+
1141
+ const { extractPrdDeliverablePaths, allDeliverablesAlreadyTracked } = require('../runVerify.cjs');
1142
+ const REPO_ROOT = path.resolve(__dirname, '../../..');
1143
+
1144
+ test('pass_no_commit: original PRD, PASS no commit, all deliverable paths already tracked in git → pass_no_commit_already_shipped', async () => {
1145
+ const tmp = makeTmpDir();
1146
+ try {
1147
+ const slug = '655-needs-review-rca-feedback-hook';
1148
+ writeLog(tmp, slug, noOpRunEvents('Verified rcaFeedbackHook.cjs already shipped in e13168d; typecheck + tests green; no commit needed.\nSCHEDULER_VERDICT: PASS'));
1149
+ const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/rcaFeedbackHook.cjs` and `src/main/__tests__/rcaFeedbackHook.test.cjs`.');
1150
+ const verdict = await verifyRun({
1151
+ runDir: tmp,
1152
+ prdPath,
1153
+ queueEntry: { slug, status: 'running', cwd: REPO_ROOT },
1154
+ allJobs: [],
1155
+ committedDuringRun: false,
1156
+ });
1157
+ assert.equal(verdict.verdict, 'pass_no_commit_already_shipped', `expected already-shipped exemption, got ${verdict.verdict}: ${verdict.reason}`);
1158
+ assert.equal(verdict.downgradeTo, null);
1159
+ } finally {
1160
+ rmdir(tmp);
1161
+ }
1162
+ });
1163
+
1164
+ test('pass_no_commit: original PRD, PASS no commit, one deliverable path untracked → falls through to pass_no_commit', async () => {
1165
+ const tmp = makeTmpDir();
1166
+ try {
1167
+ const slug = '655-needs-review-rca-feedback-hook';
1168
+ writeLog(tmp, slug, noOpRunEvents('Nothing to do here.\nSCHEDULER_VERDICT: PASS'));
1169
+ const prdPath = writePrd(tmp, slug, 'Deliverables: `src/main/lib/rcaFeedbackHook.cjs` and `src/main/lib/does-not-exist-xyz.cjs`.');
1170
+ const verdict = await verifyRun({
1171
+ runDir: tmp,
1172
+ prdPath,
1173
+ queueEntry: { slug, status: 'running', cwd: REPO_ROOT },
1174
+ allJobs: [],
1175
+ committedDuringRun: false,
1176
+ });
1177
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1178
+ } finally {
1179
+ rmdir(tmp);
1180
+ }
1181
+ });
1182
+
1183
+ test('pass_no_commit: original PRD, PASS no commit, zero extractable deliverable paths → falls through to pass_no_commit', async () => {
1184
+ const tmp = makeTmpDir();
1185
+ try {
1186
+ const slug = '999-some-vague-prd';
1187
+ writeLog(tmp, slug, noOpRunEvents('Nothing to do here.\nSCHEDULER_VERDICT: PASS'));
1188
+ const prdPath = writePrd(tmp, slug, 'This PRD names no file paths at all, just prose.');
1189
+ const verdict = await verifyRun({
1190
+ runDir: tmp,
1191
+ prdPath,
1192
+ queueEntry: { slug, status: 'running', cwd: REPO_ROOT },
1193
+ allJobs: [],
1194
+ committedDuringRun: false,
1195
+ });
1196
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1197
+ } finally {
1198
+ rmdir(tmp);
1199
+ }
1200
+ });
1201
+
1202
+ // (incident: 812-commit-guard-retry-on-worktree-ref-visibility-delay — a
1203
+ // stale scheduler process, booted before the already-shipped exemption
1204
+ // landed, produced a false pass_no_commit for exactly this shape of re-run)
1205
+ test('pass_no_commit: commit-guard-retry PRD naming scheduler.cjs + its committed-in-window test, PASS no commit → pass_no_commit_already_shipped', async () => {
1206
+ const tmp = makeTmpDir();
1207
+ try {
1208
+ const slug = '812-commit-guard-retry-on-worktree-ref-visibility-delay';
1209
+ writeLog(tmp, slug, noOpRunEvents('Retry + tests already present; nothing to change.\nSCHEDULER_VERDICT: PASS'));
1210
+ const prdPath = writePrd(
1211
+ tmp,
1212
+ slug,
1213
+ 'Deliverables: `src/main/scheduler.cjs` and `src/main/__tests__/scheduler-committed-in-window.test.cjs`.',
1214
+ );
1215
+ const verdict = await verifyRun({
1216
+ runDir: tmp,
1217
+ prdPath,
1218
+ queueEntry: { slug, status: 'running', cwd: REPO_ROOT },
1219
+ allJobs: [],
1220
+ committedDuringRun: false,
1221
+ });
1222
+ assert.equal(verdict.verdict, 'pass_no_commit_already_shipped', `expected already-shipped exemption, got ${verdict.verdict}: ${verdict.reason}`);
1223
+ assert.equal(verdict.downgradeTo, null);
1224
+ } finally {
1225
+ rmdir(tmp);
1226
+ }
1227
+ });
1228
+
1229
+ test('extractPrdDeliverablePaths: extracts backticked repo-relative paths, ignores prose', () => {
1230
+ const body = 'Deliverables: `src/main/lib/rcaFeedbackHook.cjs` and `src/main/__tests__/rcaFeedbackHook.test.cjs`. Also see node_modules/foo/bar.js which should be excluded.';
1231
+ const paths = extractPrdDeliverablePaths(body);
1232
+ assert.deepEqual(paths, ['src/main/lib/rcaFeedbackHook.cjs', 'src/main/__tests__/rcaFeedbackHook.test.cjs']);
1233
+ assert.deepEqual(extractPrdDeliverablePaths(''), []);
1234
+ assert.deepEqual(extractPrdDeliverablePaths(undefined), []);
1235
+ });
1236
+
1237
+ test('allDeliverablesAlreadyTracked: fail-safe on empty paths, non-git cwd, and git errors', () => {
1238
+ assert.equal(allDeliverablesAlreadyTracked({ cwd: REPO_ROOT, paths: [] }), false);
1239
+ assert.equal(allDeliverablesAlreadyTracked({
1240
+ cwd: REPO_ROOT,
1241
+ paths: ['src/main/runVerify.cjs'],
1242
+ execImpl: () => { throw new Error('boom'); },
1243
+ }), false);
1244
+ assert.equal(allDeliverablesAlreadyTracked({
1245
+ cwd: REPO_ROOT,
1246
+ paths: ['src/main/runVerify.cjs'],
1247
+ execImpl: () => '',
1248
+ }), true);
1249
+ });
1250
+
1251
+ // ─── repo-root config deliverable extraction ────────────────────────────────
1252
+ // (incident: 816-vitest-register-runverify-test, 2026-07-31 — PRD named only
1253
+ // `vitest.config.ts` as its deliverable; the source-dir-only regex extracted
1254
+ // nothing, so allDeliverablesAlreadyTracked early-returned false even though
1255
+ // the config edit was already tracked in git)
1256
+
1257
+ test('extractPrdDeliverablePaths: extracts a root-config file alongside a src path', () => {
1258
+ const body = 'Register the test in `vitest.config.ts` and port `src/main/__tests__/runVerify.test.cjs` onto vitest.';
1259
+ const paths = extractPrdDeliverablePaths(body);
1260
+ assert.deepEqual(new Set(paths), new Set(['vitest.config.ts', 'src/main/__tests__/runVerify.test.cjs']));
1261
+ });
1262
+
1263
+ test('extractPrdDeliverablePaths: root-config-only PRD body yields a non-empty result', () => {
1264
+ const body = 'The only deliverable is `vitest.config.ts` — add the missing test.include entry.';
1265
+ const paths = extractPrdDeliverablePaths(body);
1266
+ assert.deepEqual(paths, ['vitest.config.ts']);
1267
+ });
1268
+
1269
+ test('extractPrdDeliverablePaths: nested or non-allowlisted root files do not produce spurious root-config entries', () => {
1270
+ const body = 'See `some/nested/package.json` for the dependency, and update README.md with notes.';
1271
+ const paths = extractPrdDeliverablePaths(body);
1272
+ assert.equal(paths.includes('package.json'), false);
1273
+ assert.equal(paths.includes('README.md'), false);
1274
+ assert.deepEqual(paths, []);
1275
+ });
1276
+
1277
+ test('pass_no_commit: root-config-only PRD, PASS no commit, config already tracked → pass_no_commit_already_shipped', async () => {
1278
+ const tmp = makeTmpDir();
1279
+ try {
1280
+ const slug = '816-vitest-register-runverify-test';
1281
+ writeLog(tmp, slug, noOpRunEvents('Registered already in vitest.config.ts; 48 tests green.\nSCHEDULER_VERDICT: PASS'));
1282
+ const prdPath = writePrd(tmp, slug, 'Deliverable: register the test file in `vitest.config.ts`.');
1283
+ const verdict = await verifyRun({
1284
+ runDir: tmp,
1285
+ prdPath,
1286
+ queueEntry: { slug, status: 'running', cwd: REPO_ROOT },
1287
+ allJobs: [],
1288
+ committedDuringRun: false,
1289
+ });
1290
+ assert.equal(verdict.verdict, 'pass_no_commit_already_shipped', `expected already-shipped exemption, got ${verdict.verdict}: ${verdict.reason}`);
1291
+ assert.equal(verdict.downgradeTo, null);
1292
+ } finally {
1293
+ rmdir(tmp);
1294
+ }
1295
+ });
1296
+
1297
+ // ─── pass_no_commit_prior_run_verified: re-fired slug whose PRIOR run landed ─
1298
+ // (incident: 812-workbench-review-nits-cleanup, 2026-07-31)
1299
+
1300
+ const { isAncestorCommit } = require('../runVerify.cjs');
1301
+ const REPO_ROOT_COMMIT = execFileSync('git', ['rev-list', '--max-parents=0', 'HEAD'], {
1302
+ cwd: REPO_ROOT, encoding: 'utf8',
1303
+ }).trim().split('\n')[0];
1304
+
1305
+ test('pass_no_commit: PASS, no commit, priorLandedCommit is an ancestor of HEAD → pass_no_commit_prior_run_verified', async () => {
1306
+ const tmp = makeTmpDir();
1307
+ try {
1308
+ const slug = '812-workbench-review-nits-cleanup';
1309
+ writeLog(tmp, slug, noOpRunEvents('Re-checked every AC item; all already implemented by an earlier run.\nSCHEDULER_VERDICT: PASS'));
1310
+ const prdPath = writePrd(tmp, slug, 'This PRD names no deliverable paths, just prose.');
1311
+ const verdict = await verifyRun({
1312
+ runDir: tmp,
1313
+ prdPath,
1314
+ queueEntry: { slug, status: 'running', cwd: REPO_ROOT },
1315
+ allJobs: [],
1316
+ committedDuringRun: false,
1317
+ priorLandedCommit: REPO_ROOT_COMMIT,
1318
+ });
1319
+ assert.equal(verdict.verdict, 'pass_no_commit_prior_run_verified', `expected prior-run exemption, got ${verdict.verdict}: ${verdict.reason}`);
1320
+ assert.equal(verdict.downgradeTo, null);
1321
+ assert.equal(verdict.priorLandedCommit, REPO_ROOT_COMMIT);
1322
+ } finally {
1323
+ rmdir(tmp);
1324
+ }
1325
+ });
1326
+
1327
+ test('pass_no_commit: PASS, no commit, priorLandedCommit is NOT an ancestor of HEAD → pass_no_commit', async () => {
1328
+ const tmp = makeTmpDir();
1329
+ try {
1330
+ const slug = '812-workbench-review-nits-cleanup';
1331
+ writeLog(tmp, slug, noOpRunEvents('Nothing to do here.\nSCHEDULER_VERDICT: PASS'));
1332
+ const prdPath = writePrd(tmp, slug, 'This PRD names no deliverable paths, just prose.');
1333
+ const verdict = await verifyRun({
1334
+ runDir: tmp,
1335
+ prdPath,
1336
+ queueEntry: { slug, status: 'running', cwd: REPO_ROOT },
1337
+ allJobs: [],
1338
+ committedDuringRun: false,
1339
+ priorLandedCommit: '0000000000000000000000000000000000000000',
1340
+ });
1341
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1342
+ } finally {
1343
+ rmdir(tmp);
1344
+ }
1345
+ });
1346
+
1347
+ test('isAncestorCommit: fail-safe on missing sha and on a throwing execImpl (git error)', () => {
1348
+ assert.equal(isAncestorCommit({ cwd: REPO_ROOT, sha: null }), false);
1349
+ assert.equal(isAncestorCommit({
1350
+ cwd: REPO_ROOT,
1351
+ sha: REPO_ROOT_COMMIT,
1352
+ execImpl: () => { throw new Error('git: command not found'); },
1353
+ }), false);
1354
+ assert.equal(isAncestorCommit({
1355
+ cwd: REPO_ROOT,
1356
+ sha: REPO_ROOT_COMMIT,
1357
+ execImpl: () => '',
1358
+ }), true);
1359
+ });
1360
+
1361
+ test('pass_no_commit: PASS, no commit, priorLandedCommit not supplied (null) → pass_no_commit (regression guard)', async () => {
1362
+ const tmp = makeTmpDir();
1363
+ try {
1364
+ const slug = '812-workbench-review-nits-cleanup';
1365
+ writeLog(tmp, slug, noOpRunEvents('Nothing to do here.\nSCHEDULER_VERDICT: PASS'));
1366
+ const prdPath = writePrd(tmp, slug, 'This PRD names no deliverable paths, just prose.');
1367
+ const verdict = await verifyRun({
1368
+ runDir: tmp,
1369
+ prdPath,
1370
+ queueEntry: { slug, status: 'running', cwd: REPO_ROOT },
1371
+ allJobs: [],
1372
+ committedDuringRun: false,
1373
+ });
1374
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected pass_no_commit with no priorLandedCommit, got ${verdict.verdict}: ${verdict.reason}`);
1375
+ } finally {
1376
+ rmdir(tmp);
1377
+ }
1378
+ });
1379
+
925
1380
  // (e) halt + sentinel PASS → still halt (override must not apply to halt)
926
1381
  test('halt result + sentinel PASS + committedDuringRun:true → still halt', async () => {
927
1382
  const tmp = makeTmpDir();
@@ -47,9 +47,17 @@ function makeFakeRemote({ jobs = [] } = {}) {
47
47
  async listJobs() {
48
48
  return jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
49
49
  },
50
- async resetJob(slug) {
50
+ // Mirrors the real scheduler.cjs remote.resetJob's force-guard so this
51
+ // fake exercises the SAME contract the admin route promises callers.
52
+ async resetJob(slug, opts = {}) {
51
53
  const job = jobs.find((j) => j.slug === slug);
52
54
  if (!job) return { ok: false, error: 'not found' };
55
+ if (job.status === 'completed' && opts.force !== true) {
56
+ return {
57
+ ok: false,
58
+ error: 'job already completed — resetting it would re-execute shipped work; archive the PRD instead, or pass force:true',
59
+ };
60
+ }
53
61
  job.status = 'pending';
54
62
  job.runId = null;
55
63
  job.startedAt = null;
@@ -64,9 +72,15 @@ function makeFakeRemote({ jobs = [] } = {}) {
64
72
  }
65
73
 
66
74
  async function startWithRemote(remote) {
75
+ // Force the e2e-suffixed token path so this suite never races the real
76
+ // ~/.claude/session-manager/admin-api.json against other test files (or a
77
+ // live production instance) that also call createAdminHttp() concurrently.
78
+ const prevE2e = process.env.SM_E2E;
79
+ process.env.SM_E2E = '1';
67
80
  const admin = createAdminHttp();
68
81
  registerAdminRoutes(admin, remote);
69
82
  const { port, token } = await admin.start();
83
+ if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
70
84
  return { admin, port, token };
71
85
  }
72
86
 
@@ -114,11 +128,11 @@ test('POST /admin/scheduler/reset-job without slug returns 400', async () => {
114
128
  }
115
129
  });
116
130
 
117
- test('POST /admin/scheduler/reset-job with known slug flips job to pending and clears run fields', async () => {
131
+ test('POST /admin/scheduler/reset-job with known needs_review slug flips job to pending and clears run fields', async () => {
118
132
  const job = {
119
133
  slug: '10-foo',
120
134
  title: 'Foo',
121
- status: 'completed',
135
+ status: 'needs_review',
122
136
  cwd: '/tmp/foo',
123
137
  runId: 'run-1',
124
138
  startedAt: 100,
@@ -148,3 +162,38 @@ test('POST /admin/scheduler/reset-job with known slug flips job to pending and c
148
162
  await admin.stop();
149
163
  }
150
164
  });
165
+
166
+ test('POST /admin/scheduler/reset-job on a completed job without force refuses and does not mutate', async () => {
167
+ const job = { slug: '10-foo', title: 'Foo', status: 'completed', cwd: '/tmp/foo', runId: 'run-1' };
168
+ const remote = makeFakeRemote({ jobs: [job] });
169
+ const { admin, port, token } = await startWithRemote(remote);
170
+ try {
171
+ const res = await request(port, {
172
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: '10-foo' },
173
+ });
174
+ expect(res.status).toBe(200);
175
+ expect(res.json.ok).toBe(false);
176
+ expect(res.json.error).toMatch(/force:true/);
177
+ expect(job.status).toBe('completed');
178
+ expect(job.runId).toBe('run-1');
179
+ } finally {
180
+ await admin.stop();
181
+ }
182
+ });
183
+
184
+ test('POST /admin/scheduler/reset-job on a completed job WITH force:true resets it', async () => {
185
+ const job = { slug: '10-foo', title: 'Foo', status: 'completed', cwd: '/tmp/foo', runId: 'run-1' };
186
+ const remote = makeFakeRemote({ jobs: [job] });
187
+ const { admin, port, token } = await startWithRemote(remote);
188
+ try {
189
+ const res = await request(port, {
190
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: '10-foo', force: true },
191
+ });
192
+ expect(res.status).toBe(200);
193
+ expect(res.json).toEqual({ ok: true, slug: '10-foo', status: 'pending' });
194
+ expect(job.status).toBe('pending');
195
+ expect(job.runId).toBe(null);
196
+ } finally {
197
+ await admin.stop();
198
+ }
199
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * scheduler-archive-completed-prd.test.cjs — unit tests for archiveCompletedPrd
3
+ * (PRD 812 dormant-default-session-review-nits recurrence fix): a job's PRD
4
+ * `.md` must move from its `prds/` dir into the SIBLING `prds-archived/` dir
5
+ * the moment its job transitions to `completed`, so a re-fired scheduler
6
+ * tick can never pick the same finished slug back up.
7
+ *
8
+ * Uses a temp cwd fixture (`prdDirForCwd` is a pure path join off `cwd`) so
9
+ * this never touches the real `~/.claude/session-manager/` tree.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-archive-completed-prd.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const { archiveCompletedPrd, prdDirForCwd } = require('../scheduler.cjs');
21
+
22
+ const tmpDirs = [];
23
+
24
+ function makeFixtureCwd() {
25
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-archive-prd-'));
26
+ tmpDirs.push(cwd);
27
+ return cwd;
28
+ }
29
+
30
+ afterEach(() => {
31
+ while (tmpDirs.length) {
32
+ const dir = tmpDirs.pop();
33
+ fs.rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test('archiveCompletedPrd moves the .md from prds/ into sibling prds-archived/', async () => {
38
+ const cwd = makeFixtureCwd();
39
+ const slug = 'test-archive-on-completed';
40
+ const prdsDir = prdDirForCwd(cwd);
41
+ fs.mkdirSync(prdsDir, { recursive: true });
42
+ const src = path.join(prdsDir, `${slug}.md`);
43
+ fs.writeFileSync(src, '# Goal\n\ntest\n', 'utf8');
44
+
45
+ await archiveCompletedPrd(slug, cwd);
46
+
47
+ expect(fs.existsSync(src)).toBe(false);
48
+ const archivedPath = path.join(prdsDir, '..', 'prds-archived', `${slug}.md`);
49
+ expect(fs.existsSync(archivedPath)).toBe(true);
50
+ expect(fs.readFileSync(archivedPath, 'utf8')).toBe('# Goal\n\ntest\n');
51
+ });
52
+
53
+ test('a job left in needs_review never has its PRD archived (the .md stays in prds/)', async () => {
54
+ const cwd = makeFixtureCwd();
55
+ const slug = 'test-no-archive-on-needs-review';
56
+ const prdsDir = prdDirForCwd(cwd);
57
+ fs.mkdirSync(prdsDir, { recursive: true });
58
+ const src = path.join(prdsDir, `${slug}.md`);
59
+ fs.writeFileSync(src, '# Goal\n\ntest\n', 'utf8');
60
+
61
+ // scheduler.cjs only calls archiveCompletedPrd from status==='completed'
62
+ // transition sites — a needs_review (or failed/running) transition never
63
+ // invokes it, so the source file is untouched.
64
+
65
+ expect(fs.existsSync(src)).toBe(true);
66
+ const archivedPath = path.join(prdsDir, '..', 'prds-archived', `${slug}.md`);
67
+ expect(fs.existsSync(archivedPath)).toBe(false);
68
+ });
69
+
70
+ test('archiveCompletedPrd is a silent no-op when the source .md is missing (ENOENT)', async () => {
71
+ const cwd = makeFixtureCwd();
72
+ const slug = 'test-archive-enoent-noop';
73
+ const prdsDir = prdDirForCwd(cwd);
74
+ fs.mkdirSync(prdsDir, { recursive: true });
75
+
76
+ await expect(archiveCompletedPrd(slug, cwd)).resolves.toBeUndefined();
77
+
78
+ const archivedPath = path.join(prdsDir, '..', 'prds-archived', `${slug}.md`);
79
+ expect(fs.existsSync(archivedPath)).toBe(false);
80
+ });
81
+
82
+ test('archiveCompletedPrd refuses a slug that would resolve outside the PRD dir', async () => {
83
+ const cwd = makeFixtureCwd();
84
+ const prdsDir = prdDirForCwd(cwd);
85
+ fs.mkdirSync(prdsDir, { recursive: true });
86
+
87
+ // A slug crafted to escape prdsDir via '..' segments (SLUG_RE normally
88
+ // blocks this at the IPC boundary; archiveCompletedPrd itself must still
89
+ // refuse it as defense-in-depth, matching schedule:clear-queue's check).
90
+ const outsideDir = path.join(prdsDir, '..');
91
+ const escapeSlug = '../escaped-outside';
92
+ const outsideTarget = path.join(outsideDir, 'escaped-outside.md');
93
+ fs.writeFileSync(outsideTarget, 'should never be touched', 'utf8');
94
+
95
+ await expect(archiveCompletedPrd(escapeSlug, cwd)).resolves.toBeUndefined();
96
+
97
+ // Untouched in place — containment check refused before any rename.
98
+ expect(fs.existsSync(outsideTarget)).toBe(true);
99
+ expect(fs.readFileSync(outsideTarget, 'utf8')).toBe('should never be touched');
100
+ const archivedPath = path.join(prdsDir, '..', 'prds-archived', 'escaped-outside.md');
101
+ expect(fs.existsSync(archivedPath)).toBe(false);
102
+ });