start-command 0.37.6 → 0.37.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.37.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 6499f91: `--cpu-penalty`: `penalizedMs` keeps the time since the last monitor tick when a graceful stop or a supervisor override ends a penalty, so it never decreases (#218).
8
+
3
9
  ## 0.37.6
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.37.6",
3
+ "version": "0.37.7",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  const { spawnSync } = require('child_process');
5
5
  const {
6
6
  initialState,
7
+ accrue,
7
8
  evaluate,
8
9
  observeBase,
9
10
  transitionLog,
@@ -289,11 +290,12 @@ async function monitor(
289
290
  name,
290
291
  ]);
291
292
  if (update.success) {
293
+ const now = Date.now();
292
294
  state = {
293
- ...state,
295
+ ...accrue(state, now),
294
296
  phase: 'observing',
295
297
  limitCpus: cpus,
296
- since: Date.now(),
298
+ since: now,
297
299
  };
298
300
  log(`[start] CPU penalty lifted: ${cpus} CPUs (monitor stopped)`);
299
301
  publish();
@@ -85,13 +85,24 @@ function initialState(baseCpus = null, now = Date.now(), saved = null) {
85
85
  capacity: null,
86
86
  };
87
87
  }
88
+ // Penalized time counts until the phase leaves `penalized`; `--status` only
89
+ // extrapolates while penalized, so every transition settles it first.
90
+ function accrue(state, now) {
91
+ return {
92
+ ...state,
93
+ penalizedMs:
94
+ state.penalizedMs +
95
+ (state.phase === 'penalized' ? Math.max(0, now - state.lastAt) : 0),
96
+ lastAt: now,
97
+ };
98
+ }
88
99
  // A quota different from our cap is a supervisor override, not our penalty.
89
100
  function observeBase(state, baseCpus, now) {
90
101
  if (baseCpus === state.limitCpus) {
91
102
  return state;
92
103
  }
93
104
  return {
94
- ...state,
105
+ ...accrue(state, now),
95
106
  phase: 'observing',
96
107
  baseCpus,
97
108
  limitCpus: baseCpus,
@@ -133,11 +144,7 @@ function evaluate(
133
144
  { now, cores, daemonCpus, maxGapMs },
134
145
  config = DEFAULTS
135
146
  ) {
136
- const s = { ...state, samples: [...state.samples] };
137
- if (s.phase === 'penalized') {
138
- s.penalizedMs += Math.max(0, now - s.lastAt);
139
- }
140
- s.lastAt = now;
147
+ const s = { ...accrue(state, now), samples: [...state.samples] };
141
148
  const capacity = Number.isFinite(daemonCpus)
142
149
  ? Math.min(s.baseCpus || Infinity, daemonCpus)
143
150
  : NaN;
@@ -197,6 +204,7 @@ module.exports = {
197
204
  validate,
198
205
  duration,
199
206
  initialState,
207
+ accrue,
200
208
  observeBase,
201
209
  transitionLog,
202
210
  average,
@@ -0,0 +1,101 @@
1
+ const { it, expect } = require('bun:test');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const {
7
+ initialState,
8
+ accrue,
9
+ observeBase,
10
+ evaluate,
11
+ } = require('../src/lib/cpu-penalty');
12
+ const cpu = require('../src/lib/cpu-penalty-monitor');
13
+
14
+ const penalized = (lastAt) => ({
15
+ ...initialState(4, 0),
16
+ phase: 'penalized',
17
+ limitCpus: 2,
18
+ penaltyCount: 1,
19
+ penalizedMs: 1000,
20
+ lastAt,
21
+ });
22
+
23
+ it('accrue settles penalized time since the last tick', () => {
24
+ expect(accrue(penalized(1000), 4000)).toMatchObject({
25
+ penalizedMs: 4000,
26
+ lastAt: 4000,
27
+ });
28
+ expect(accrue(penalized(5000), 4000).penalizedMs).toBe(1000);
29
+ const observing = { ...penalized(1000), phase: 'observing' };
30
+ expect(accrue(observing, 4000).penalizedMs).toBe(1000);
31
+ });
32
+
33
+ it('a supervisor override keeps the penalized time before it', () => {
34
+ const state = observeBase(penalized(1000), 3, 4000);
35
+ expect(state.phase).toBe('observing');
36
+ expect(state.penalizedMs).toBe(4000);
37
+ const next = evaluate(state, {
38
+ now: 5000,
39
+ cores: 0,
40
+ daemonCpus: 6,
41
+ maxGapMs: 5000,
42
+ }).state;
43
+ expect(next.penalizedMs).toBe(4000);
44
+ });
45
+
46
+ for (const mode of ['graceful', 'supervise']) {
47
+ it(`graceful stop lifts at once and keeps penalizedMs: ${mode}`, () => {
48
+ if (process.platform === 'win32') {
49
+ return;
50
+ }
51
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-monitor-218-'));
52
+ try {
53
+ const logPath = path.join(dir, 'task.log');
54
+ fs.writeFileSync(logPath, '');
55
+ const record = {
56
+ logPath,
57
+ options: {
58
+ sessionName: 'cpu-task',
59
+ isolated: 'docker',
60
+ // Default 15-minute windows: the monitor ticks every 30 s.
61
+ cpuPenaltyConfig: {
62
+ cpus: 2,
63
+ trigger: 95,
64
+ release: 65,
65
+ triggerWindowMs: 900000,
66
+ releaseWindowMs: 900000,
67
+ },
68
+ baseResourceLimits: [],
69
+ },
70
+ };
71
+ let command = [
72
+ process.execPath,
73
+ require.resolve('../src/lib/cpu-penalty-monitor'),
74
+ '',
75
+ 'cpu-task',
76
+ '1',
77
+ JSON.stringify(record),
78
+ ];
79
+ if (mode === 'supervise') {
80
+ const script = `${cpu.startSnippet(null, 'cpu-task', 1, { ...record.options, logPath })}; read done; ${cpu.stopSnippet()}`;
81
+ command = ['sh', '-c', script];
82
+ }
83
+ const result = spawnSync(
84
+ 'python3',
85
+ [
86
+ path.resolve(__dirname, '../../experiments/issue-218-monitor.py'),
87
+ mode,
88
+ logPath,
89
+ ...command,
90
+ ],
91
+ { encoding: 'utf8' }
92
+ );
93
+ expect(result.status, result.stderr).toBe(0);
94
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
95
+ 'CPU penalty lifted: 4 CPUs (monitor stopped)'
96
+ );
97
+ } finally {
98
+ fs.rmSync(dir, { recursive: true, force: true });
99
+ }
100
+ }, 20000);
101
+ }