badgr-cli 1.0.30 → 1.0.32

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.
@@ -7,6 +7,7 @@ import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallbac
7
7
  * badgr run python train.py # gpu=auto, attached
8
8
  * badgr run python train.py --gpu A100 # specific GPU
9
9
  * badgr run --image my/image:latest --gpu L40S --detach
10
+ * badgr run python train.py --env HF_TOKEN=abc --env DATASET=my/data
10
11
  */
11
12
  export function parseRunArgs(args) {
12
13
  const flags = {};
@@ -27,11 +28,26 @@ export function parseRunArgs(args) {
27
28
  if (args[i] === '--no-expanded-search') { flags.noFallback = true; i++; continue; }
28
29
  if (args[i] === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
29
30
  if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
31
+ if (args[i] === '--env') {
32
+ const kv = args[++i]; i++;
33
+ if (!flags.env) flags.env = [];
34
+ flags.env.push(kv);
35
+ continue;
36
+ }
30
37
  positional.push(args[i++]);
31
38
  }
32
39
  return { flags, positional };
33
40
  }
34
41
 
42
+ function parseEnvFlag(envList) {
43
+ const obj = {};
44
+ for (const kv of (envList || [])) {
45
+ const idx = kv.indexOf('=');
46
+ if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
47
+ }
48
+ return obj;
49
+ }
50
+
35
51
  // Mirror of backend workload_profile.py — kept in sync for pre-flight display.
36
52
  const _PROFILES = {
37
53
  smoke_test: { label: 'smoke test', vram: '4 GB', gpus: ['RTX 3080', 'RTX 3090', 'RTX 4090'] },
@@ -60,6 +76,7 @@ function fmtRuntime(ms) {
60
76
 
61
77
  const HEARTBEAT_WARN_POLLS = 3;
62
78
  const HEARTBEAT_KILL_POLLS = 15;
79
+ const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
63
80
 
64
81
  export function classifyFailure(finalStatus, exitCode) {
65
82
  if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
@@ -68,7 +85,7 @@ export function classifyFailure(finalStatus, exitCode) {
68
85
  }
69
86
 
70
87
  // Lines the log stream never needs to print — we surface them in the status bar instead.
71
- const LOG_META_RE = /^\[dep-[^\]]+\] (status|gpu|region|endpoint|cost|receipt|provider_status|uptime)=/;
88
+ const LOG_META_RE = /^\[dep-[^\]]+\] (status|gpu|region|cost|receipt|provider_status|uptime)=/;
72
89
 
73
90
  // Extract structured values from provider status lines so we can show them nicely.
74
91
  function parseProviderLine(line) {
@@ -103,11 +120,10 @@ function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxR
103
120
  }
104
121
 
105
122
  // Wait for status to leave 'starting'/'queued'/'provisioning'.
106
- // The runtime limit does NOT start until this returns.
107
- // Returns the final dep object with status 'running' or 'failed'.
123
+ // Returns the dep once it leaves startup states (or the last known state on timeout).
108
124
  async function waitForRunning(config, depId, chalk) {
109
125
  const POLL_MS = 3000;
110
- const TIMEOUT_MS = 5 * 60 * 1000; // 5 min startup grace (image pull + container init)
126
+ const TIMEOUT_MS = 5 * 60 * 1000;
111
127
  const startMs = Date.now();
112
128
  const PHASES = [
113
129
  { afterMs: 0, label: ' Starting container' },
@@ -117,11 +133,9 @@ async function waitForRunning(config, depId, chalk) {
117
133
  ];
118
134
 
119
135
  let lastPhaseIdx = -1;
120
- const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
121
136
 
122
137
  const ticker = setInterval(() => {
123
138
  const elapsed = Date.now() - startMs;
124
- // Find the latest phase whose afterMs has been passed
125
139
  let phaseIdx = 0;
126
140
  for (let i = 0; i < PHASES.length; i++) {
127
141
  if (elapsed >= PHASES[i].afterMs) phaseIdx = i;
@@ -140,6 +154,7 @@ async function waitForRunning(config, depId, chalk) {
140
154
  const dep = await callApi(`/deployments/${depId}`, {
141
155
  apiKey: config.apiKey,
142
156
  baseUrl: config.baseUrl,
157
+ timeoutMs: 10_000,
143
158
  });
144
159
  if (!STARTUP_STATES.has(dep.status)) {
145
160
  process.stdout.write('\n');
@@ -151,14 +166,17 @@ async function waitForRunning(config, depId, chalk) {
151
166
  process.stdout.write('\n');
152
167
  }
153
168
 
154
- // Timed out — return last known state
155
- return await callApi(`/deployments/${depId}`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
169
+ return await callApi(`/deployments/${depId}`, {
170
+ apiKey: config.apiKey,
171
+ baseUrl: config.baseUrl,
172
+ timeoutMs: 10_000,
173
+ });
156
174
  }
157
175
 
158
- async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
176
+ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown, isShuttingDown }) {
159
177
  const TERMINAL = new Set(['stopped', 'failed', 'completed', 'succeeded']);
160
178
  const POLL_MS = 4000;
161
- let seenContent = new Set(); // track by content, not index, to avoid reprinting stale lines
179
+ let seenContent = new Set();
162
180
  let lastStatus = '';
163
181
  let consecutiveErrs = 0;
164
182
  let gpuUtil = null;
@@ -167,7 +185,9 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
167
185
  let statusBarActive = false;
168
186
  const startMs = Date.now();
169
187
 
170
- // Ticker: update status bar in place every second between polls.
188
+ // tearing: guards against double-teardown for cap/heartbeat paths within this function.
189
+ let tearing = false;
190
+
171
191
  let tickerInterval = null;
172
192
  const startTicker = () => {
173
193
  if (tickerInterval) return;
@@ -181,22 +201,20 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
181
201
  };
182
202
  const stopTicker = () => {
183
203
  if (tickerInterval) { clearInterval(tickerInterval); tickerInterval = null; }
184
- if (statusBarActive) { process.stdout.write('\r\x1b[2K'); statusBarActive = false; } // clear line
185
- };
186
-
187
- let tearing = false;
188
- const sigintHandler = () => {
189
- if (tearing) return;
190
- tearing = true;
191
- stopTicker();
192
- onTeardown('interrupted');
204
+ if (statusBarActive) { process.stdout.write('\r\x1b[2K'); statusBarActive = false; }
193
205
  };
194
- process.once('SIGINT', sigintHandler);
195
206
 
196
207
  try {
197
208
  while (true) {
209
+ // Exit loop if SIGINT handler has started shutdown externally.
210
+ if (isShuttingDown()) break;
211
+ if (tearing) break;
212
+
198
213
  await new Promise(r => setTimeout(r, POLL_MS));
199
214
 
215
+ if (isShuttingDown()) break;
216
+ if (tearing) break;
217
+
200
218
  const elapsedMs = Date.now() - startMs;
201
219
  const spentSoFar = ratePerHour * (elapsedMs / 3_600_000);
202
220
 
@@ -204,14 +222,14 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
204
222
  tearing = true;
205
223
  stopTicker();
206
224
  await onTeardown('max-cost');
207
- return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
225
+ return { status: 'capped', reason: 'max-cost', exitCode: null, runtimeMs: elapsedMs, failureType: null };
208
226
  }
209
227
 
210
228
  if (maxRuntimeMs !== null && elapsedMs >= maxRuntimeMs) {
211
229
  tearing = true;
212
230
  stopTicker();
213
231
  await onTeardown('max-runtime');
214
- return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
232
+ return { status: 'capped', reason: 'max-runtime', exitCode: null, runtimeMs: elapsedMs, failureType: null };
215
233
  }
216
234
 
217
235
  let dep;
@@ -219,6 +237,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
219
237
  dep = await callApi(`/deployments/${depId}`, {
220
238
  apiKey: config.apiKey,
221
239
  baseUrl: config.baseUrl,
240
+ timeoutMs: 10_000,
222
241
  });
223
242
  consecutiveErrs = 0;
224
243
  } catch {
@@ -233,7 +252,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
233
252
  tearing = true;
234
253
  stopTicker();
235
254
  await onTeardown('heartbeat-lost');
236
- return { status: 'failed', exitCode: null, runtimeMs: elapsedMs, failureType: 'infrastructure' };
255
+ return { status: 'failed', reason: 'heartbeat-lost', exitCode: null, runtimeMs: elapsedMs, failureType: 'infrastructure' };
237
256
  }
238
257
  }
239
258
  continue;
@@ -252,16 +271,15 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
252
271
  const logData = await callApi(`/deployments/${depId}/logs`, {
253
272
  apiKey: config.apiKey,
254
273
  baseUrl: config.baseUrl,
274
+ timeoutMs: 10_000,
255
275
  });
256
276
  const lines = logData?.logs ?? [];
257
277
 
258
278
  for (const line of lines) {
259
- // Extract structured provider values (gpu_util, ssh, etc.) from any line.
260
279
  const parsed = parseProviderLine(line);
261
280
  if (parsed.gpuUtil !== null) gpuUtil = parsed.gpuUtil;
262
281
  if (parsed.cpuUtil !== null) cpuUtil = parsed.cpuUtil;
263
282
 
264
- // Show SSH address once, prominently.
265
283
  if (parsed.ssh && !sshShown) {
266
284
  stopTicker();
267
285
  console.log(` ${chalk.bold('SSH:')} ${chalk.cyan(parsed.ssh)}`);
@@ -269,11 +287,9 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
269
287
  startTicker();
270
288
  }
271
289
 
272
- // Skip lines we've already printed and pure metadata lines.
273
290
  if (seenContent.has(line)) continue;
274
291
  seenContent.add(line);
275
292
  if (LOG_META_RE.test(line)) continue;
276
- // Skip provider util lines — they're shown in the status bar instead.
277
293
  if (/\b(gpu_util|cpu_util|provider_status|uptime)=/.test(line)) continue;
278
294
 
279
295
  const isErrorLine = /^error\b/i.test(line) || /Error response from daemon/i.test(line);
@@ -285,15 +301,14 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
285
301
  // logs not ready yet
286
302
  }
287
303
 
288
- // Start the ticker once the job is confirmed running.
289
304
  if (status === 'running') startTicker();
290
305
 
291
306
  if (TERMINAL.has(status)) {
292
307
  stopTicker();
293
- process.removeListener('SIGINT', sigintHandler);
294
308
  const exitCode = dep.exit_code ?? null;
295
309
  return {
296
310
  status,
311
+ reason: null,
297
312
  exitCode,
298
313
  runtimeMs: Date.now() - startMs,
299
314
  failureType: classifyFailure(status, exitCode),
@@ -302,8 +317,11 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
302
317
  }
303
318
  } finally {
304
319
  stopTicker();
305
- process.removeListener('SIGINT', sigintHandler);
306
320
  }
321
+
322
+ // Reached when SIGINT (isShuttingDown) or duplicate tearing flag breaks the loop.
323
+ // The SIGINT handleShutdown() is managing teardown + exit.
324
+ return { status: 'interrupted', reason: 'signal', exitCode: null, runtimeMs: Date.now() - startMs, failureType: null };
307
325
  }
308
326
 
309
327
  export async function runCommand(config, args, chalk) {
@@ -317,27 +335,45 @@ export async function runCommand(config, args, chalk) {
317
335
 
318
336
  requireApiKey(config);
319
337
 
338
+ // ── Validate flags early ───────────────────────────────────────────────────
339
+ if (flags.count !== undefined && (!Number.isFinite(flags.count) || flags.count < 1)) {
340
+ console.error(chalk.red(' ✗ --count must be an integer greater than 0'));
341
+ process.exitCode = 1;
342
+ return;
343
+ }
344
+ if (flags.maxCost !== undefined && (!Number.isFinite(flags.maxCost) || flags.maxCost <= 0)) {
345
+ console.error(chalk.red(' ✗ --max-cost must be a number greater than 0'));
346
+ process.exitCode = 1;
347
+ return;
348
+ }
349
+ if (flags.maxPrice !== undefined && (!Number.isFinite(flags.maxPrice) || flags.maxPrice <= 0)) {
350
+ console.error(chalk.red(' ✗ --max-price must be a number greater than 0'));
351
+ process.exitCode = 1;
352
+ return;
353
+ }
354
+ if (flags.region !== undefined && !['US', 'EU', 'AU'].includes(flags.region.toUpperCase())) {
355
+ console.error(chalk.red(' ✗ --region must be US, EU, or AU'));
356
+ process.exitCode = 1;
357
+ return;
358
+ }
359
+
320
360
  const command = positional.length > 0 ? positional : undefined;
321
- // Use alpine for trivial one-liners (7MB vs 50MB — much faster pull)
322
361
  const cmdStr = command ? command.join(' ') : '';
323
362
  const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
324
363
  const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
325
364
  const image = flags.image || (command ? inferredImage : undefined);
326
365
  const detach = flags.detach || false;
327
- const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
328
366
  const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
329
367
  const maxCost = flags.maxCost ?? null;
330
-
368
+ const envObj = parseEnvFlag(flags.env);
331
369
  const effectiveTier = normalizeTier(flags.tier);
332
370
 
333
- // ── Auto GPU selection (no --gpu specified) ────────────────────────────────
334
371
  let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'auto';
335
372
 
336
373
  if (gpu === 'auto') {
337
374
  console.log(chalk.bold('\n⚡ Running GPU job\n'));
338
375
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
339
376
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
340
- // Show workload estimate so the user knows what Badgr inferred.
341
377
  if (command) {
342
378
  const profKey = inferProfileFromCommand(cmdStr);
343
379
  const prof = _PROFILES[profKey];
@@ -349,7 +385,6 @@ export async function runCommand(config, args, chalk) {
349
385
  }
350
386
  console.log();
351
387
  } else {
352
- // Specific GPU requested — show header
353
388
  console.log(chalk.bold('\n⚡ Running GPU job\n'));
354
389
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
355
390
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
@@ -358,6 +393,7 @@ export async function runCommand(config, args, chalk) {
358
393
  if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
359
394
  if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
360
395
  if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
396
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
361
397
  console.log();
362
398
 
363
399
  if (!detach && !flags.maxRuntime && !maxCost) {
@@ -381,6 +417,7 @@ export async function runCommand(config, args, chalk) {
381
417
  max_price_per_hour: flags.maxPrice,
382
418
  name: flags.name,
383
419
  tier: tierOverride || effectiveTier,
420
+ ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
384
421
  };
385
422
  }
386
423
 
@@ -393,15 +430,20 @@ export async function runCommand(config, args, chalk) {
393
430
  effectiveTier,
394
431
  chalk,
395
432
  { thing: 'job', cmd: 'badgr run' },
433
+ { allowTier2Fallback: !flags.noFallback },
396
434
  );
397
435
  } catch (err) {
398
436
  if (err.isPaymentRequired) {
399
437
  console.error(chalk.yellow(err.message));
400
438
  const rerun = ['badgr run', ...args].join(' ');
401
439
  console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
402
- process.exit(1);
440
+ process.exitCode = 1;
441
+ return;
403
442
  }
404
- throw err;
443
+ // CapacityError message is pre-formatted with chalk
444
+ console.error(err.message);
445
+ process.exitCode = 1;
446
+ return;
405
447
  }
406
448
 
407
449
  const rcptId = dep.receipt_id || generateReceiptId();
@@ -438,26 +480,16 @@ export async function runCommand(config, args, chalk) {
438
480
  return;
439
481
  }
440
482
 
441
- // Wait through startup phases (queued → provisioning → starting → running).
442
- // The max-runtime clock does NOT start until this returns.
443
- const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
444
- if (STARTUP_STATES.has(dep.status)) {
445
- console.log();
446
- dep = await waitForRunning(config, dep.deployment_id, chalk);
447
- }
448
-
449
- if (dep.status === 'failed') {
450
- console.error(chalk.red('\n ✗ Container failed to start (infrastructure error).\n'));
451
- console.error(chalk.dim(' The backend retried automatically. All attempts failed.'));
452
- console.error(chalk.dim(` Contact support with receipt ID: ${rcptId}`));
453
- console.log();
454
- process.exit(1);
455
- }
456
-
483
+ // ── Teardown helper ────────────────────────────────────────────────────────
484
+ // Does NOT call process.exit() only terminates the deployment and updates receipt.
457
485
  const ratePerHour = dep.cost_per_hour || 0;
458
- console.log(chalk.dim('\n ── Running command (Ctrl+C to stop) ────────────────────────────\n'));
486
+ let teardownCalled = false;
487
+ let attachStart = Date.now();
459
488
 
460
489
  async function teardown(reason) {
490
+ if (teardownCalled) return;
491
+ teardownCalled = true;
492
+
461
493
  const labels = {
462
494
  'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
463
495
  'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
@@ -465,28 +497,77 @@ export async function runCommand(config, args, chalk) {
465
497
  'interrupted': chalk.yellow('\n Stopping job...'),
466
498
  };
467
499
  console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
500
+
468
501
  try {
469
502
  await terminateDeployment(config, dep.deployment_id);
470
503
  } catch {
471
- // best-effort
504
+ // terminateDeployment retries 3×; best-effort if all fail
472
505
  }
506
+
473
507
  const runtimeMs = Date.now() - attachStart;
474
508
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
475
- updateReceipt(rcptId, { status: reason, teardownStatus: 'terminated', runtimeSeconds: Math.round(runtimeMs / 1000), finalCost });
509
+ updateReceipt(rcptId, {
510
+ status: reason,
511
+ teardownStatus: 'terminated',
512
+ runtimeSeconds: Math.round(runtimeMs / 1000),
513
+ finalCost,
514
+ });
476
515
  console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
477
516
  console.log(chalk.dim(' Job stopped. Billing ended.\n'));
517
+ }
518
+
519
+ // ── SIGINT handler — installed immediately after we have a deployment ID ───
520
+ // Covers: queued, provisioning, starting, running phases.
521
+ // Does NOT call process.exit() inside teardown; exits here after await.
522
+ let shuttingDown = false;
523
+
524
+ async function handleShutdown(reason) {
525
+ if (shuttingDown) return;
526
+ shuttingDown = true;
527
+ try {
528
+ await teardown(reason);
529
+ } catch (err) {
530
+ console.error(chalk.red(` Warning: teardown may have failed: ${err.message}`));
531
+ }
478
532
  process.exit(reason === 'interrupted' ? 0 : 1);
479
533
  }
480
534
 
481
- const attachStart = Date.now();
535
+ process.once('SIGINT', () => { void handleShutdown('interrupted'); });
536
+
537
+ // ── Wait through startup phases ────────────────────────────────────────────
538
+ if (STARTUP_STATES.has(dep.status)) {
539
+ console.log();
540
+ dep = await waitForRunning(config, dep.deployment_id, chalk);
541
+ }
542
+
543
+ if (dep.status === 'failed') {
544
+ process.removeListener('SIGINT', handleShutdown);
545
+ console.error(chalk.red('\n ✗ Container failed to start (infrastructure error).\n'));
546
+ console.error(chalk.dim(' The backend retried automatically. All attempts failed.'));
547
+ console.error(chalk.dim(` Contact support with receipt ID: ${rcptId}`));
548
+ console.log();
549
+ process.exitCode = 1;
550
+ return;
551
+ }
552
+
553
+ console.log(chalk.dim('\n ── Running command (Ctrl+C to stop) ────────────────────────────\n'));
554
+
555
+ attachStart = Date.now();
482
556
  const { status: finalStatus, exitCode, runtimeMs, failureType } = await attachToJob(config, dep.deployment_id, {
483
557
  chalk,
484
558
  maxRuntimeMs,
485
559
  maxCost,
486
560
  ratePerHour,
487
561
  onTeardown: teardown,
562
+ isShuttingDown: () => shuttingDown,
488
563
  });
489
564
 
565
+ // Remove SIGINT handler — job is done (or SIGINT was handled)
566
+ process.removeListener('SIGINT', handleShutdown);
567
+
568
+ // 'interrupted' = SIGINT handler is managing teardown + exit — don't duplicate
569
+ if (finalStatus === 'interrupted') return;
570
+
490
571
  console.log();
491
572
 
492
573
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
@@ -508,6 +589,12 @@ export async function runCommand(config, args, chalk) {
508
589
  }
509
590
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
510
591
 
592
+ // 'capped' = max-runtime or max-cost path; teardown message already printed
593
+ if (finalStatus === 'capped') {
594
+ process.exitCode = 1;
595
+ return;
596
+ }
597
+
511
598
  if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
512
599
  if (failureType === 'infrastructure') {
513
600
  console.error(chalk.red(`\n ✗ Machine failure — this is not your code.\n`));
@@ -517,8 +604,11 @@ export async function runCommand(config, args, chalk) {
517
604
  console.error(chalk.dim(` Check logs: badgr logs ${dep.deployment_id}`));
518
605
  }
519
606
  console.log();
520
- process.exit(exitCode ?? 1);
521
- } else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
607
+ process.exitCode = exitCode ?? 1;
608
+ return;
609
+ }
610
+
611
+ if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
522
612
  try {
523
613
  await terminateDeployment(config, dep.deployment_id);
524
614
  } catch { /* already stopped */ }