claude-code-session-manager 0.79.0 → 0.81.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 (82) hide show
  1. package/dist/assets/{AgentLibrary-COtVRqBR.js → AgentLibrary-psZYVM2w.js} +1 -1
  2. package/dist/assets/{DataModel-CSEKw_OR.js → DataModel-BMied5pg.js} +1 -1
  3. package/dist/assets/{History-CHHovrAO.js → History-BFC0oaKc.js} +1 -1
  4. package/dist/assets/{Hooks-BZU6C3x6.js → Hooks-CTLfO9G8.js} +1 -1
  5. package/dist/assets/{HostBilko-CqTUoq37.js → HostBilko-D4I0Cpwn.js} +1 -1
  6. package/dist/assets/{Library-BtxdyTLz.js → Library-BTzS8KsS.js} +1 -1
  7. package/dist/assets/{ListDetail-qZc7Zm-6.js → ListDetail-CuRmT008.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BHe_4fJR.js → MarkdownEditor-BgQGtvWo.js} +1 -1
  9. package/dist/assets/{McpServers-7Z98HLNo.js → McpServers-DiVQUK57.js} +1 -1
  10. package/dist/assets/{Memory-CR72KoyP.js → Memory-DSu15JmL.js} +1 -1
  11. package/dist/assets/{Panel-pL6H3dpQ.js → Panel-CD5wxGSR.js} +1 -1
  12. package/dist/assets/{Permissions-CWSWjyXM.js → Permissions-C_tAE6yJ.js} +1 -1
  13. package/dist/assets/{Plugins-CN6lX2lt.js → Plugins-kwI7W-eK.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BXSXwIsk.js → ProvenanceBadge-CQceOgsH.js} +1 -1
  15. package/dist/assets/{SaveBar-BlB5TGpR.js → SaveBar-BDk5e3Pp.js} +1 -1
  16. package/dist/assets/{Scheduler-DRciWUmR.js → Scheduler-DRnvEYzv.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-kFrXtjpr.js → ScopeSwitcher-CeifaOlq.js} +1 -1
  18. package/dist/assets/{Settings-BXuyf4lJ.js → Settings-BWQ1Utop.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-Dfacb0PE.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
  20. package/dist/assets/{Skills-CHqcpiyt.js → Skills-BLwFB0E4.js} +1 -1
  21. package/dist/assets/{SystemPrompt-fxXm0BZr.js → SystemPrompt-DC4ZTArJ.js} +1 -1
  22. package/dist/assets/{TagLibrary-DOz65ZTz.js → TagLibrary-pMeGfjUb.js} +1 -1
  23. package/dist/assets/{TiptapBody-D0bWx_9o.js → TiptapBody-BHFid2pZ.js} +1 -1
  24. package/dist/assets/{Toggle-C9jBwGSx.js → Toggle-D9eYoZh4.js} +1 -1
  25. package/dist/assets/{index-DPYa6jbM.js → index-CuyM9vAP.js} +5 -5
  26. package/dist/assets/{settingsSchema-BTPw1bR3.js → settingsSchema-DrxC67uZ.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  31. package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
  32. package/src/main/__tests__/prdCreate.test.cjs +133 -8
  33. package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
  34. package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
  35. package/src/main/__tests__/queueHistory.test.cjs +33 -0
  36. package/src/main/__tests__/rcaReport.test.cjs +24 -0
  37. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
  38. package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
  39. package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
  40. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
  41. package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
  42. package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
  43. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
  44. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
  45. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
  46. package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
  47. package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
  48. package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
  49. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
  50. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
  51. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
  52. package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
  53. package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
  54. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
  55. package/src/main/ipcSchemas.cjs +15 -1
  56. package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
  57. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
  58. package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
  59. package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
  60. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
  61. package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
  62. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
  63. package/src/main/lib/branchSweep.cjs +127 -0
  64. package/src/main/lib/depSlugResolve.cjs +72 -0
  65. package/src/main/lib/epicWorktreeMerge.cjs +3 -3
  66. package/src/main/lib/epicWorktreeMint.cjs +17 -5
  67. package/src/main/lib/fixPlanSlug.cjs +62 -0
  68. package/src/main/lib/gitWorktree.cjs +117 -12
  69. package/src/main/lib/landedSinceRun.cjs +41 -1
  70. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  71. package/src/main/lib/prdCreate.cjs +84 -5
  72. package/src/main/lib/prdFrontmatter.cjs +56 -8
  73. package/src/main/lib/queueHistory.cjs +50 -5
  74. package/src/main/lib/rateLimitWindow.cjs +62 -0
  75. package/src/main/lib/rcaReport.cjs +18 -3
  76. package/src/main/lib/reaperHelpers.cjs +169 -3
  77. package/src/main/lib/scheduleJobTransitions.cjs +28 -5
  78. package/src/main/lib/schedulerBatch.cjs +181 -23
  79. package/src/main/runVerify.cjs +71 -3
  80. package/src/main/scheduler/prdParser.cjs +7 -0
  81. package/src/main/scheduler.cjs +1554 -71
  82. package/src/preload/api.d.ts +8 -0
@@ -10,6 +10,13 @@
10
10
  * coverage in scheduler-autofix-select.test.cjs (node --test) exercises the
11
11
  * same helpers in more granular detail.
12
12
  *
13
+ * PRD 1129 added one sub-shape to case (d): "outcome 'plan' WITH a queue
14
+ * row is neither retried nor annotated" only holds while that queue row
15
+ * (the fix-plan child) is still alive. Once the child itself reaches a
16
+ * terminal, non-completed status, isFixPlanDead makes the parent eligible
17
+ * for exactly one further attempt (stamped autoFixReopened: true), after
18
+ * which it is permanently excluded and annotated again.
19
+ *
13
20
  * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-autofix-outcome.test.cjs
14
21
  */
15
22
 
@@ -17,7 +24,7 @@
17
24
 
18
25
  import { test, expect } from 'vitest';
19
26
  const {
20
- selectAutoFixTargets, isExhaustedAutoFix, isPlanUnqueued, fixSlugFor,
27
+ selectAutoFixTargets, isExhaustedAutoFix, isPlanUnqueued, isFixPlanDead, fixSlugFor,
21
28
  } = require('../scheduler.cjs');
22
29
 
23
30
  const noSiblingOnDisk = () => false;
@@ -82,6 +89,71 @@ test("(d) outcome 'plan' with the fix slug present in the queue is neither retri
82
89
  expect(isAnnotatable(job, queuedSlugs)).toBe(false);
83
90
  });
84
91
 
92
+ // (e) dead fix-plan child: outcome 'plan' whose fix-plan row itself died —
93
+ // the parent is eligible again, exactly once.
94
+ test('(e) a dead fix-plan child makes the parent eligible for one further attempt', () => {
95
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
96
+ const deadChild = { slug: fixSlugFor(job), status: 'needs_review', parallelGroup: job.parallelGroup };
97
+ const jobs = [job, deadChild];
98
+ expect(isFixPlanDead(job, jobs)).toBe(true);
99
+ expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(true);
100
+ expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
101
+ });
102
+
103
+ test('(e) a failed, quarantined, or skipped fix-plan child also counts as dead', () => {
104
+ for (const status of ['failed', 'quarantined', 'skipped']) {
105
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
106
+ const deadChild = { slug: fixSlugFor(job), status, parallelGroup: job.parallelGroup };
107
+ expect(isFixPlanDead(job, [job, deadChild])).toBe(true);
108
+ }
109
+ });
110
+
111
+ test('(e) an already-reopened parent is annotated, never eligible again — even with another dead child', () => {
112
+ const job = makeJob({
113
+ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 1, autoFixReopened: true,
114
+ });
115
+ const deadChild = { slug: fixSlugFor(job), status: 'needs_review', parallelGroup: job.parallelGroup };
116
+ const jobs = [job, deadChild];
117
+ expect(isFixPlanDead(job, jobs)).toBe(false);
118
+ expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
119
+ expect(isExhaustedAutoFix(job)).toBe(true);
120
+ expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(true);
121
+ });
122
+
123
+ test('(e) a reopened parent whose second attempt is still in flight (outcome cleared) is untouched — not re-eligible, not yet annotated', () => {
124
+ const job = makeJob({ autoFixAttempted: true, autoFixRetries: 1, autoFixReopened: true });
125
+ expect(isFixPlanDead(job, [job])).toBe(false);
126
+ expect(selectAutoFixTargets([job], { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
127
+ expect(isExhaustedAutoFix(job)).toBe(false);
128
+ });
129
+
130
+ test('(e) a fix-plan child that is still pending leaves the parent untouched', () => {
131
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
132
+ const pendingChild = { slug: fixSlugFor(job), status: 'pending', parallelGroup: job.parallelGroup };
133
+ const jobs = [job, pendingChild];
134
+ expect(isFixPlanDead(job, jobs)).toBe(false);
135
+ expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
136
+ expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
137
+ });
138
+
139
+ test('(e) a fix-plan child that is still running leaves the parent untouched', () => {
140
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
141
+ const runningChild = { slug: fixSlugFor(job), status: 'running', parallelGroup: job.parallelGroup };
142
+ const jobs = [job, runningChild];
143
+ expect(isFixPlanDead(job, jobs)).toBe(false);
144
+ expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
145
+ expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
146
+ });
147
+
148
+ test('(e) a fix-plan child that completed leaves the parent untouched (unchanged from today)', () => {
149
+ const job = makeJob({ autoFixAttempted: true, autoFixOutcome: 'plan', autoFixRetries: 0 });
150
+ const completedChild = { slug: fixSlugFor(job), status: 'completed', parallelGroup: job.parallelGroup };
151
+ const jobs = [job, completedChild];
152
+ expect(isFixPlanDead(job, jobs)).toBe(false);
153
+ expect(selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk }).some((j) => j.slug === job.slug)).toBe(false);
154
+ expect(isAnnotatable(job, new Set(jobs.map((j) => j.slug)))).toBe(false);
155
+ });
156
+
85
157
  // (invariant) every needs_review job is either an auto-fix target or
86
158
  // annotatable (would receive a non-null verifierVerdict) — never both false.
87
159
  test('invariant: fresh, retry-eligible, exhausted, and plan-unqueued jobs are always selected or annotatable', () => {
@@ -308,6 +308,23 @@ test('isFixPlanBeyondDepthCap: fix-plan at depth 1 is not capped, depth 2 is', (
308
308
  assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1), true);
309
309
  });
310
310
 
311
+ // PRD 1131: classification is now provenance-aware (isFixPlan), not slug-only.
312
+ // A legacy row with no `isFixPlan` argument at all still falls back to the
313
+ // slug-only heuristic above (no re-classification of live history).
314
+
315
+ test('isFixPlanBeyondDepthCap: a scheduler-authored fix plan (isFixPlan:true) is still classified as one and capped at the same depth', () => {
316
+ assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH, true), false);
317
+ assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', MAX_INVESTIGATION_DEPTH + 1, true), true);
318
+ });
319
+
320
+ test('isFixPlanBeyondDepthCap: a human-authored PRD (isFixPlan:false) whose slug merely matches "fix-" is never capped, even with no investigationDepth (PRD 1126 regression)', () => {
321
+ assert.strictEqual(isFixPlanBeyondDepthCap('126-fix-plan-death-reopens-parent', undefined, false), false);
322
+ });
323
+
324
+ test('isFixPlanBeyondDepthCap: a legacy row (isFixPlan argument omitted/undefined) with a fix- slug and no depth field is still treated as a fix plan via the slug fallback', () => {
325
+ assert.strictEqual(isFixPlanBeyondDepthCap('05-fix-foo', undefined, undefined), true);
326
+ });
327
+
311
328
  test('healTargetForFix: depth-2 fix-of-a-fix slug resolves back to the depth-1 fix job (521-fix-recorder-export-footer-redesign)', () => {
312
329
  // Depth-1 fix job: originally authored to fix job 521, itself in needs_review.
313
330
  const depth1FixJob = {
@@ -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
+ });
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { test, expect } from 'vitest';
26
26
  const { commitGuardVerdict } = require('../scheduler.cjs');
27
+ const { resolveIsFixPlan } = require('../lib/fixPlanSlug.cjs');
27
28
 
28
29
  test('commitGuardVerdict: legitimate no-op verdict (pass_no_commit_already_shipped) + concurrent dirt → no flag (655 incident)', () => {
29
30
  const verifyResult = {
@@ -102,6 +103,25 @@ test('commitGuardVerdict: fix-plan job (523-fix-bounded-fix-plan-retry incident)
102
103
  expect(verdict).toBeNull();
103
104
  });
104
105
 
106
+ test('commitGuardVerdict: a zero-edit run of a human-authored PRD whose slug merely starts with "fix-" still yields silent_no_op (PRD 1131 — not exempted by name alone)', () => {
107
+ // Mirrors PRD 1126: authored via scheduler_create_prd (isFixPlan stamped
108
+ // false at reconcile time), slug happens to read "fix-...". Feeding the
109
+ // job's own persisted isFixPlan through resolveIsFixPlan (the same call
110
+ // spawnJob makes) must NOT exempt this zero-edit run from the guard.
111
+ const job = { slug: '126-fix-plan-death-reopens-parent', isFixPlan: false };
112
+ const verdict = commitGuardVerdict({
113
+ newlyDirty: [],
114
+ siblingRunning: false,
115
+ jobSelfCommitted: false,
116
+ legitimateNoOp: false,
117
+ isFixPlanJob: resolveIsFixPlan(job.slug, job.isFixPlan),
118
+ verifyResult: null,
119
+ });
120
+ expect(verdict).not.toBeNull();
121
+ expect(verdict.verdict).toBe('silent_no_op');
122
+ expect(verdict.downgradeTo).toBe('needs_review');
123
+ });
124
+
105
125
  test('commitGuardVerdict: fix-plan job that DID leave real dirt behind is still flagged (isFixPlanJob only exempts the zero-edit case)', () => {
106
126
  const verdict = commitGuardVerdict({
107
127
  newlyDirty: ['src/main/someFeature.cjs'],
@@ -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
+ });