claude-code-session-manager 0.75.3 → 0.76.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.
- package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-CBx9l4zN.js} +1 -1
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-Bf0EIE_t.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CpdtWhC8.js} +1 -1
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-DyUbMDmg.js} +1 -1
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-By-wIpry.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-CQmo4QVC.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-BQMd6NOm.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-DEp43FXX.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-CLarzwqA.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-B0sCdIy1.js} +1 -1
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-BhWPVOCD.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-Ddlq8T_O.js} +1 -1
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-D2oA_2Jl.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-DgAgavUM.js} +1 -1
- package/dist/assets/{SaveBar-D-gCUx4n.js → SaveBar-Qvc4Ek-H.js} +1 -1
- package/dist/assets/{Scheduler-Bpd4OGju.js → Scheduler-BmYJvNzK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-2Vx3X5SI.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-Cmrz_LeN.js} +1 -1
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-DVA1eYDP.js} +1 -1
- package/dist/assets/{TagLibrary-E5CLeuVk.js → TagLibrary-DYJGAKZu.js} +1 -1
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-DmPc3amD.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-zfd5LJkK.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-B_4PNh9T.js} +676 -676
- package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-B9es6fdA.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- package/scripts/scheduler-mcp-server.cjs +154 -95
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdCreate.test.cjs +109 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +22 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +56 -4
- package/src/main/ipcSchemas.cjs +18 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +33 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/delegationReadiness.cjs +408 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/gitWorktree.cjs +56 -2
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +285 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +94 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdCreate.cjs +46 -14
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueStore.cjs +31 -5
- package/src/main/lib/rcaReport.cjs +1 -1
- package/src/main/lib/reaperHelpers.cjs +47 -1
- package/src/main/lib/schedulerBatch.cjs +171 -29
- package/src/main/lib/schedulerConfig.cjs +80 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +2 -1
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/scheduler.cjs +346 -49
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +2 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectRootResolve.test.cjs — resolveProjectContext (PRD: worktree-cwd
|
|
3
|
+
* Epic-lookup hazard). Exercises the resolver purely in-process with injected
|
|
4
|
+
* deps (no Electron, no real git worktrees) — see prdCreate.test.cjs for the
|
|
5
|
+
* end-to-end fixture reproducing the live starry-night-ships shape.
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/projectRootResolve.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, vi } from 'vitest';
|
|
13
|
+
const { resolveProjectContext } = require('../projectRootResolve.cjs');
|
|
14
|
+
|
|
15
|
+
function makeDeps({ projectRootOfImpl, allCwds = [], indexes = {}, ephemeral = [] } = {}) {
|
|
16
|
+
return {
|
|
17
|
+
projectRootOf: projectRootOfImpl || ((p) => p),
|
|
18
|
+
allProjectCwds: () => allCwds,
|
|
19
|
+
readActiveIndex: (cwd) => ({ sessions: indexes[cwd] || {}, events: {} }),
|
|
20
|
+
isEphemeralCwd: (cwd) => ephemeral.includes(cwd),
|
|
21
|
+
warn: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('a plain project root (no worktree, no ops-internal segment) is returned unchanged', () => {
|
|
26
|
+
const deps = makeDeps();
|
|
27
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/some-app' }, deps);
|
|
28
|
+
expect(result).toEqual({ cwd: '/home/bilko/Projects/some-app', epicId: null, epicCwd: null, source: 'cwd' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a worktree cwd normalizes to the main tree', () => {
|
|
32
|
+
const deps = makeDeps({
|
|
33
|
+
projectRootOfImpl: (p) => (p.startsWith('/tmp/worktree') ? '/home/bilko/Projects/starry-night-ships' : p),
|
|
34
|
+
});
|
|
35
|
+
const result = resolveProjectContext({ cwd: '/tmp/worktree/abc/epic-1' }, deps);
|
|
36
|
+
expect(result.cwd).toBe('/home/bilko/Projects/starry-night-ships');
|
|
37
|
+
expect(result.source).toBe('cwd');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an ops-internal cwd normalizes to the project root', () => {
|
|
41
|
+
const deps = makeDeps({
|
|
42
|
+
projectRootOfImpl: (p) => (p.includes('session-manager-operations') ? '/home/bilko/Projects/foo' : p),
|
|
43
|
+
});
|
|
44
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/foo/session-manager-operations/scheduler/state' }, deps);
|
|
45
|
+
expect(result.cwd).toBe('/home/bilko/Projects/foo');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('Epic found in the normalized cwd\'s own active-index — source epic-index', () => {
|
|
49
|
+
const deps = makeDeps({
|
|
50
|
+
indexes: {
|
|
51
|
+
'/home/bilko/Projects/foo': { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1', status: 'active' } },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/foo', originClaudeSessionId: 'sess-1' }, deps);
|
|
55
|
+
expect(result).toEqual({ cwd: '/home/bilko/Projects/foo', epicId: 'epic-1', epicCwd: '/home/bilko/Projects/foo', source: 'epic-index' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('Epic found only via cross-project scan — epicCwd wins over the supplied cwd', () => {
|
|
59
|
+
const deps = makeDeps({
|
|
60
|
+
allCwds: ['/home/bilko/Projects/foo', '/home/bilko/Projects/bar'],
|
|
61
|
+
indexes: {
|
|
62
|
+
'/home/bilko/Projects/bar': { 'epic-2': { id: 'epic-2', claudeSessionId: 'sess-2', status: 'active' } },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/foo', originClaudeSessionId: 'sess-2' }, deps);
|
|
66
|
+
expect(result.epicId).toBe('epic-2');
|
|
67
|
+
expect(result.epicCwd).toBe('/home/bilko/Projects/bar');
|
|
68
|
+
expect(result.cwd).toBe('/home/bilko/Projects/bar');
|
|
69
|
+
expect(result.source).toBe('cross-project-scan');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no match anywhere → epicId null, cwd unchanged', () => {
|
|
73
|
+
const deps = makeDeps({ allCwds: ['/home/bilko/Projects/foo'] });
|
|
74
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/foo', originClaudeSessionId: 'sess-unknown' }, deps);
|
|
75
|
+
expect(result).toEqual({ cwd: '/home/bilko/Projects/foo', epicId: null, epicCwd: null, source: 'cwd' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('explicit epicId (sourcePromptId) resolves cwd when no cwd was supplied at all', () => {
|
|
79
|
+
const deps = makeDeps({
|
|
80
|
+
allCwds: ['/home/bilko/Projects/foo'],
|
|
81
|
+
indexes: {
|
|
82
|
+
'/home/bilko/Projects/foo': { 'epic-3': { id: 'epic-3', claudeSessionId: 'sess-3', status: 'proposed' } },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const result = resolveProjectContext({ epicId: 'epic-3' }, deps);
|
|
86
|
+
expect(result.cwd).toBe('/home/bilko/Projects/foo');
|
|
87
|
+
expect(result.epicId).toBe('epic-3');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('duplicate claudeSessionId across two projects: prefers the non-ephemeral, status=active row and warns naming both', () => {
|
|
91
|
+
const deps = makeDeps({
|
|
92
|
+
allCwds: ['/tmp/session-manager-epic-worktrees/abc/epic-1', '/home/bilko/Projects/starry-night-ships'],
|
|
93
|
+
indexes: {
|
|
94
|
+
'/tmp/session-manager-epic-worktrees/abc/epic-1': {
|
|
95
|
+
'epic-1': { id: 'epic-1', claudeSessionId: 'sess-dup', status: 'proposed' },
|
|
96
|
+
},
|
|
97
|
+
'/home/bilko/Projects/starry-night-ships': {
|
|
98
|
+
'epic-1': { id: 'epic-1', claudeSessionId: 'sess-dup', status: 'active' },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
ephemeral: ['/tmp/session-manager-epic-worktrees/abc/epic-1'],
|
|
102
|
+
});
|
|
103
|
+
const result = resolveProjectContext({ originClaudeSessionId: 'sess-dup' }, deps);
|
|
104
|
+
expect(result.epicCwd).toBe('/home/bilko/Projects/starry-night-ships');
|
|
105
|
+
expect(result.source).toBe('cross-project-scan');
|
|
106
|
+
expect(deps.warn).toHaveBeenCalledTimes(1);
|
|
107
|
+
const warnMsg = deps.warn.mock.calls[0][0];
|
|
108
|
+
expect(warnMsg).toContain('/tmp/session-manager-epic-worktrees/abc/epic-1');
|
|
109
|
+
expect(warnMsg).toContain('/home/bilko/Projects/starry-night-ships');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('an originProjectRoot hint outranks the supplied cwd, but a resolved Epic still outranks the hint', () => {
|
|
113
|
+
const deps = makeDeps({
|
|
114
|
+
indexes: {
|
|
115
|
+
'/home/bilko/Projects/hinted': { 'epic-4': { id: 'epic-4', claudeSessionId: 'sess-4', status: 'active' } },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const noEpic = resolveProjectContext({ cwd: '/home/bilko/Projects/guessed', originProjectRoot: '/home/bilko/Projects/hinted' }, deps);
|
|
119
|
+
expect(noEpic.cwd).toBe('/home/bilko/Projects/hinted');
|
|
120
|
+
|
|
121
|
+
const withEpic = resolveProjectContext(
|
|
122
|
+
{ cwd: '/home/bilko/Projects/guessed', originProjectRoot: '/home/bilko/Projects/hinted', originClaudeSessionId: 'sess-4' },
|
|
123
|
+
deps,
|
|
124
|
+
);
|
|
125
|
+
expect(withEpic.cwd).toBe('/home/bilko/Projects/hinted');
|
|
126
|
+
expect(withEpic.epicId).toBe('epic-4');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('never throws on missing cwd/originClaudeSessionId/epicId — returns an all-null result', () => {
|
|
130
|
+
const deps = makeDeps();
|
|
131
|
+
expect(resolveProjectContext({}, deps)).toEqual({ cwd: null, epicId: null, epicCwd: null, source: 'cwd' });
|
|
132
|
+
expect(resolveProjectContext(undefined, deps)).toEqual({ cwd: null, epicId: null, epicCwd: null, source: 'cwd' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('bounded: reads each candidate project\'s active-index exactly once per call', () => {
|
|
136
|
+
const readActiveIndex = vi.fn((cwd) => ({ sessions: {}, events: {} }));
|
|
137
|
+
const deps = {
|
|
138
|
+
projectRootOf: (p) => p,
|
|
139
|
+
allProjectCwds: () => ['/home/bilko/Projects/a', '/home/bilko/Projects/b', '/home/bilko/Projects/c'],
|
|
140
|
+
readActiveIndex,
|
|
141
|
+
isEphemeralCwd: () => false,
|
|
142
|
+
warn: vi.fn(),
|
|
143
|
+
};
|
|
144
|
+
resolveProjectContext({ cwd: '/home/bilko/Projects/a', originClaudeSessionId: 'sess-x' }, deps);
|
|
145
|
+
expect(readActiveIndex).toHaveBeenCalledTimes(3);
|
|
146
|
+
const calledCwds = readActiveIndex.mock.calls.map((c) => c[0]);
|
|
147
|
+
expect(new Set(calledCwds).size).toBe(3);
|
|
148
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
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 } = 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
|
+
});
|
|
@@ -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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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,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
|
+
});
|