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.
- package/dist/assets/{TiptapBody-DrqOSx6h.js → TiptapBody-MCzz_6Zm.js} +1 -1
- package/dist/assets/{index-C_w1NLLg.js → index-BPbLVT6E.js} +490 -488
- package/dist/assets/{index-B6JNrbpD.css → index-DVlD8N1X.css} +2 -2
- package/dist/index.html +2 -2
- package/package.json +3 -3
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/scripts/lib/activeSessions.cjs +26 -3
- package/scripts/lib/watchdogHelpers.cjs +13 -6
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +84 -0
- package/src/main/__tests__/prdCreate.test.cjs +6 -0
- package/src/main/__tests__/prdLocations.test.cjs +41 -0
- package/src/main/__tests__/prdMigration.test.cjs +41 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +106 -0
- package/src/main/__tests__/queueHistory.test.cjs +2 -2
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +108 -0
- package/src/main/__tests__/runVerify.test.cjs +459 -4
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +52 -3
- package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +102 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +126 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +58 -3
- package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +42 -0
- package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +63 -0
- package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +23 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +68 -0
- package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +77 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +62 -0
- package/src/main/browserView.cjs +5 -4
- package/src/main/chatRunner.cjs +90 -7
- package/src/main/config.cjs +9 -0
- package/src/main/ipcSchemas.cjs +1 -1
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +118 -0
- package/src/main/lib/prdLocations.cjs +43 -6
- package/src/main/lib/prdMigration.cjs +5 -3
- package/src/main/lib/queueHistory.cjs +1 -1
- package/src/main/lib/rcaFeedbackHook.cjs +55 -5
- package/src/main/lib/terminalRunOutcome.cjs +100 -0
- package/src/main/promptSessionEvents.cjs +87 -0
- package/src/main/runVerify.cjs +195 -3
- package/src/main/scheduler.cjs +436 -80
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
+
});
|