claude-code-session-manager 0.80.0 → 0.82.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 (59) hide show
  1. package/dist/assets/{AgentLibrary-zS3jw_1e.js → AgentLibrary-pwlkAFb3.js} +1 -1
  2. package/dist/assets/{DataModel-Cy_vxTpi.js → DataModel-BZK9PXFD.js} +1 -1
  3. package/dist/assets/{History-C6JRuqfT.js → History-BVRjxJjS.js} +1 -1
  4. package/dist/assets/{Hooks-BafPy9mB.js → Hooks-CNuwHeGx.js} +1 -1
  5. package/dist/assets/{HostBilko-BZwhQOFt.js → HostBilko-cjwNodhV.js} +1 -1
  6. package/dist/assets/{Library-C8JDDliz.js → Library-YPNm9W92.js} +1 -1
  7. package/dist/assets/{ListDetail-CqiOdwLc.js → ListDetail-CY4GM1Om.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-CyLyP67L.js → MarkdownEditor-BF4y2Jiz.js} +1 -1
  9. package/dist/assets/{McpServers-BzMv-_84.js → McpServers-CmBdWtX_.js} +1 -1
  10. package/dist/assets/{Memory-DSBYQdJR.js → Memory-CvkIXNl1.js} +1 -1
  11. package/dist/assets/{Panel-CLUhkNNA.js → Panel-D93o-sxe.js} +1 -1
  12. package/dist/assets/{Permissions-BfC2-HN4.js → Permissions-DQipg16I.js} +1 -1
  13. package/dist/assets/{Plugins-BKi40jT5.js → Plugins-B6NwzPfK.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BzFw4KhD.js → ProvenanceBadge-DACVJhrB.js} +1 -1
  15. package/dist/assets/{SaveBar-avk2p9jv.js → SaveBar-Cg4lbChb.js} +1 -1
  16. package/dist/assets/{Scheduler-Bf_6MdJo.js → Scheduler-Dr5ZcBLe.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-C-RwYUVZ.js → ScopeSwitcher-C-locvy0.js} +1 -1
  18. package/dist/assets/{Settings-Djd8OoBA.js → Settings-BVrAle90.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DuogY6s7.js → SkillReferenceGraph-DDzuYgSK.js} +1 -1
  20. package/dist/assets/{Skills-D_qAqxZ_.js → Skills-DFvhiOAQ.js} +1 -1
  21. package/dist/assets/{SystemPrompt-DbHFLQV3.js → SystemPrompt-8PiTyUyL.js} +1 -1
  22. package/dist/assets/{TagLibrary-C2y91BT0.js → TagLibrary-DruUYaAc.js} +1 -1
  23. package/dist/assets/{TiptapBody-D9iz4xQx.js → TiptapBody-Dr4a--42.js} +1 -1
  24. package/dist/assets/{Toggle-BGnFL2E5.js → Toggle-B_EH2TFb.js} +1 -1
  25. package/dist/assets/{index-_2ARyFDj.js → index-DApB4DHS.js} +4 -4
  26. package/dist/assets/{settingsSchema-JK15eJU8.js → settingsSchema-BKa-xk8g.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  30. package/scripts/project-pages-logic/dist/logic.cjs +12 -12
  31. package/scripts/render-project-pages/dist/renderer.cjs +22 -22
  32. package/src/main/__tests__/machineProfile.test.cjs +134 -0
  33. package/src/main/__tests__/rcaReport.test.cjs +24 -0
  34. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
  35. package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
  36. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
  37. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
  38. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
  39. package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
  40. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +85 -0
  41. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
  42. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
  43. package/src/main/__tests__/telemetrySettings.test.cjs +178 -0
  44. package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
  45. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
  46. package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
  47. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
  48. package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
  49. package/src/main/lib/branchSweep.cjs +127 -0
  50. package/src/main/lib/gitWorktree.cjs +20 -0
  51. package/src/main/lib/landedSinceRun.cjs +41 -1
  52. package/src/main/lib/machineProfile.cjs +144 -0
  53. package/src/main/lib/rateLimitWindow.cjs +62 -0
  54. package/src/main/lib/rcaReport.cjs +18 -3
  55. package/src/main/lib/reaperHelpers.cjs +169 -3
  56. package/src/main/lib/scheduleJobTransitions.cjs +16 -3
  57. package/src/main/lib/telemetrySettings.cjs +171 -0
  58. package/src/main/runVerify.cjs +71 -3
  59. package/src/main/scheduler.cjs +768 -34
@@ -0,0 +1,58 @@
1
+ /**
2
+ * runVerify-blocked-by-foreign-wip.test.cjs — scanSentinel's new
3
+ * BLOCKED_BY_FOREIGN_WIP token and the scanForeignWipPathsClaim evidence-line
4
+ * scanner (the executor-facing half of "give the executor a first-class
5
+ * verdict for a sibling job's in-flight file, but validate the claim").
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect } from 'vitest';
13
+ const { scanSentinel, scanForeignWipPathsClaim } = require('../runVerify.cjs');
14
+
15
+ function resultEventFor(text) {
16
+ return { kind: 'result', seq: 0, subtype: 'success', resultText: text };
17
+ }
18
+
19
+ test('scanSentinel: recognizes BLOCKED_BY_FOREIGN_WIP alongside PASS/FAIL', () => {
20
+ expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: PASS'), [])).toBe('pass');
21
+ expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: FAIL bad thing'), [])).toBe('fail');
22
+ expect(scanSentinel(resultEventFor('SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP'), [])).toBe('blocked_by_foreign_wip');
23
+ });
24
+
25
+ test('scanSentinel: BLOCKED_BY_FOREIGN_WIP only matches at line start, like PASS/FAIL', () => {
26
+ expect(scanSentinel(resultEventFor('I saw a SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP mention mid-sentence'), [])).toBe(null);
27
+ });
28
+
29
+ test('scanSentinel: falls back to the last tool_result content when no resultEvent matches', () => {
30
+ const events = [
31
+ { kind: 'tool_result', seq: 1, toolUseId: 'a', content: 'nothing here' },
32
+ { kind: 'tool_result', seq: 2, toolUseId: 'b', content: 'SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP\nFOREIGN_WIP_PATHS: src/foo.ts' },
33
+ ];
34
+ expect(scanSentinel(null, events)).toBe('blocked_by_foreign_wip');
35
+ });
36
+
37
+ test('scanForeignWipPathsClaim: extracts a comma-separated FOREIGN_WIP_PATHS line from resultEvent', () => {
38
+ const text = 'SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP\nFOREIGN_WIP_PATHS: src/foo.ts, src/bar.ts';
39
+ expect(scanForeignWipPathsClaim(resultEventFor(text), [])).toEqual(['src/foo.ts', 'src/bar.ts']);
40
+ });
41
+
42
+ test('scanForeignWipPathsClaim: dedupes and trims whitespace', () => {
43
+ const text = 'FOREIGN_WIP_PATHS: src/foo.ts ,src/foo.ts, src/bar.ts ';
44
+ expect(scanForeignWipPathsClaim(resultEventFor(text), [])).toEqual(['src/foo.ts', 'src/bar.ts']);
45
+ });
46
+
47
+ test('scanForeignWipPathsClaim: returns [] when no FOREIGN_WIP_PATHS line is present', () => {
48
+ expect(scanForeignWipPathsClaim(resultEventFor('SCHEDULER_VERDICT: FAIL nope'), [])).toEqual([]);
49
+ expect(scanForeignWipPathsClaim(null, [])).toEqual([]);
50
+ });
51
+
52
+ test('scanForeignWipPathsClaim: falls back to the last tool_result content', () => {
53
+ const events = [
54
+ { kind: 'tool_result', seq: 1, toolUseId: 'a', content: 'FOREIGN_WIP_PATHS: src/stale.ts' },
55
+ { kind: 'tool_result', seq: 2, toolUseId: 'b', content: 'FOREIGN_WIP_PATHS: src/latest.ts' },
56
+ ];
57
+ expect(scanForeignWipPathsClaim(null, events)).toEqual(['src/latest.ts']);
58
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * runVerify-policy-denial.test.cjs — a PreToolUse hook denial (`Blocked: ...`
3
+ * tool_result) must not downgrade an otherwise-clean run, but a genuine
4
+ * late-run failure must still downgrade normally.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/runVerify-policy-denial.test.cjs
7
+ */
8
+
9
+ import { test } from 'vitest';
10
+ const assert = require('node:assert/strict');
11
+ const os = require('node:os');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const { verifyRun } = require('../runVerify.cjs');
15
+
16
+ function makeTmpDir() {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-policy-denial-test-'));
18
+ }
19
+
20
+ function rmdir(dir) {
21
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
22
+ }
23
+
24
+ function writeLog(dir, slug, events) {
25
+ const lines = ['[scheduler] starting ' + slug + ' at 2026-09-02T00:00:00.000Z'];
26
+ for (const ev of events) lines.push(JSON.stringify(ev));
27
+ lines.push('[scheduler] exit code=0 (raw code=0 signal=null) duration=47s');
28
+ fs.writeFileSync(path.join(dir, `${slug}.log`), lines.join('\n') + '\n');
29
+ }
30
+
31
+ function writePrd(dir, slug, body) {
32
+ const text = `---\ntitle: Test PRD\ncwd: /tmp\nestimateMinutes: 30\n---\n${body}\n`;
33
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
34
+ return path.join(dir, `${slug}.md`);
35
+ }
36
+
37
+ test('PreToolUse policy denial (Blocked: ...) in final 20% → clean, surfaced as annotation', async () => {
38
+ const tmp = makeTmpDir();
39
+ try {
40
+ const slug = '1106-policy-denial-exempt';
41
+ const events = [];
42
+ for (let k = 0; k < 8; k++) {
43
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
44
+ { type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
45
+ events.push({ type: 'user', message: { role: 'user', content: [
46
+ { type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
47
+ }
48
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
49
+ { type: 'tool_use', id: 'tgit', name: 'Bash', input: { command: 'git stash', description: 'stash before checkout' } }] } });
50
+ events.push({ type: 'user', message: { role: 'user', content: [
51
+ { type: 'tool_result', tool_use_id: 'tgit',
52
+ content: 'Blocked: `stash` in what this hook believes is a SHARED working tree (/home/bilko/Projects/session-manager).',
53
+ is_error: true }] } });
54
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' });
55
+
56
+ writeLog(tmp, slug, events);
57
+ const prdPath = writePrd(tmp, slug, '# Policy denial exempt');
58
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
59
+ assert.equal(verdict.verdict, 'clean', `policy denial must not flag, got ${verdict.verdict}: ${verdict.reason}`);
60
+ assert.ok(
61
+ Array.isArray(verdict.annotations) && verdict.annotations.some((a) => a.verdict === 'policy_denial'),
62
+ `expected a policy_denial annotation, got ${JSON.stringify(verdict.annotations)}`,
63
+ );
64
+ } finally { rmdir(tmp); }
65
+ });
66
+
67
+ test('genuine late-run failure (not a policy denial) still downgrades to transcript_errors', async () => {
68
+ const tmp = makeTmpDir();
69
+ try {
70
+ const slug = '1106-genuine-failure-still-flags';
71
+ const events = [];
72
+ for (let k = 0; k < 8; k++) {
73
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
74
+ { type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
75
+ events.push({ type: 'user', message: { role: 'user', content: [
76
+ { type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
77
+ }
78
+ events.push({ type: 'assistant', message: { role: 'assistant', content: [
79
+ { type: 'tool_use', id: 'ttest', name: 'Bash', input: { command: 'npm test', description: 'run tests' } }] } });
80
+ events.push({ type: 'user', message: { role: 'user', content: [
81
+ { type: 'tool_result', tool_use_id: 'ttest', content: 'FAILED: 1 test failed', is_error: true }] } });
82
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
83
+
84
+ writeLog(tmp, slug, events);
85
+ const prdPath = writePrd(tmp, slug, '# Genuine failure still flags');
86
+ const verdict = await verifyRun({ runDir: tmp, prdPath, queueEntry: { slug, status: 'running' }, allJobs: [], committedDuringRun: true });
87
+ assert.equal(verdict.verdict, 'transcript_errors', `genuine failure must still flag, got ${verdict.verdict}: ${verdict.reason}`);
88
+ } finally { rmdir(tmp); }
89
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * scheduler-already-satisfied-on-main.test.cjs — PRD 1136.
3
+ *
4
+ * Two live rows (1133-reaper-must-verify-integration-before-completed,
5
+ * 1134-land-stranded-sm-job-branches) were false-negatived on 2026-09-06:
6
+ * their work had already landed on main before their runs dispatched, so
7
+ * each run exited 0, made no commit, and left a clean tree — the exact
8
+ * 'silent_no_op' shape commitGuardVerdict parks as needs_review ("finish
9
+ * protocol incomplete"). Both then auto-minted a redundant `-fix-` child
10
+ * queued to re-do already-shipped work.
11
+ *
12
+ * This exercises the full finalize decision chain — commitGuardVerdict
13
+ * (unchanged) feeding resolveCommitGuardOutcome (new) — entirely at the
14
+ * pure-function level, per standards.md's TDD guidance and the PRD's own
15
+ * implementation note to prefer a pure isAlreadySatisfiedOnMain-style helper
16
+ * over driving the whole spawnJob pipeline.
17
+ *
18
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ import { test, expect } from 'vitest';
24
+ const { commitGuardVerdict, selectAutoFixTargets } = require('../scheduler.cjs');
25
+ const { resolveCommitGuardOutcome, isAlreadySatisfiedOnMain } = require('../lib/reaperHelpers.cjs');
26
+
27
+ const noSiblingOnDisk = () => false;
28
+
29
+ // AC 1: exit 0 / no commit / clean tree, with a commit on main newer than
30
+ // queuedAt touching the PRD's declared paths → completed, naming the sha.
31
+ test('silent_no_op + a satisfying commit on main → completed-shaped verdict naming the sha, not needs_review', () => {
32
+ const guardVerdict = commitGuardVerdict({
33
+ newlyDirty: [],
34
+ siblingRunning: false,
35
+ jobSelfCommitted: false,
36
+ legitimateNoOp: false,
37
+ isFixPlanJob: false,
38
+ verifyResult: null,
39
+ });
40
+ expect(guardVerdict.verdict).toBe('silent_no_op'); // sanity: this is the shape 1133/1134 hit
41
+
42
+ const outcome = resolveCommitGuardOutcome(guardVerdict, ['5de4134abc123']);
43
+ expect(outcome.verdict).toBe('already_satisfied_on_main');
44
+ expect(outcome.verdict).not.toBe('needs_review');
45
+ expect(outcome.satisfyingSha).toBe('5de4134abc123');
46
+ expect(outcome.reason).toMatch(/5de4134abc123/);
47
+ });
48
+
49
+ // AC 2: the inverse — same clean-tree/no-commit shape, but NO commit on main
50
+ // satisfies it → still parks as the existing 'finish protocol incomplete'
51
+ // verdict. The gate must not be weakened into always-passing.
52
+ test('silent_no_op + NO satisfying commit on main → unchanged silent_no_op verdict, still parks needs_review', () => {
53
+ const guardVerdict = commitGuardVerdict({
54
+ newlyDirty: [],
55
+ siblingRunning: false,
56
+ jobSelfCommitted: false,
57
+ legitimateNoOp: false,
58
+ isFixPlanJob: false,
59
+ verifyResult: null,
60
+ });
61
+
62
+ const outcome = resolveCommitGuardOutcome(guardVerdict, []);
63
+ expect(outcome).toBe(guardVerdict); // untouched — same object, not just same shape
64
+ expect(outcome.verdict).toBe('silent_no_op');
65
+ expect(outcome.downgradeTo).toBe('needs_review');
66
+ expect(outcome.reason).toMatch(/finish protocol incomplete/);
67
+ });
68
+
69
+ test('resolveCommitGuardOutcome never touches the uncommitted_changes shape, satisfied or not', () => {
70
+ const guardVerdict = commitGuardVerdict({
71
+ newlyDirty: ['src/main/someFeature.cjs'],
72
+ siblingRunning: false,
73
+ jobSelfCommitted: false,
74
+ legitimateNoOp: false,
75
+ verifyResult: null,
76
+ });
77
+ expect(guardVerdict.verdict).toBe('uncommitted_changes');
78
+
79
+ const outcome = resolveCommitGuardOutcome(guardVerdict, ['5de4134abc123']);
80
+ expect(outcome).toBe(guardVerdict);
81
+ expect(outcome.verdict).toBe('uncommitted_changes');
82
+ });
83
+
84
+ test('resolveCommitGuardOutcome: null guardVerdict (no violation) passes through as null', () => {
85
+ expect(resolveCommitGuardOutcome(null, ['5de4134'])).toBeNull();
86
+ });
87
+
88
+ // AC 3: a job terminalised via the already-satisfied path never becomes a
89
+ // fix-plan target — selectAutoFixTargets only ever considers needs_review
90
+ // rows, and the already-satisfied path never produces one.
91
+ test('a job whose status is completed (the already-satisfied outcome) is never selected for an auto-fix child', () => {
92
+ const jobs = [{
93
+ slug: '1133-reaper-must-verify-integration-before-completed',
94
+ status: 'completed',
95
+ runId: '2026-09-06T13-03-00-000Z',
96
+ verifierVerdict: 'already_satisfied_on_main',
97
+ }];
98
+ const targets = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
99
+ expect(targets).toEqual([]);
100
+ });
101
+
102
+ test('isAlreadySatisfiedOnMain picks the newest (first) satisfying commit when several land', () => {
103
+ const verdict = isAlreadySatisfiedOnMain(['newest111', 'older222']);
104
+ expect(verdict.sha).toBe('newest111');
105
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * scheduler-blocked-by-foreign-wip.test.cjs — validateForeignWipBlockClaim
3
+ * (the manifest-membership check that stops an executor from laundering a
4
+ * real regression into a BLOCKED_BY_FOREIGN_WIP verdict) and
5
+ * requeueForeignWipBlockedJobs (reconcile()'s auto-clear-and-resume pass).
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect } from 'vitest';
13
+ const {
14
+ validateForeignWipBlockClaim,
15
+ requeueForeignWipBlockedJobs,
16
+ FOREIGN_WIP_BLOCK_STREAK_LIMIT,
17
+ } = require('../scheduler.cjs');
18
+
19
+ // ─── validateForeignWipBlockClaim ──────────────────────────────────────────
20
+
21
+ test('validateForeignWipBlockClaim: ok when every claimed path is in preRunDirtyPaths', () => {
22
+ const job = { preRunDirtyPaths: ['src/a.ts', 'src/b.ts'] };
23
+ const v = validateForeignWipBlockClaim(['src/a.ts'], job);
24
+ expect(v.ok).toBe(true);
25
+ expect(v.validPaths).toEqual(['src/a.ts']);
26
+ expect(v.invalidPaths).toEqual([]);
27
+ });
28
+
29
+ test('validateForeignWipBlockClaim: ok when every claimed path is in carriedPaths', () => {
30
+ const job = { carriedPaths: ['config/settings.json'] };
31
+ const v = validateForeignWipBlockClaim(['config/settings.json'], job);
32
+ expect(v.ok).toBe(true);
33
+ expect(v.validPaths).toEqual(['config/settings.json']);
34
+ });
35
+
36
+ test('validateForeignWipBlockClaim: rejects an unlisted path — the launder-a-regression case', () => {
37
+ // The job's real, disclosed foreign-WIP manifest never mentioned this path
38
+ // — an executor naming it anyway must not get away with claiming a block.
39
+ const job = { preRunDirtyPaths: ['src/a.ts'] };
40
+ const v = validateForeignWipBlockClaim(['src/a.ts', 'src/my-own-broken-file.ts'], job);
41
+ expect(v.ok).toBe(false);
42
+ expect(v.validPaths).toEqual(['src/a.ts']);
43
+ expect(v.invalidPaths).toEqual(['src/my-own-broken-file.ts']);
44
+ });
45
+
46
+ test('validateForeignWipBlockClaim: rejects a claim naming ONLY an unlisted path', () => {
47
+ const job = { preRunDirtyPaths: ['src/a.ts'] };
48
+ const v = validateForeignWipBlockClaim(['src/totally-unrelated.ts'], job);
49
+ expect(v.ok).toBe(false);
50
+ expect(v.invalidPaths).toEqual(['src/totally-unrelated.ts']);
51
+ });
52
+
53
+ test('validateForeignWipBlockClaim: rejects an empty claim (no evidence at all)', () => {
54
+ const job = { preRunDirtyPaths: ['src/a.ts'] };
55
+ expect(validateForeignWipBlockClaim([], job).ok).toBe(false);
56
+ expect(validateForeignWipBlockClaim(null, job).ok).toBe(false);
57
+ });
58
+
59
+ test('validateForeignWipBlockClaim: rejects against an empty/missing manifest', () => {
60
+ expect(validateForeignWipBlockClaim(['src/a.ts'], {}).ok).toBe(false);
61
+ expect(validateForeignWipBlockClaim(['src/a.ts'], null).ok).toBe(false);
62
+ });
63
+
64
+ // ─── requeueForeignWipBlockedJobs ──────────────────────────────────────────
65
+
66
+ test('requeueForeignWipBlockedJobs: promotes skipped/blocked job to pending once its paths are clean', async () => {
67
+ const job = {
68
+ slug: '10-foo', status: 'skipped', cwd: '/repo', blockedByForeignWip: true,
69
+ foreignWipBlockedPaths: ['src/a.ts'], statusHistory: [],
70
+ };
71
+ await requeueForeignWipBlockedJobs([job], { getDirtyPaths: async () => [] });
72
+ expect(job.status).toBe('pending');
73
+ expect(job.blockedByForeignWip).toBeUndefined();
74
+ expect(job.foreignWipBlockedPaths).toBeUndefined();
75
+ });
76
+
77
+ test('requeueForeignWipBlockedJobs: leaves the job parked while any blocked path is still dirty', async () => {
78
+ const job = {
79
+ slug: '10-foo', status: 'skipped', cwd: '/repo', blockedByForeignWip: true,
80
+ foreignWipBlockedPaths: ['src/a.ts', 'src/b.ts'], statusHistory: [],
81
+ };
82
+ await requeueForeignWipBlockedJobs([job], { getDirtyPaths: async () => ['src/a.ts'] });
83
+ expect(job.status).toBe('skipped');
84
+ expect(job.blockedByForeignWip).toBe(true);
85
+ });
86
+
87
+ test('requeueForeignWipBlockedJobs: ignores jobs not in the blocked-skipped shape', async () => {
88
+ const skippedNotBlocked = { slug: '1', status: 'skipped', cwd: '/repo', statusHistory: [] };
89
+ const runningBlocked = { slug: '2', status: 'running', cwd: '/repo', blockedByForeignWip: true, foreignWipBlockedPaths: ['x'], statusHistory: [] };
90
+ const getDirtyPaths = async () => [];
91
+ await requeueForeignWipBlockedJobs([skippedNotBlocked, runningBlocked], { getDirtyPaths });
92
+ expect(skippedNotBlocked.status).toBe('skipped');
93
+ expect(runningBlocked.status).toBe('running');
94
+ });
95
+
96
+ test('requeueForeignWipBlockedJobs: leaves the row alone when the cwd is not a git repo (getDirtyPaths → null)', async () => {
97
+ const job = {
98
+ slug: '10-foo', status: 'skipped', cwd: '/not-a-repo', blockedByForeignWip: true,
99
+ foreignWipBlockedPaths: ['src/a.ts'], statusHistory: [],
100
+ };
101
+ await requeueForeignWipBlockedJobs([job], { getDirtyPaths: async () => null });
102
+ expect(job.status).toBe('skipped');
103
+ });
104
+
105
+ test('FOREIGN_WIP_BLOCK_STREAK_LIMIT is 3', () => {
106
+ expect(FOREIGN_WIP_BLOCK_STREAK_LIMIT).toBe(3);
107
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * scheduler-finalize-dispatch-guards.test.cjs — regression cover for the
3
+ * 2026-09-06 incident: PRD 1133 (reaper integration check) shipped correctly
4
+ * in commit 5de4134, but its finalize mutate's silent early-return dropped
5
+ * three subsequent no-op re-verifications of the same already-shipped slug
6
+ * with no audit trail, leaving the row stuck 'pending' forever and causing
7
+ * the dispatcher to re-fire it three more times.
8
+ *
9
+ * Covers the three pure decision helpers extracted for this fix:
10
+ * - evaluateFinalizeDrop: never silently discard a finalize (row missing,
11
+ * or row moved off 'running' by a concurrent cancel) — always says so,
12
+ * and still lets a genuinely-landed commit be stamped as a durable fact.
13
+ * - evaluateDispatchSidecarReconcile: refuse to re-dispatch a pending row
14
+ * whose newest run sidecar already shows a completed-equivalent outcome
15
+ * for THIS queueing episode.
16
+ * - isQueueRowRegression: detect (never repair) a running->pending change
17
+ * whose statusHistory got shorter — evidence of lost state, not a real
18
+ * transition.
19
+ *
20
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs, matching
21
+ * scheduler-reap-dead-running-jobs.test.cjs's pattern — every path this
22
+ * module touches is baked into a top-level const from os.homedir() at
23
+ * require time.
24
+ *
25
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const assert = require('node:assert/strict');
31
+ const fs = require('node:fs');
32
+ const os = require('node:os');
33
+ const path = require('node:path');
34
+
35
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-finalize-dispatch-guards-test-'));
36
+ process.env.HOME = tmpHome;
37
+
38
+ const {
39
+ evaluateFinalizeDrop,
40
+ evaluateDispatchSidecarReconcile,
41
+ isQueueRowRegression,
42
+ } = require('../scheduler.cjs');
43
+
44
+ describe('evaluateFinalizeDrop', () => {
45
+ it('drops loudly (row-missing) with nothing to stamp when the row vanished from s.jobs', () => {
46
+ const decision = evaluateFinalizeDrop({
47
+ rowExists: false,
48
+ rowStatus: null,
49
+ rowRunId: null,
50
+ rowLandedCommit: null,
51
+ runId: 'run-2',
52
+ landedCommit: 'deadbeef',
53
+ });
54
+ assert.equal(decision.drop, true);
55
+ assert.equal(decision.reason, 'row-missing');
56
+ assert.equal(decision.stampLandedCommit, null);
57
+ });
58
+
59
+ it('drops the status change (row-not-running) but stamps landedCommit when this run owns the row', () => {
60
+ const decision = evaluateFinalizeDrop({
61
+ rowExists: true,
62
+ rowStatus: 'failed', // e.g. remote.cancelJob already finalized it (PRD 1024)
63
+ rowRunId: 'run-2',
64
+ rowLandedCommit: null,
65
+ runId: 'run-2',
66
+ landedCommit: 'deadbeef',
67
+ });
68
+ assert.equal(decision.drop, true);
69
+ assert.equal(decision.reason, 'row-not-running');
70
+ assert.equal(decision.stampLandedCommit, 'deadbeef');
71
+ });
72
+
73
+ it('stamps landedCommit even when the row carries a different runId, as long as it has none of its own', () => {
74
+ const decision = evaluateFinalizeDrop({
75
+ rowExists: true,
76
+ rowStatus: 'pending',
77
+ rowRunId: 'some-other-run',
78
+ rowLandedCommit: null,
79
+ runId: 'run-2',
80
+ landedCommit: 'deadbeef',
81
+ });
82
+ assert.equal(decision.drop, true);
83
+ assert.equal(decision.stampLandedCommit, 'deadbeef');
84
+ });
85
+
86
+ it('never overwrites a row that already carries its own newer landedCommit from a different run', () => {
87
+ const decision = evaluateFinalizeDrop({
88
+ rowExists: true,
89
+ rowStatus: 'failed',
90
+ rowRunId: 'some-other-run',
91
+ rowLandedCommit: 'already-there',
92
+ runId: 'run-2',
93
+ landedCommit: 'deadbeef',
94
+ });
95
+ assert.equal(decision.drop, true);
96
+ assert.equal(decision.stampLandedCommit, null);
97
+ });
98
+
99
+ it('never re-legalizes a cancelled (failed) row back to completed — the PRD-1024 protection', () => {
100
+ // The finalize call site never even reaches transitionJob for a dropped
101
+ // finalize; this test documents the contract evaluateFinalizeDrop enforces:
102
+ // drop:true means the caller MUST NOT change job.status.
103
+ const decision = evaluateFinalizeDrop({
104
+ rowExists: true,
105
+ rowStatus: 'failed',
106
+ rowRunId: 'run-2',
107
+ rowLandedCommit: null,
108
+ runId: 'run-2',
109
+ landedCommit: 'deadbeef',
110
+ });
111
+ assert.equal(decision.drop, true);
112
+ });
113
+
114
+ it('does not drop when the row is still running — the normal finalize path', () => {
115
+ const decision = evaluateFinalizeDrop({
116
+ rowExists: true,
117
+ rowStatus: 'running',
118
+ rowRunId: 'run-2',
119
+ rowLandedCommit: null,
120
+ runId: 'run-2',
121
+ landedCommit: 'deadbeef',
122
+ });
123
+ assert.equal(decision.drop, false);
124
+ assert.equal(decision.stampLandedCommit, null);
125
+ });
126
+ });
127
+
128
+ describe('evaluateDispatchSidecarReconcile', () => {
129
+ const baseRow = {
130
+ rowStatus: 'pending',
131
+ rowRunId: null,
132
+ statusHistory: [
133
+ { from: 'running', to: 'pending', at: '2026-09-06T19:30:00.000Z' },
134
+ ],
135
+ queuedAt: '2026-09-06T17:02:07.815Z',
136
+ };
137
+
138
+ it('skips dispatch when a prior run of this slug already completed after the last pending transition', () => {
139
+ const decision = evaluateDispatchSidecarReconcile({
140
+ ...baseRow,
141
+ outcome: { status: 'completed', runId: '2026-09-06T19-26-54-681Z', finishedAt: '2026-09-06T19:35:00.000Z' },
142
+ });
143
+ assert.equal(decision.skip, true);
144
+ assert.equal(decision.runId, '2026-09-06T19-26-54-681Z');
145
+ });
146
+
147
+ it('proceeds with dispatch when a deliberate human re-queue postdates the earlier completion', () => {
148
+ // The re-queue's own pending transition (19:30) is AFTER the completed
149
+ // sidecar's finishedAt (18:00) — an OLDER episode's completion, not this one.
150
+ const decision = evaluateDispatchSidecarReconcile({
151
+ ...baseRow,
152
+ outcome: { status: 'completed', runId: 'old-run', finishedAt: '2026-09-06T18:00:00.000Z' },
153
+ });
154
+ assert.equal(decision.skip, false);
155
+ });
156
+
157
+ it('proceeds with dispatch when there is no sidecar at all (guard is a no-op)', () => {
158
+ const decision = evaluateDispatchSidecarReconcile({ ...baseRow, outcome: null });
159
+ assert.equal(decision.skip, false);
160
+ });
161
+
162
+ it('proceeds with dispatch when the sidecar belongs to this row\'s own current runId', () => {
163
+ const decision = evaluateDispatchSidecarReconcile({
164
+ ...baseRow,
165
+ rowRunId: 'run-x',
166
+ outcome: { status: 'completed', runId: 'run-x', finishedAt: '2026-09-06T19:35:00.000Z' },
167
+ });
168
+ assert.equal(decision.skip, false);
169
+ });
170
+
171
+ it('proceeds with dispatch when the row is not pending (e.g. needs_review resume-recovery)', () => {
172
+ const decision = evaluateDispatchSidecarReconcile({
173
+ ...baseRow,
174
+ rowStatus: 'needs_review',
175
+ outcome: { status: 'completed', runId: 'some-run', finishedAt: '2026-09-06T19:35:00.000Z' },
176
+ });
177
+ assert.equal(decision.skip, false);
178
+ });
179
+
180
+ it('proceeds with dispatch when the newest sidecar reports a failed (not completed-equivalent) outcome', () => {
181
+ const decision = evaluateDispatchSidecarReconcile({
182
+ ...baseRow,
183
+ outcome: { status: 'failed', runId: 'some-run', finishedAt: '2026-09-06T19:35:00.000Z' },
184
+ });
185
+ assert.equal(decision.skip, false);
186
+ });
187
+
188
+ it('falls back to queuedAt when the row has never been reset (no pending statusHistory entry)', () => {
189
+ const decision = evaluateDispatchSidecarReconcile({
190
+ rowStatus: 'pending',
191
+ rowRunId: null,
192
+ statusHistory: [],
193
+ queuedAt: '2026-09-06T17:02:07.815Z',
194
+ outcome: { status: 'completed', runId: 'some-run', finishedAt: '2026-09-06T19:35:00.000Z' },
195
+ });
196
+ assert.equal(decision.skip, true);
197
+ });
198
+ });
199
+
200
+ describe('isQueueRowRegression', () => {
201
+ it('flags a running->pending change whose statusHistory got shorter', () => {
202
+ assert.equal(
203
+ isQueueRowRegression({ statusBefore: 'running', statusAfter: 'pending', historyLenBefore: 5, historyLenAfter: 3 }),
204
+ true,
205
+ );
206
+ });
207
+
208
+ it('does not flag a running->pending change with a normal appended (equal or longer) history', () => {
209
+ assert.equal(
210
+ isQueueRowRegression({ statusBefore: 'running', statusAfter: 'pending', historyLenBefore: 5, historyLenAfter: 6 }),
211
+ false,
212
+ );
213
+ assert.equal(
214
+ isQueueRowRegression({ statusBefore: 'running', statusAfter: 'pending', historyLenBefore: 20, historyLenAfter: 20 }),
215
+ false,
216
+ );
217
+ });
218
+
219
+ it('does not flag a shortened history on transitions other than running->pending', () => {
220
+ assert.equal(
221
+ isQueueRowRegression({ statusBefore: 'pending', statusAfter: 'completed', historyLenBefore: 5, historyLenAfter: 3 }),
222
+ false,
223
+ );
224
+ assert.equal(
225
+ isQueueRowRegression({ statusBefore: 'running', statusAfter: 'completed', historyLenBefore: 5, historyLenAfter: 3 }),
226
+ false,
227
+ );
228
+ });
229
+ });