claude-code-session-manager 0.75.3 → 0.77.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 (164) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
  15. package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
  16. package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
  26. package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +8 -2
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  31. package/scripts/lib/activeSessions.cjs +116 -6
  32. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  33. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  34. package/scripts/render-project-pages.cjs +70 -0
  35. package/scripts/scheduler-mcp-server.cjs +269 -96
  36. package/scripts/validate-project-pages-summary.cjs +62 -0
  37. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  38. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  39. package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
  40. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  41. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  42. package/src/main/__tests__/prdCreate.test.cjs +247 -0
  43. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  44. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  45. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  46. package/src/main/__tests__/projectPages.test.cjs +73 -1
  47. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  48. package/src/main/__tests__/runVerify.test.cjs +94 -0
  49. package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
  50. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  51. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  52. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  54. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  55. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  56. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  57. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  58. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  59. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  60. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  61. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
  62. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  63. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  64. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  65. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  66. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  67. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  68. package/src/main/bilkoHost.cjs +4 -3
  69. package/src/main/chatRunner.cjs +6 -1
  70. package/src/main/config.cjs +25 -33
  71. package/src/main/health.cjs +153 -2
  72. package/src/main/index.cjs +64 -5
  73. package/src/main/ipcSchemas.cjs +69 -1
  74. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  75. package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
  76. package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
  77. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  78. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
  79. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  80. package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
  81. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  82. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  83. package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
  84. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  85. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  86. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  87. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
  88. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  89. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  90. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  91. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  92. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  93. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  94. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  95. package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
  96. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  97. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  98. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  99. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  100. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  101. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  102. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  103. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  104. package/src/main/lib/activeIndexMerge.cjs +15 -0
  105. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  106. package/src/main/lib/agentModelResolve.cjs +58 -0
  107. package/src/main/lib/buildTarget.cjs +3 -2
  108. package/src/main/lib/childWithLog.cjs +69 -2
  109. package/src/main/lib/claudeBin.cjs +54 -1
  110. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  111. package/src/main/lib/definitionOfDone.cjs +3 -2
  112. package/src/main/lib/delegationReadiness.cjs +514 -26
  113. package/src/main/lib/ephemeralCwd.cjs +78 -0
  114. package/src/main/lib/epicDelegationStats.cjs +2 -1
  115. package/src/main/lib/epicMint.cjs +17 -1
  116. package/src/main/lib/epicStatusMirror.cjs +95 -0
  117. package/src/main/lib/epicValidationHook.cjs +2 -1
  118. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  119. package/src/main/lib/fixChainDepth.cjs +45 -0
  120. package/src/main/lib/gitWorktree.cjs +520 -21
  121. package/src/main/lib/jobWorktree.cjs +2 -0
  122. package/src/main/lib/landedSinceRun.cjs +55 -0
  123. package/src/main/lib/launchFailure.cjs +357 -0
  124. package/src/main/lib/loadGate.cjs +134 -0
  125. package/src/main/lib/mcpToolCatalog.cjs +370 -0
  126. package/src/main/lib/opsErrorLog.cjs +12 -1
  127. package/src/main/lib/opsOwnership.cjs +106 -0
  128. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  129. package/src/main/lib/prdAgentType.cjs +84 -0
  130. package/src/main/lib/prdCreate.cjs +103 -15
  131. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  132. package/src/main/lib/prdFrontmatter.cjs +17 -3
  133. package/src/main/lib/prdLocations.cjs +13 -6
  134. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  135. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  136. package/src/main/lib/projectRootResolve.cjs +134 -0
  137. package/src/main/lib/promptSessionSchema.cjs +7 -0
  138. package/src/main/lib/queueHealth.cjs +38 -0
  139. package/src/main/lib/queueStore.cjs +40 -7
  140. package/src/main/lib/quietMachineLease.cjs +48 -0
  141. package/src/main/lib/rcaReport.cjs +54 -4
  142. package/src/main/lib/reaperHelpers.cjs +64 -1
  143. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  144. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  145. package/src/main/lib/schedulerBatch.cjs +301 -55
  146. package/src/main/lib/schedulerConfig.cjs +99 -0
  147. package/src/main/projectBrief.cjs +3 -2
  148. package/src/main/projectPages.cjs +162 -3
  149. package/src/main/promptSessionTranscript.cjs +0 -0
  150. package/src/main/pty.cjs +5 -0
  151. package/src/main/queueOps.cjs +15 -8
  152. package/src/main/runVerify.cjs +50 -9
  153. package/src/main/scheduler/prdParser.cjs +18 -1
  154. package/src/main/scheduler.cjs +1701 -130
  155. package/src/main/seedAgentPersonas.cjs +62 -21
  156. package/src/main/seedSchedulerMcp.cjs +58 -4
  157. package/src/main/templates/project-pages-catalog.json +741 -0
  158. package/src/main/templates/project-pages-pipeline.md +417 -0
  159. package/src/preload/api.d.ts +187 -3
  160. package/src/preload/index.cjs +9 -0
  161. package/src/seed/agents/project-home-builder.md +59 -0
  162. package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
  163. package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
  164. package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
