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.
- package/README.md +37 -18
- package/package.json +1 -1
- package/src/api.js +56 -15
- package/src/badgr.js +14 -0
- package/src/commands/run.js +151 -61
- package/src/commands/serve.js +189 -43
- package/src/fallback.js +41 -32
- package/tests/commands.test.js +52 -1
- package/tests/run-lifecycle.test.js +498 -0
- package/tests/serve-lifecycle.test.js +499 -0
package/src/commands/run.js
CHANGED
|
@@ -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|
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
|
|
155
|
-
|
|
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();
|
|
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
|
-
//
|
|
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; }
|
|
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: '
|
|
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: '
|
|
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.
|
|
440
|
+
process.exitCode = 1;
|
|
441
|
+
return;
|
|
403
442
|
}
|
|
404
|
-
|
|
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
|
-
//
|
|
442
|
-
//
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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.
|
|
521
|
-
|
|
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 */ }
|