@@ -0,0 +1,133 @@
1
+ /**
2
+ * reaperHelpers.test.cjs — selectReapableJobs: the pure predicate behind
3
+ * reapDeadRunningJobs()'s pidless-zombie fix.
4
+ *
5
+ * Prior behaviour: a 'running' row with no runtime.pid was skipped forever,
6
+ * unconditionally ("spawn may be mid-flight; give it a cycle") — no age
7
+ * bound. Repro 2026-09-01: a row sat pidless for 464 minutes against a
8
+ * 24-minute estimate, empty run dir, no claude process anywhere — nothing
9
+ * pid-bound (deadman, idle watchdog, supervisor) could ever see it.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/reaperHelpers.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ // vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
17
+ import { test } from 'vitest';
18
+ const assert = require('node:assert/strict');
19
+ const { selectReapableJobs, mapOutcomeToGateOutcome } = require('../reaperHelpers.cjs');
20
+
21
+ const NOW = Date.parse('2026-09-01T12:00:00.000Z');
22
+ const agoMin = (m) => new Date(NOW - m * 60_000).toISOString();
23
+ const GRACE = 10 * 60_000;
24
+ const alwaysDead = () => false;
25
+ const alwaysAlive = () => true;
26
+
27
+ test('pidless + older than grace → reaped, with a named reason', () => {
28
+ const jobs = [{ slug: 'zombie', status: 'running', startedAt: agoMin(464) }];
29
+ const { reapable, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
30
+ assert.strictEqual(warnings.length, 0);
31
+ assert.strictEqual(reapable.length, 1);
32
+ assert.strictEqual(reapable[0].slug, 'zombie');
33
+ assert.strictEqual(reapable[0].pid, null);
34
+ assert.strictEqual(reapable[0].pidless, true);
35
+ assert.match(reapable[0].reason, /no runtime\.pid recorded after 10m/);
36
+ });
37
+
38
+ test('pidless + within grace → skipped (the genuine mid-flight-spawn case)', () => {
39
+ const jobs = [{ slug: 'fresh', status: 'running', startedAt: agoMin(2) }];
40
+ const { reapable, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
41
+ assert.deepStrictEqual(reapable, []);
42
+ assert.deepStrictEqual(warnings, []);
43
+ });
44
+
45
+ test('pidless + exactly at grace boundary → reaped (>= not >)', () => {
46
+ const jobs = [{ slug: 'boundary', status: 'running', startedAt: agoMin(10) }];
47
+ const { reapable } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
48
+ assert.strictEqual(reapable.length, 1);
49
+ });
50
+
51
+ test('live pid → skipped regardless of age', () => {
52
+ const jobs = [{ slug: 'live', status: 'running', runtime: { pid: 4242 }, startedAt: agoMin(500) }];
53
+ const { reapable } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
54
+ assert.deepStrictEqual(reapable, []);
55
+ });
56
+
57
+ test('dead pid → reaped exactly as before (pidless: false, no reason string)', () => {
58
+ const jobs = [{ slug: 'dead', status: 'running', runtime: { pid: 4242 }, startedAt: agoMin(500) }];
59
+ const { reapable } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysDead, grace: GRACE });
60
+ assert.strictEqual(reapable.length, 1);
61
+ assert.strictEqual(reapable[0].slug, 'dead');
62
+ assert.strictEqual(reapable[0].pid, 4242);
63
+ assert.strictEqual(reapable[0].pidless, false);
64
+ });
65
+
66
+ test('pidless + missing startedAt → skipped and warned, not reaped', () => {
67
+ const jobs = [{ slug: 'no-started-at', status: 'running' }];
68
+ const { reapable, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
69
+ assert.deepStrictEqual(reapable, []);
70
+ assert.strictEqual(warnings.length, 1);
71
+ assert.strictEqual(warnings[0].slug, 'no-started-at');
72
+ });
73
+
74
+ test('pidless + unparseable startedAt → skipped and warned, not reaped', () => {
75
+ const jobs = [{ slug: 'bad-started-at', status: 'running', startedAt: 'not-a-date' }];
76
+ const { reapable, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
77
+ assert.deepStrictEqual(reapable, []);
78
+ assert.strictEqual(warnings.length, 1);
79
+ assert.strictEqual(warnings[0].slug, 'bad-started-at');
80
+ });
81
+
82
+ test('pidless + startedAt in the future (clock skew) → skipped, not reaped', () => {
83
+ const jobs = [{ slug: 'future', status: 'running', startedAt: agoMin(-60) }];
84
+ const { reapable, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
85
+ assert.deepStrictEqual(reapable, []);
86
+ assert.deepStrictEqual(warnings, []);
87
+ });
88
+
89
+ test('non-running rows are never considered, pidless or not', () => {
90
+ const jobs = [
91
+ { slug: 'a', status: 'pending', startedAt: agoMin(500) },
92
+ { slug: 'b', status: 'completed', startedAt: agoMin(500) },
93
+ { slug: 'c', status: 'failed', runtime: { pid: 1 }, startedAt: agoMin(500) },
94
+ ];
95
+ const { reapable, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysDead, grace: GRACE });
96
+ assert.deepStrictEqual(reapable, []);
97
+ assert.deepStrictEqual(warnings, []);
98
+ });
99
+
100
+ test('mixed batch: each row classified independently', () => {
101
+ const jobs = [
102
+ { slug: 'zombie', status: 'running', startedAt: agoMin(464) },
103
+ { slug: 'fresh', status: 'running', startedAt: agoMin(2) },
104
+ { slug: 'live', status: 'running', runtime: { pid: 1 }, startedAt: agoMin(500) },
105
+ { slug: 'dead', status: 'running', runtime: { pid: 2 }, startedAt: agoMin(500) },
106
+ { slug: 'bad-started-at', status: 'running', startedAt: 'nope' },
107
+ ];
108
+ const pidAlive = (pid) => pid === 1;
109
+ const { reapable, warnings } = selectReapableJobs(jobs, NOW, { pidAlive, grace: GRACE });
110
+ assert.deepStrictEqual(reapable.map((r) => r.slug).sort(), ['dead', 'zombie']);
111
+ assert.deepStrictEqual(warnings.map((w) => w.slug), ['bad-started-at']);
112
+ });
113
+
114
+ // mapOutcomeToGateOutcome — classifyRunOutcome's four-way result collapsed
115
+ // onto the persisted `gateOutcome` field (PRD 1109). 'no_result' -> 'never_ran'
116
+ // is the one non-obvious mapping: a missing verdict event means the gate
117
+ // never fired, a distinct fact from 'failed' (a verdict event exists and
118
+ // says error).
119
+ test('mapOutcomeToGateOutcome: success -> passed', () => {
120
+ assert.strictEqual(mapOutcomeToGateOutcome('success'), 'passed');
121
+ });
122
+
123
+ test('mapOutcomeToGateOutcome: failed -> failed', () => {
124
+ assert.strictEqual(mapOutcomeToGateOutcome('failed'), 'failed');
125
+ });
126
+
127
+ test('mapOutcomeToGateOutcome: no_result -> never_ran', () => {
128
+ assert.strictEqual(mapOutcomeToGateOutcome('no_result'), 'never_ran');
129
+ });
130
+
131
+ test('mapOutcomeToGateOutcome: unknown -> unknown', () => {
132
+ assert.strictEqual(mapOutcomeToGateOutcome('unknown'), 'unknown');
133
+ });
@@ -118,15 +118,25 @@ test('a SKIPPED (never-ran) bare-named dep holds the dependent and reports an ex
118
118
  // ---------------------------------------------------------------------------
119
119
 
120
120
  test('fires EVERY dependency-eligible job, not one per parallelGroup', () => {
121
- const jobs = [
122
- job('983-a', 'pending'),
123
- job('984-b', 'pending'),
124
- job('985-c', 'pending'),
125
- job('986-d', 'pending'),
126
- ];
127
- const { batch } = pick(jobs, new Set(), 5);
128
- // Pre-fix this returned exactly ['983-a'] — one singleton group.
129
- assert.deepEqual(batch.map((j) => j.slug), ['983-a', '984-b', '985-c', '986-d']);
121
+ // This test predates the per-project job cap (PRD 1066, default 2) and
122
+ // asserts against the parallelGroup barrier specifically — raise the cap
123
+ // so it isn't incidentally exercising that unrelated limit.
124
+ const prevCap = process.env.SM_PROJECT_JOB_CAP;
125
+ process.env.SM_PROJECT_JOB_CAP = '10';
126
+ try {
127
+ const jobs = [
128
+ job('983-a', 'pending'),
129
+ job('984-b', 'pending'),
130
+ job('985-c', 'pending'),
131
+ job('986-d', 'pending'),
132
+ ];
133
+ const { batch } = pick(jobs, new Set(), 5);
134
+ // Pre-fix this returned exactly ['983-a'] — one singleton group.
135
+ assert.deepEqual(batch.map((j) => j.slug), ['983-a', '984-b', '985-c', '986-d']);
136
+ } finally {
137
+ if (prevCap === undefined) delete process.env.SM_PROJECT_JOB_CAP;
138
+ else process.env.SM_PROJECT_JOB_CAP = prevCap;
139
+ }
130
140
  });
131
141
 
132
142
  test('a higher-numbered job is NOT held behind an in-flight lower-numbered one', () => {
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+ // Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerBatchFairness.test.cjs
3
+ //
4
+ // PRDs 1086 + 1087 — cross-project fairness and starvation escalation.
5
+ // pickNextBatch used to order projects by their lowest pending parallelGroup.
6
+ // NN numbers are allocated PER PROJECT and only grow, so that compared
7
+ // unrelated sequences: on 2026-09-01 social-signals-trader (8 pending, lowest
8
+ // NN 3887) got zero starts for 3.5 h while starry-night-ships (lowest NN 158)
9
+ // took every freed slot. These tests pin the replacement contract: fewest
10
+ // running first, oldest pending as tiebreak, cwd last, and ROUND-ROBIN slot
11
+ // hand-out — plus the escalation that makes any regression visible.
12
+ const assert = require('node:assert/strict');
13
+ const {
14
+ pickNextBatch, enqueueTimestamp, findStarvedProjects,
15
+ } = require('../schedulerBatch.cjs');
16
+
17
+ const A = '/home/user/Projects/starry-night-ships';
18
+ const B = '/home/user/Projects/social-signals-trader';
19
+ const C = '/home/user/Projects/burrow';
20
+ const D = '/home/user/Projects/sigma';
21
+
22
+ function job(slug, status, cwd, extra = {}) {
23
+ return {
24
+ slug,
25
+ status,
26
+ cwd,
27
+ parallelGroup: Number(String(slug).match(/^(\d+)-/)?.[1] ?? 99),
28
+ dependsOn: [],
29
+ ...extra,
30
+ };
31
+ }
32
+ const ORIGINAL_CAP_ENV = process.env.SM_PROJECT_JOB_CAP;
33
+ afterEach(() => {
34
+ if (ORIGINAL_CAP_ENV === undefined) delete process.env.SM_PROJECT_JOB_CAP;
35
+ else process.env.SM_PROJECT_JOB_CAP = ORIGINAL_CAP_ENV;
36
+ });
37
+
38
+ // ─── the reported bug ───────────────────────────────────────────────────────
39
+
40
+ test('a project with high NN numbers is never starved behind a project with low ones (freeSlots=1, repeated ticks)', () => {
41
+ delete process.env.SM_PROJECT_JOB_CAP;
42
+ const jobs = [];
43
+ for (let n = 158; n <= 172; n++) jobs.push(job(`${n}-a`, 'pending', A));
44
+ for (let n = 3887; n <= 3941; n++) jobs.push(job(`${n}-b`, 'pending', B));
45
+ const running = new Set();
46
+ const dispatched = [];
47
+ for (let tick = 0; tick < 2; tick++) {
48
+ const { batch } = pickNextBatch(jobs, running, 1);
49
+ assert.equal(batch.length, 1, `tick ${tick} should dispatch exactly one job`);
50
+ const picked = batch[0];
51
+ dispatched.push(picked);
52
+ // simulate the picked job going to running
53
+ const row = jobs.find((j) => j.slug === picked.slug);
54
+ row.status = 'running';
55
+ running.add(row.slug);
56
+ }
57
+ assert.ok(dispatched.some((j) => j.cwd === B), `B must get a start within 2 ticks, got ${dispatched.map((j) => j.slug)}`);
58
+ assert.ok(dispatched.some((j) => j.cwd === A), 'A still gets its start too');
59
+ });
60
+
61
+ // ─── round-robin ────────────────────────────────────────────────────────────
62
+
63
+ test('round-robin: 4 projects x 3 eligible, freeSlots=4 -> exactly one job from each project', () => {
64
+ delete process.env.SM_PROJECT_JOB_CAP;
65
+ const jobs = [];
66
+ for (const [i, cwd] of [A, B, C, D].entries()) {
67
+ for (let k = 1; k <= 3; k++) jobs.push(job(`${(i + 1) * 100 + k}-${k}`, 'pending', cwd));
68
+ }
69
+ const { batch } = pickNextBatch(jobs, new Set(), 4);
70
+ assert.equal(batch.length, 4);
71
+ assert.deepEqual(new Set(batch.map((j) => j.cwd)), new Set([A, B, C, D]));
72
+ });
73
+
74
+ test('round-robin keeps cycling: 2 projects x 3 eligible, freeSlots=4, cap 2 -> 2 from each, not 3+1', () => {
75
+ process.env.SM_PROJECT_JOB_CAP = '2';
76
+ const jobs = [
77
+ job('101-a', 'pending', A), job('102-a', 'pending', A), job('103-a', 'pending', A),
78
+ job('201-b', 'pending', B), job('202-b', 'pending', B), job('203-b', 'pending', B),
79
+ ];
80
+ const { batch } = pickNextBatch(jobs, new Set(), 4);
81
+ assert.equal(batch.length, 4);
82
+ assert.equal(batch.filter((j) => j.cwd === A).length, 2);
83
+ assert.equal(batch.filter((j) => j.cwd === B).length, 2);
84
+ });
85
+
86
+ test('the per-project cap holds ACROSS passes (a project already at cap is offered nothing more this tick)', () => {
87
+ process.env.SM_PROJECT_JOB_CAP = '2';
88
+ const jobs = [
89
+ job('101-a', 'pending', A), job('102-a', 'pending', A), job('103-a', 'pending', A), job('104-a', 'pending', A),
90
+ ];
91
+ const { batch } = pickNextBatch(jobs, new Set(), 5);
92
+ assert.deepEqual(batch.map((j) => j.slug), ['101-a', '102-a']);
93
+ });
94
+
95
+ test('within a project, parallelGroup ascending is still the priority hint', () => {
96
+ delete process.env.SM_PROJECT_JOB_CAP;
97
+ const jobs = [job('105-a', 'pending', A), job('101-a', 'pending', A)];
98
+ const { batch } = pickNextBatch(jobs, new Set(), 1);
99
+ assert.equal(batch[0].slug, '101-a');
100
+ });
101
+
102
+ // ─── ordering keys ──────────────────────────────────────────────────────────
103
+
104
+ test('fewest running first: a project already running 1 job sorts behind one running 0, even with the older pending job', () => {
105
+ delete process.env.SM_PROJECT_JOB_CAP;
106
+ const jobs = [
107
+ job('100-a-running', 'running', A),
108
+ job('101-a', 'pending', A, { queuedAt: '2026-09-01T00:00:00Z' }), // older
109
+ job('900-b', 'pending', B, { queuedAt: '2026-09-01T01:00:00Z' }), // newer, but B runs nothing
110
+ ];
111
+ const { batch } = pickNextBatch(jobs, new Set(['100-a-running']), 1);
112
+ assert.equal(batch[0].slug, '900-b');
113
+ });
114
+
115
+ test('tiebreak on the oldest pending enqueue time when running counts are equal', () => {
116
+ delete process.env.SM_PROJECT_JOB_CAP;
117
+ const jobs = [
118
+ job('101-a', 'pending', A, { queuedAt: '2026-09-01T01:00:00Z' }),
119
+ job('900-b', 'pending', B, { queuedAt: '2026-09-01T00:00:00Z' }), // older -> first
120
+ ];
121
+ assert.equal(pickNextBatch(jobs, new Set(), 1).batch[0].slug, '900-b');
122
+ });
123
+
124
+ test('unprovable ages sort LAST, and the final tiebreak is the cwd string', () => {
125
+ delete process.env.SM_PROJECT_JOB_CAP;
126
+ const jobs = [
127
+ job('101-a', 'pending', A), // no timestamp
128
+ job('900-b', 'pending', B, { queuedAt: '2026-09-01T00:00:00Z' }),
129
+ ];
130
+ assert.equal(pickNextBatch(jobs, new Set(), 1).batch[0].slug, '900-b');
131
+ // Both unprovable -> the lexicographically smaller cwd wins: '.../burrow' (C) < '.../starry-night-ships' (A).
132
+ const tie = [job('900-b', 'pending', A), job('101-a', 'pending', C)];
133
+ assert.equal(pickNextBatch(tie, new Set(), 1).batch[0].slug, '101-a');
134
+ });
135
+
136
+ test('holds are aggregated once per slug across passes and reason is non-null only for an empty batch', () => {
137
+ delete process.env.SM_PROJECT_JOB_CAP;
138
+ const jobs = [
139
+ job('100-dep', 'failed', A),
140
+ job('101-a', 'pending', A, { dependsOn: ['100-dep'] }),
141
+ ];
142
+ const r = pickNextBatch(jobs, new Set(), 3);
143
+ assert.equal(r.batch.length, 0);
144
+ assert.match(r.reason, /failed dependencies/);
145
+ assert.equal(r.holds.filter((h) => h.slug === '101-a').length, 1);
146
+ });
147
+
148
+ // ─── enqueueTimestamp ───────────────────────────────────────────────────────
149
+
150
+ test('enqueueTimestamp resolves createdAt -> queuedAt -> statusHistory(to pending) -> startedAt -> Infinity', () => {
151
+ const t = (s) => Date.parse(s);
152
+ assert.equal(enqueueTimestamp({ createdAt: '2026-01-01T00:00:00Z', queuedAt: '2026-02-01T00:00:00Z' }), t('2026-01-01T00:00:00Z'));
153
+ assert.equal(enqueueTimestamp({ queuedAt: '2026-02-01T00:00:00Z' }), t('2026-02-01T00:00:00Z'));
154
+ assert.equal(enqueueTimestamp({ statusHistory: [{ from: null, to: 'pending', at: '2026-03-01T00:00:00Z' }] }), t('2026-03-01T00:00:00Z'));
155
+ assert.equal(enqueueTimestamp({ startedAt: '2026-04-01T00:00:00Z' }), t('2026-04-01T00:00:00Z'));
156
+ assert.equal(enqueueTimestamp({}), Infinity);
157
+ assert.equal(enqueueTimestamp({ queuedAt: 'garbage' }), Infinity);
158
+ assert.equal(enqueueTimestamp(null), Infinity);
159
+ });
160
+
161
+ // ─── findStarvedProjects (PRD 1087) ────────────────────────────────────────
162
+
163
+ const NOW = Date.parse('2026-09-01T12:00:00Z');
164
+ const H = 60 * 60_000;
165
+
166
+ test('a genuinely starved project is reported with its oldest pending slug and age', () => {
167
+ const jobs = [
168
+ job('158-a', 'running', A),
169
+ job('3887-b', 'pending', B, { queuedAt: new Date(NOW - 3 * H).toISOString() }),
170
+ job('3888-b', 'pending', B, { queuedAt: new Date(NOW - 1 * H).toISOString() }),
171
+ ];
172
+ const out = findStarvedProjects(jobs, NOW, 45 * 60_000);
173
+ assert.equal(out.length, 1);
174
+ assert.equal(out[0].cwd, B);
175
+ assert.equal(out[0].pendingCount, 2);
176
+ assert.equal(out[0].oldestPendingSlug, '3887-b');
177
+ assert.equal(out[0].ageMs, 3 * H);
178
+ });
179
+
180
+ test('NOT reported when no project at all is running (idle/paused machine)', () => {
181
+ const jobs = [job('3887-b', 'pending', B, { queuedAt: new Date(NOW - 5 * H).toISOString() })];
182
+ assert.deepEqual(findStarvedProjects(jobs, NOW, 45 * 60_000), []);
183
+ });
184
+
185
+ test('NOT reported when every pending job is younger than the threshold', () => {
186
+ const jobs = [
187
+ job('158-a', 'running', A),
188
+ job('3887-b', 'pending', B, { queuedAt: new Date(NOW - 10 * 60_000).toISOString() }),
189
+ ];
190
+ assert.deepEqual(findStarvedProjects(jobs, NOW, 45 * 60_000), []);
191
+ });
192
+
193
+ test('NOT reported when the project has a running job of its own', () => {
194
+ const jobs = [
195
+ job('158-a', 'running', A),
196
+ job('3880-b', 'running', B),
197
+ job('3887-b', 'pending', B, { queuedAt: new Date(NOW - 5 * H).toISOString() }),
198
+ ];
199
+ assert.deepEqual(findStarvedProjects(jobs, NOW, 45 * 60_000), []);
200
+ });
201
+
202
+ test('NOT reported (but warned) when the pending rows carry no provable enqueue time', () => {
203
+ const warns = [];
204
+ const orig = console.warn;
205
+ console.warn = (m) => warns.push(String(m));
206
+ try {
207
+ const jobs = [job('158-a', 'running', A), job('3887-b', 'pending', B)];
208
+ assert.deepEqual(findStarvedProjects(jobs, NOW, 45 * 60_000), []);
209
+ assert.ok(warns.some((w) => w.includes(B) && w.includes('cannot prove age')));
210
+ } finally {
211
+ console.warn = orig;
212
+ }
213
+ });
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs
4
+ //
5
+ // The launch circuit breaker (lib/launchFailure.cjs, issue #11) hands the
6
+ // picker a Map of slug → hold reason. A held row must be invisible to every
7
+ // pick — it must NOT shadow a pickable sibling behind it, must not make a
8
+ // project a round-robin candidate on its own, and must not be offered as a
9
+ // quiet-machine job — while still surfacing as a per-row hold for the UI.
10
+
11
+ const assert = require('node:assert/strict');
12
+ const { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD } = require('../schedulerBatch.cjs');
13
+ const { computeLaunchHolds } = require('../../scheduler.cjs');
14
+ const lf = require('../launchFailure.cjs');
15
+
16
+ const CWD = DEFAULT_PROJECT_CWD;
17
+
18
+ function job(slug, status = 'pending', extra = {}) {
19
+ return {
20
+ slug,
21
+ status,
22
+ cwd: CWD,
23
+ parallelGroup: Number(String(slug).match(/^(\d+)-/)?.[1] ?? 99),
24
+ dependsOn: [],
25
+ createdAt: '2026-09-02T18:00:00.000Z',
26
+ ...extra,
27
+ };
28
+ }
29
+
30
+ test('pickForProject: a held row is skipped and the sibling behind it is picked', () => {
31
+ const jobs = [job('10-blocked'), job('11-free')];
32
+ const held = new Map([['10-blocked', 'launch blocked (model_config_rejected) — re-probe in 5 min']]);
33
+ const r = pickForProject(jobs, new Set(), 1, held);
34
+ assert.deepEqual(r.batch.map((j) => j.slug), ['11-free']);
35
+ const hold = r.holds.find((h) => h.slug === '10-blocked');
36
+ assert.ok(hold, 'held row surfaces as a hold record');
37
+ assert.equal(hold.dep, null);
38
+ assert.match(hold.reason, /launch blocked/);
39
+ });
40
+
41
+ test('pickForProject: every pending row held → empty batch, holds still reported, no dep reason invented', () => {
42
+ const jobs = [job('10-a'), job('11-b')];
43
+ const held = new Map([['10-a', 'launch blocked'], ['11-b', 'launch blocked']]);
44
+ const r = pickForProject(jobs, new Set(), 3, held);
45
+ assert.equal(r.batch.length, 0);
46
+ assert.equal(r.reason, null);
47
+ assert.deepEqual(r.holds.map((h) => h.slug).sort(), ['10-a', '11-b']);
48
+ });
49
+
50
+ test('pickForProject: a held row still counts as a blocking dependency for its dependents', () => {
51
+ const jobs = [job('10-base'), job('11-child', 'pending', { dependsOn: ['10-base'] })];
52
+ const held = new Map([['10-base', 'launch blocked']]);
53
+ const r = pickForProject(jobs, new Set(), 3, held);
54
+ assert.equal(r.batch.length, 0, 'the child must not run ahead of its held dependency');
55
+ const childHold = r.holds.find((h) => h.slug === '11-child');
56
+ assert.equal(childHold.dep, '10-base');
57
+ });
58
+
59
+ test('pickNextBatch: heldSlugs option threads through; a project with only held rows is not a candidate', () => {
60
+ const other = '/tmp/other-project';
61
+ const jobs = [job('10-held'), job('20-other', 'pending', { cwd: other })];
62
+ const held = new Map([['10-held', 'launch blocked (auth_failed)']]);
63
+ const r = pickNextBatch(jobs, new Set(), 2, { heldSlugs: held, now: Date.parse('2026-09-02T18:05:00Z') });
64
+ assert.deepEqual(r.batch.map((j) => j.slug), ['20-other']);
65
+ assert.ok(r.holds.some((h) => h.slug === '10-held' && /auth_failed/.test(h.reason)));
66
+ });
67
+
68
+ test('pickNextBatch: a held quiet-machine row is never dispatched exclusively', () => {
69
+ const jobs = [job('10-quiet', 'pending', { quietMachine: true }), job('11-plain')];
70
+ const held = new Map([['10-quiet', 'launch blocked']]);
71
+ const r = pickNextBatch(jobs, new Set(), 1, { heldSlugs: held, machineInUse: 0 });
72
+ assert.deepEqual(r.batch.map((j) => j.slug), ['11-plain']);
73
+ });
74
+
75
+ test('pickNextBatch: nothing pickable at all → empty batch with holds, not a crash', () => {
76
+ const jobs = [job('10-held')];
77
+ const r = pickNextBatch(jobs, new Set(), 2, { heldSlugs: new Map([['10-held', 'launch blocked']]) });
78
+ assert.equal(r.batch.length, 0);
79
+ assert.equal(r.holds[0].slug, '10-held');
80
+ });
81
+
82
+ // ─── computeLaunchHolds (scheduler.cjs) ─────────────────────────────────────
83
+
84
+ const T0 = Date.parse('2026-09-02T18:00:00Z');
85
+
86
+ test('computeLaunchHolds: no blocks → empty map without probing the CLI', async () => {
87
+ const held = await computeLaunchHolds({ jobs: [job('10-a')], launchBlocks: {} }, { now: T0, claudeVersion: 'v1' });
88
+ assert.equal(held.size, 0);
89
+ });
90
+
91
+ test('computeLaunchHolds: inside backoff every pending row of the blocked persona is held; other personas run', async () => {
92
+ const block = lf.armLaunchBlock(null, { kind: 'model_config_rejected', message: 'm', now: T0, claudeVersion: 'v1' });
93
+ const state = {
94
+ jobs: [job('10-a', 'pending', { agentType: 'dev-lead' }), job('11-b', 'pending', { agentType: 'dev-lead' }), job('12-c', 'pending', { agentType: 'architect' }), job('13-done', 'completed', { agentType: 'dev-lead' })],
95
+ launchBlocks: { 'dev-lead': block },
96
+ };
97
+ const held = await computeLaunchHolds(state, { now: T0 + 1000, claudeVersion: 'v1' });
98
+ assert.deepEqual([...held.keys()].sort(), ['10-a', '11-b']);
99
+ assert.match(held.get('10-a'), /launch blocked \(model_config_rejected\)/);
100
+ });
101
+
102
+ test('computeLaunchHolds: after backoff exactly ONE row per persona is released as the probe', async () => {
103
+ const block = lf.armLaunchBlock(null, { kind: 'api_overloaded', message: 'm', now: T0, claudeVersion: 'v1' });
104
+ const state = {
105
+ jobs: [job('10-a', 'pending', { agentType: 'dev-lead' }), job('11-b', 'pending', { agentType: 'dev-lead' })],
106
+ launchBlocks: { 'dev-lead': block },
107
+ };
108
+ const held = await computeLaunchHolds(state, { now: Date.parse(block.until) + 1, claudeVersion: 'v1' });
109
+ assert.deepEqual([...held.keys()], ['11-b']);
110
+ assert.match(held.get('11-b'), /waiting for this tick's probe/);
111
+ });
112
+
113
+ test('computeLaunchHolds: a CLI version change releases everything', async () => {
114
+ const block = lf.armLaunchBlock(null, { kind: 'model_config_rejected', message: 'm', now: T0, claudeVersion: 'v1' });
115
+ const state = { jobs: [job('10-a', 'pending', { agentType: 'dev-lead' })], launchBlocks: { 'dev-lead': block } };
116
+ const held = await computeLaunchHolds(state, { now: T0 + 1, claudeVersion: 'v2' });
117
+ assert.equal(held.size, 0);
118
+ });
119
+
120
+ test('computeLaunchHolds: rows with no agentType are keyed as default', async () => {
121
+ const block = lf.armLaunchBlock(null, { kind: 'auth_failed', message: 'm', now: T0, claudeVersion: 'v1' });
122
+ const state = { jobs: [job('10-a')], launchBlocks: { default: block } };
123
+ const held = await computeLaunchHolds(state, { now: T0 + 1, claudeVersion: 'v1' });
124
+ assert.ok(held.has('10-a'));
125
+ });
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs
4
+ //
5
+ // Regression coverage for the per-project job cap (PRD 1066): the global
6
+ // sessionSlots pool is machine-wide and project-blind, so all of it can land
7
+ // on one project — measured 2026-08-31 as 4 concurrent executors on one
8
+ // project, loadavg 25.89/14 cores, silently converting that project's
9
+ // timing-sensitive test gate into a no-op. These tests pin the fix: an INNER
10
+ // per-cwd cap inside pickNextBatch/pickForProject, layered under the existing
11
+ // global pool (never a replacement for it).
12
+
13
+ const assert = require('node:assert/strict');
14
+ const { pickNextBatch, pickForProject } = require('../schedulerBatch.cjs');
15
+
16
+ const CWD_A = '/home/user/Projects/project-a';
17
+ const CWD_B = '/home/user/Projects/project-b';
18
+
19
+ function job(slug, status, cwd, extra = {}) {
20
+ return {
21
+ slug,
22
+ status,
23
+ cwd,
24
+ parallelGroup: Number(String(slug).match(/^(\d+)-/)?.[1] ?? 99),
25
+ dependsOn: [],
26
+ ...extra,
27
+ };
28
+ }
29
+
30
+ const ORIGINAL_CAP_ENV = process.env.SM_PROJECT_JOB_CAP;
31
+ afterEach(() => {
32
+ if (ORIGINAL_CAP_ENV === undefined) delete process.env.SM_PROJECT_JOB_CAP;
33
+ else process.env.SM_PROJECT_JOB_CAP = ORIGINAL_CAP_ENV;
34
+ });
35
+
36
+ test('three eligible jobs in one project, default cap 2 -> exactly 2 selected', () => {
37
+ delete process.env.SM_PROJECT_JOB_CAP;
38
+ const jobs = [
39
+ job('101-a', 'pending', CWD_A),
40
+ job('102-b', 'pending', CWD_A),
41
+ job('103-c', 'pending', CWD_A),
42
+ ];
43
+ const { batch } = pickNextBatch(jobs, new Set(), 5);
44
+ assert.deepEqual(batch.map((j) => j.slug), ['101-a', '102-b']);
45
+ });
46
+
47
+ test('cap is per-project: two projects with 2 eligible jobs each (cap 2) both fire in full', () => {
48
+ delete process.env.SM_PROJECT_JOB_CAP;
49
+ const jobs = [
50
+ job('101-a', 'pending', CWD_A),
51
+ job('102-b', 'pending', CWD_A),
52
+ job('201-a', 'pending', CWD_B),
53
+ job('202-b', 'pending', CWD_B),
54
+ ];
55
+ const { batch } = pickNextBatch(jobs, new Set(), 5);
56
+ // A global cap of 2 would only ever admit 2 total; the per-project cap
57
+ // admits up to 2 PER cwd, so all 4 fire here against a 5-slot pool.
58
+ assert.deepEqual(
59
+ batch.map((j) => j.slug).sort(),
60
+ ['101-a', '102-b', '201-a', '202-b'],
61
+ );
62
+ });
63
+
64
+ test('SM_PROJECT_JOB_CAP=1 -> exactly 1 selected', () => {
65
+ process.env.SM_PROJECT_JOB_CAP = '1';
66
+ const jobs = [
67
+ job('101-a', 'pending', CWD_A),
68
+ job('102-b', 'pending', CWD_A),
69
+ job('103-c', 'pending', CWD_A),
70
+ ];
71
+ const { batch } = pickNextBatch(jobs, new Set(), 5);
72
+ assert.deepEqual(batch.map((j) => j.slug), ['101-a']);
73
+ });
74
+
75
+ test('a job deferred by the project cap stays eligible and fires once a sibling finishes', () => {
76
+ delete process.env.SM_PROJECT_JOB_CAP;
77
+ const jobs = [
78
+ job('101-a', 'running', CWD_A),
79
+ job('102-b', 'running', CWD_A),
80
+ job('103-c', 'pending', CWD_A),
81
+ ];
82
+ // Tick 1: both slots for this project are already running (cap 2) -> 103-c
83
+ // is held, NOT failed/skipped, and remains 'pending' in the queue.
84
+ const running1 = new Set(['101-a', '102-b']);
85
+ const tick1 = pickNextBatch(jobs, running1, 5);
86
+ assert.deepEqual(tick1.batch, []);
87
+ assert.match(tick1.reason, /project-cap/);
88
+ assert.equal(jobs.find((j) => j.slug === '103-c').status, 'pending');
89
+
90
+ // Tick 2: 101-a finished (removed from running, its row now 'completed');
91
+ // 103-c is still eligible and fires on this later tick.
92
+ const jobsTick2 = [
93
+ job('101-a', 'completed', CWD_A),
94
+ job('102-b', 'running', CWD_A),
95
+ job('103-c', 'pending', CWD_A),
96
+ ];
97
+ const running2 = new Set(['102-b']);
98
+ const tick2 = pickNextBatch(jobsTick2, running2, 5);
99
+ assert.deepEqual(tick2.batch.map((j) => j.slug), ['103-c']);
100
+ });
101
+
102
+ test('cap counts an untracked running row (boot-orphan grace window), not just the tracked running Set', () => {
103
+ delete process.env.SM_PROJECT_JOB_CAP;
104
+ const jobs = [
105
+ // status:running but its slug is absent from the caller's `running` Set —
106
+ // e.g. still alive during BOOT_ORPHAN_KILL_GRACE_MS before reconciliation
107
+ // catches up. Must still count against the cap.
108
+ job('101-a', 'running', CWD_A),
109
+ job('102-b', 'running', CWD_A),
110
+ job('103-c', 'pending', CWD_A),
111
+ ];
112
+ const { batch, reason } = pickNextBatch(jobs, new Set(), 5);
113
+ assert.deepEqual(batch, []);
114
+ assert.match(reason, /project-cap/);
115
+ });
116
+
117
+ test('pickForProject: cap counts jobs already running in that project', () => {
118
+ delete process.env.SM_PROJECT_JOB_CAP;
119
+ const jobs = [
120
+ job('101-a', 'running', CWD_A),
121
+ job('102-b', 'running', CWD_A),
122
+ job('103-c', 'pending', CWD_A),
123
+ ];
124
+ const { batch, reason } = pickForProject(jobs, new Set(['101-a', '102-b']), 5);
125
+ assert.deepEqual(batch, []);
126
+ assert.match(reason, /project-cap/);
127
+ });