badgr-cli 1.0.43 → 1.0.45

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.
@@ -8,6 +8,8 @@ import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
8
8
  import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
9
9
  import { formatCliError } from '../errors.js';
10
10
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
11
+ import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
12
+ import { detectWorkload, workloadTypeLabel } from '../detect.js';
11
13
 
12
14
  /**
13
15
  * Flow 1 — local project (primary):
@@ -55,6 +57,10 @@ export function parseRunArgs(args) {
55
57
  if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
56
58
  if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
57
59
  if (flagArgs[i] === '--cmd') { flags.cmd = flagArgs[++i]; i++; continue; }
60
+ if (flagArgs[i] === '--output') { flags.output = flagArgs[++i]; i++; continue; }
61
+ if (flagArgs[i] === '--checkpoint') { flags.checkpoint = flagArgs[++i]; i++; continue; }
62
+ if (flagArgs[i] === '--retry-safe') { flags.retrySafe = true; i++; continue; }
63
+ if (flagArgs[i] === '--resume-cmd') { flags.resumeCmd = flagArgs[++i]; i++; continue; }
58
64
  if (flagArgs[i] === '--env') {
59
65
  const kv = flagArgs[++i]; i++;
60
66
  if (!flags.env) flags.env = [];
@@ -105,6 +111,20 @@ const HEARTBEAT_WARN_POLLS = 3;
105
111
  const HEARTBEAT_KILL_POLLS = 15;
106
112
  const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
107
113
 
114
+ // Shared closing block for every terminal path (success, failure, cap, heartbeat loss) —
115
+ // always states exit code (when known), whether teardown/billing succeeded, and how to
116
+ // pull the receipt, so the user is never left guessing what happened.
117
+ function _printFinalInfo(chalk, { exitCode = undefined, teardownOk, jobId, rcptId, logsAvailable = true }) {
118
+ if (exitCode !== null && exitCode !== undefined) {
119
+ console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
120
+ }
121
+ console.log(` ${chalk.bold('Teardown:')} ${teardownOk ? chalk.green('succeeded') : chalk.red(`failed — run: badgr down ${jobId}`)}`);
122
+ console.log(` ${chalk.bold('Billing:')} ${teardownOk ? 'stopped' : 'unconfirmed — check receipt'}`);
123
+ if (logsAvailable) console.log(` ${chalk.bold('Logs:')} badgr logs ${jobId}`);
124
+ console.log(` ${chalk.bold('Job ID:')} ${jobId}`);
125
+ console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
126
+ }
127
+
108
128
  export function classifyFailure(finalStatus, exitCode) {
109
129
  if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
110
130
  if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'customer_code';
@@ -128,24 +148,6 @@ function parseProviderLine(line) {
128
148
  };
129
149
  }
130
150
 
131
- function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxRuntimeMs, maxCost }) {
132
- const spent = ratePerHour * (elapsedMs / 3_600_000);
133
- const parts = [`⏱ ${fmtRuntime(elapsedMs)}`];
134
- if (ratePerHour > 0) parts.push(`$${spent.toFixed(4)} spent`);
135
- if (gpuUtil !== null) parts.push(`GPU ${gpuUtil.toFixed(0)}%`);
136
- if (cpuUtil !== null) parts.push(`CPU ${cpuUtil.toFixed(0)}%`);
137
- if (maxRuntimeMs) {
138
- const left = Math.max(0, maxRuntimeMs - elapsedMs);
139
- parts.push(`${fmtRuntime(left)} left`);
140
- }
141
- if (maxCost && ratePerHour > 0) {
142
- const budgetLeft = Math.max(0, maxCost - spent);
143
- parts.push(`$${budgetLeft.toFixed(4)} budget left`);
144
- }
145
- parts.push('Ctrl+C to stop');
146
- return chalk.dim(' ' + parts.join(' • '));
147
- }
148
-
149
151
  // Wait for status to leave 'starting'/'queued'/'provisioning'.
150
152
  // Returns the dep once it leaves startup states (or the last known state on timeout).
151
153
  async function waitForRunning(config, depId, chalk) {
@@ -200,7 +202,7 @@ async function waitForRunning(config, depId, chalk) {
200
202
  });
201
203
  }
202
204
 
203
- async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown, isShuttingDown }) {
205
+ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown, isShuttingDown, stageLine }) {
204
206
  const TERMINAL = new Set(['stopped', 'failed', 'completed', 'succeeded']);
205
207
  const POLL_MS = 4000;
206
208
  let seenContent = new Set();
@@ -209,7 +211,8 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
209
211
  let gpuUtil = null;
210
212
  let cpuUtil = null;
211
213
  let sshShown = false;
212
- let statusBarActive = false;
214
+ let statusWord = 'running';
215
+ let blockLines = 0;
213
216
  const startMs = Date.now();
214
217
 
215
218
  // tearing: guards against double-teardown for cap/heartbeat paths within this function.
@@ -218,17 +221,17 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
218
221
  let tickerInterval = null;
219
222
  const startTicker = () => {
220
223
  if (tickerInterval) return;
221
- statusBarActive = true;
222
224
  tickerInterval = setInterval(() => {
223
- const bar = renderStatusBar(chalk, {
224
- elapsedMs: Date.now() - startMs, ratePerHour, gpuUtil, cpuUtil, maxRuntimeMs, maxCost,
225
- });
226
- process.stdout.write(`\r${bar} `);
225
+ const elapsedSec = Math.round((Date.now() - startMs) / 1000);
226
+ const spend = ratePerHour * (elapsedSec / 3600);
227
+ blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
228
+ stageLine, elapsedSec, statusWord, spend, id: depId,
229
+ }));
227
230
  }, 1000);
228
231
  };
229
232
  const stopTicker = () => {
230
233
  if (tickerInterval) { clearInterval(tickerInterval); tickerInterval = null; }
231
- if (statusBarActive) { process.stdout.write('\r\x1b[2K'); statusBarActive = false; }
234
+ if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
232
235
  };
233
236
 
234
237
  try {
@@ -271,6 +274,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
271
274
  consecutiveErrs++;
272
275
  if (lastStatus === 'running') {
273
276
  const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
277
+ if (consecutiveErrs >= HEARTBEAT_WARN_POLLS) statusWord = 'stuck';
274
278
  if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
275
279
  stopTicker();
276
280
  console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — cloud machine may be unresponsive`));
@@ -293,6 +297,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
293
297
  }
294
298
  lastStatus = status;
295
299
  }
300
+ if (status === 'running') statusWord = seenContent.size === 0 ? 'no logs yet' : 'running';
296
301
 
297
302
  try {
298
303
  const logData = await callApi(`/deployments/${depId}/logs`, {
@@ -339,6 +344,8 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
339
344
  exitCode,
340
345
  runtimeMs: Date.now() - startMs,
341
346
  failureType: classifyFailure(status, exitCode),
347
+ failureClass: dep.failure_class ?? null,
348
+ nextAction: dep.next_action ?? null,
342
349
  };
343
350
  }
344
351
  }
@@ -357,6 +364,7 @@ const _KNOWN_RUN_FLAGS = new Set([
357
364
  '--detach', '--fallback', '--no-fallback', '--strict-capacity',
358
365
  '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
359
366
  '--dry-run', '--cmd', '--save', '--workspace',
367
+ '--output', '--checkpoint', '--retry-safe', '--resume-cmd',
360
368
  ]);
361
369
 
362
370
  // Directories and files always excluded from project zip uploads.
@@ -497,10 +505,43 @@ export async function runCommand(config, args, chalk) {
497
505
  const isGitHubUrl = firstArg && _isGitHubUrl(firstArg);
498
506
  const isCodeSource = isLocalPath || isGitHubUrl;
499
507
 
508
+ // Local paths can be inspected on disk, so a missing --cmd doesn't have to be
509
+ // an error — try to infer it (and output/checkpoint conventions) the same way
510
+ // `badgr detect .` would, before ever provisioning anything.
511
+ let detectionReport = null;
512
+ if (isLocalPath && !flags.cmd) {
513
+ detectionReport = detectWorkload(firstArg);
514
+ if (detectionReport.command && detectionReport.confidence !== 'low') {
515
+ flags.cmd = detectionReport.command;
516
+ console.log(chalk.dim(`\n Detected command (${detectionReport.confidence} confidence): ${detectionReport.command}`));
517
+ console.log(chalk.dim(` Workload: ${workloadTypeLabel(detectionReport.workloadType)} • badgr detect . for the full report • --cmd to override`));
518
+ }
519
+ if (!flags.output && detectionReport.outputs?.length) flags.output = detectionReport.outputs[0];
520
+ if (!flags.checkpoint && detectionReport.checkpoints?.length) flags.checkpoint = detectionReport.checkpoints[0];
521
+ }
522
+
523
+ // Detection couldn't confidently name a command — ask for just that one
524
+ // missing essential (interactively, if we have a terminal to ask on).
525
+ if (isLocalPath && !flags.cmd && process.stdin.isTTY && process.stdout.isTTY) {
526
+ console.log(chalk.dim(`\n Badgr couldn't confidently detect a command to run in ${path.resolve(firstArg)}.`));
527
+ try {
528
+ const { input } = await import('@inquirer/prompts');
529
+ const answer = await input({
530
+ message: 'Which command should run?',
531
+ default: detectionReport?.command || undefined,
532
+ validate: v => v.trim() ? true : 'A command is required',
533
+ });
534
+ flags.cmd = answer.trim();
535
+ } catch {
536
+ // Ctrl+C or a non-interactive stdin that lied about isTTY — fall through to the hard error below.
537
+ }
538
+ }
539
+
500
540
  if (isCodeSource && !flags.cmd) {
501
541
  console.error(chalk.red(`\n ✗ --cmd is required when running from a ${isLocalPath ? 'local path' : 'GitHub URL'}.\n`));
502
542
  if (isLocalPath) {
503
543
  console.error(chalk.dim(' Example: badgr run . --cmd "python train.py" --max-cost 5\n'));
544
+ console.error(chalk.dim(' Or check what Badgr detects first: badgr detect .\n'));
504
545
  } else {
505
546
  console.error(chalk.dim(' Example: badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5\n'));
506
547
  }
@@ -550,6 +591,20 @@ export async function runCommand(config, args, chalk) {
550
591
  return;
551
592
  }
552
593
 
594
+ if (!flags.maxCost && !flags.dryRun && isLocalPath && process.stdin.isTTY && process.stdout.isTTY) {
595
+ try {
596
+ const { input } = await import('@inquirer/prompts');
597
+ const answer = await input({
598
+ message: 'What max cost ($) should cap this run?',
599
+ default: '5',
600
+ validate: v => (Number.isFinite(parseFloat(v)) && parseFloat(v) > 0) ? true : 'Enter a number greater than 0',
601
+ });
602
+ flags.maxCost = parseFloat(answer);
603
+ } catch {
604
+ // fall through to the hard error below
605
+ }
606
+ }
607
+
553
608
  if (!flags.maxCost && !flags.dryRun) {
554
609
  console.error(chalk.red('\n ✗ --max-cost is required for run workloads.\n'));
555
610
  console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --image node:20 --max-cost 5 -- node script.js\n'));
@@ -578,7 +633,16 @@ export async function runCommand(config, args, chalk) {
578
633
  const maxRuntimeMs = effectiveMaxRuntime * 60 * 1000;
579
634
 
580
635
  const maxCost = flags.maxCost ?? null;
581
- const envObj = parseEnvFlag(flags.env);
636
+ // BADGR_OUTPUT_DIR / BADGR_CHECKPOINT_DIR / BADGR_RETRY_SAFE are a
637
+ // convention for custom.run containers to write recoverable state outside
638
+ // the pod — Badgr doesn't enforce what the container does with them, it
639
+ // just wires the directories through and surfaces --resume-cmd on failure.
640
+ // Explicit --env always wins if the user passes the same key directly.
641
+ const conventionEnv = {};
642
+ if (flags.output) conventionEnv.BADGR_OUTPUT_DIR = flags.output;
643
+ if (flags.checkpoint) conventionEnv.BADGR_CHECKPOINT_DIR = flags.checkpoint;
644
+ if (flags.retrySafe) conventionEnv.BADGR_RETRY_SAFE = '1';
645
+ const envObj = { ...conventionEnv, ...parseEnvFlag(flags.env) };
582
646
  const effectiveTier = normalizeTier(flags.tier);
583
647
 
584
648
  const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : undefined;
@@ -595,6 +659,10 @@ export async function runCommand(config, args, chalk) {
595
659
  if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost}`);
596
660
  if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice}/hr`);
597
661
  if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
662
+ if (flags.output) console.log(` ${chalk.bold('Output:')} ${flags.output}`);
663
+ if (flags.checkpoint) console.log(` ${chalk.bold('Checkpoint:')} ${flags.checkpoint}`);
664
+ if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
665
+ if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
598
666
  console.log(chalk.dim('\n Remove --dry-run to provision.\n'));
599
667
  return;
600
668
  }
@@ -605,17 +673,20 @@ export async function runCommand(config, args, chalk) {
605
673
  if (flags.cmd) console.log(` ${chalk.bold('Command:')} ${flags.cmd}`);
606
674
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
607
675
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
608
- if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
609
- else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
676
+ if (flags.gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
610
677
  if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
611
678
  const runtimeLabel = isDefaultRuntime
612
- ? `${effectiveMaxRuntime}min ${chalk.dim('(default — use --max-runtime N to override)')}`
613
- : `${effectiveMaxRuntime}min`;
679
+ ? `${effectiveMaxRuntime} min ${chalk.dim('(default — use --max-runtime N to override)')}`
680
+ : `${effectiveMaxRuntime} min`;
681
+ console.log(` ${chalk.bold('Max cost:')} ${maxCost ? `$${maxCost.toFixed(2)}` : chalk.dim('none')}`);
614
682
  console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
615
- if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
683
+ console.log(` ${chalk.bold('Auto-stop:')} ${maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
616
684
  if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
617
685
  if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
618
686
  if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
687
+ if (flags.output) console.log(` ${chalk.bold('Output:')} ${flags.output}`);
688
+ if (flags.checkpoint) console.log(` ${chalk.bold('Checkpoint:')} ${flags.checkpoint}`);
689
+ if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
619
690
  console.log();
620
691
 
621
692
 
@@ -636,6 +707,10 @@ export async function runCommand(config, args, chalk) {
636
707
  }
637
708
  }
638
709
 
710
+ // Total stages: local-path runs get a "Preparing upload" stage the others don't.
711
+ const STAGE_TOTAL = isLocalPath ? 5 : 4;
712
+ let stageN = 1;
713
+
639
714
  // ── Upload local project zip (Flow 1) ─────────────────────────────────────
640
715
  let codeUri = null;
641
716
  if (isLocalPath) {
@@ -646,9 +721,10 @@ export async function runCommand(config, args, chalk) {
646
721
  process.exitCode = 1;
647
722
  return;
648
723
  }
724
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Preparing upload...')));
725
+ stageN++;
649
726
  }
650
727
 
651
- console.log(chalk.dim(' Finding suitable capacity...'));
652
728
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
653
729
  console.log(chalk.dim(` API: ${config.baseUrl}`));
654
730
  }
@@ -717,12 +793,10 @@ export async function runCommand(config, args, chalk) {
717
793
 
718
794
  const rate = dep.cost_per_hour || 0;
719
795
 
720
- console.log(chalk.dim(' Capacity found.\n'));
721
- console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
722
- console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
723
- if (rate > 0) console.log(` ${chalk.bold('Rate:')} $${rate.toFixed(2)}/hr`);
724
- if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
725
- console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
796
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Finding a working route...')));
797
+ stageN++;
798
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Starting runtime...')));
799
+ stageN++;
726
800
 
727
801
  if (rate > HIGH_RATE_THRESHOLD && !maxCost) {
728
802
  console.log(chalk.yellow(`\n Selected capacity rate: $${rate.toFixed(2)}/hr`));
@@ -745,30 +819,35 @@ export async function runCommand(config, args, chalk) {
745
819
  if (teardownCalled) return;
746
820
  teardownCalled = true;
747
821
 
748
- const labels = {
749
- 'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
750
- 'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
751
- 'heartbeat-lost': chalk.red('\n ✗ No response from machine — stopping job...'),
752
- 'interrupted': chalk.yellow('\n Stopping job...'),
822
+ const stageLabels = {
823
+ 'max-runtime': 'Stopped runtime cap reached',
824
+ 'max-cost': 'Stopped spend cap reached',
825
+ 'heartbeat-lost': 'Failed lost connection to machine',
826
+ 'interrupted': 'Stopped',
753
827
  };
754
- console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
755
828
 
829
+ let teardownOk = true;
756
830
  try {
757
831
  await terminateDeployment(config, dep.deployment_id);
758
832
  } catch {
759
833
  // terminateDeployment retries 3×; best-effort if all fail
834
+ teardownOk = false;
760
835
  }
761
836
 
762
837
  const runtimeMs = Date.now() - attachStart;
763
838
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
764
839
  updateReceipt(rcptId, {
765
840
  status: reason,
766
- teardownStatus: 'terminated',
841
+ teardownStatus: teardownOk ? 'terminated' : 'failed',
767
842
  runtimeSeconds: Math.round(runtimeMs / 1000),
768
843
  finalCost,
769
844
  });
770
- console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
771
- console.log(chalk.dim(' Job stopped. Billing ended.\n'));
845
+ console.log();
846
+ console.log(chalk.dim(_stage(STAGE_TOTAL, STAGE_TOTAL, stageLabels[reason] ?? 'Stopped')));
847
+ console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
848
+ _printFinalInfo(chalk, { exitCode: null, teardownOk, jobId: dep.deployment_id, rcptId });
849
+ if (flags.resumeCmd) console.log(` ${chalk.bold('Resume:')} ${flags.resumeCmd}`);
850
+ console.log();
772
851
  }
773
852
 
774
853
  // ── SIGINT handler — installed immediately after we have a deployment ID ───
@@ -798,20 +877,23 @@ export async function runCommand(config, args, chalk) {
798
877
  if (dep.status === 'failed') {
799
878
  process.removeListener('SIGINT', handleShutdown);
800
879
  console.error(formatCliError('JOB_INFRASTRUCTURE_FAILURE', { receiptId: rcptId }, chalk));
880
+ _printFailureClass(chalk, dep);
801
881
  process.exitCode = 1;
802
882
  return;
803
883
  }
804
884
 
805
- console.log(chalk.dim('\n ── Running command (Ctrl+C to stop) ────────────────────────────\n'));
885
+ const runStageLine = _stage(stageN, STAGE_TOTAL, 'Running command...');
886
+ console.log(chalk.dim(`\n [${stageN}/${STAGE_TOTAL}] Running command (Ctrl+C to stop)`));
806
887
 
807
888
  attachStart = Date.now();
808
- const { status: finalStatus, exitCode, runtimeMs, failureType } = await attachToJob(config, dep.deployment_id, {
889
+ const { status: finalStatus, exitCode, runtimeMs, failureType, failureClass, nextAction } = await attachToJob(config, dep.deployment_id, {
809
890
  chalk,
810
891
  maxRuntimeMs,
811
892
  maxCost,
812
893
  ratePerHour,
813
894
  onTeardown: teardown,
814
895
  isShuttingDown: () => shuttingDown,
896
+ stageLine: runStageLine,
815
897
  });
816
898
 
817
899
  // Remove SIGINT handler — job is done (or SIGINT was handled)
@@ -820,7 +902,11 @@ export async function runCommand(config, args, chalk) {
820
902
  // 'interrupted' = SIGINT handler is managing teardown + exit — don't duplicate
821
903
  if (finalStatus === 'interrupted') return;
822
904
 
823
- console.log();
905
+ // 'capped' = max-runtime or max-cost path; teardown() already printed the full summary
906
+ if (finalStatus === 'capped') {
907
+ process.exitCode = 1;
908
+ return;
909
+ }
824
910
 
825
911
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
826
912
  updateReceipt(rcptId, {
@@ -832,20 +918,7 @@ export async function runCommand(config, args, chalk) {
832
918
  teardownStatus: (finalStatus === 'completed' || finalStatus === 'succeeded') ? 'terminated' : 'failed',
833
919
  });
834
920
 
835
- console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
836
- if (ratePerHour > 0) {
837
- console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
838
- }
839
- if (exitCode !== null && exitCode !== undefined) {
840
- console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
841
- }
842
- console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
843
-
844
- // 'capped' = max-runtime or max-cost path; teardown message already printed
845
- if (finalStatus === 'capped') {
846
- process.exitCode = 1;
847
- return;
848
- }
921
+ console.log();
849
922
 
850
923
  if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
851
924
  if (failureType === 'infrastructure') {
@@ -853,16 +926,25 @@ export async function runCommand(config, args, chalk) {
853
926
  } else {
854
927
  console.error(formatCliError('JOB_FAILED', { exitCode, deploymentId: dep.deployment_id }, chalk));
855
928
  }
929
+ _printFailureClass(chalk, { failure_class: failureClass, next_action: nextAction });
930
+ console.log(chalk.dim(_stage(STAGE_TOTAL, STAGE_TOTAL, 'Failed')));
931
+ console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
932
+ // The container already reached a terminal state on the provider side by the
933
+ // time we observe it here, so billing is already stopped — no extra teardown call needed.
934
+ _printFinalInfo(chalk, { exitCode, teardownOk: true, jobId: dep.deployment_id, rcptId });
935
+ if (flags.resumeCmd) console.log(` ${chalk.bold('Resume:')} ${flags.resumeCmd}`);
856
936
  process.exitCode = exitCode ?? 1;
857
937
  return;
858
938
  }
859
939
 
860
940
  if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
941
+ let teardownOk = true;
861
942
  try {
862
943
  await terminateDeployment(config, dep.deployment_id);
863
- } catch { /* already stopped */ }
864
- console.log(chalk.green(`\n ✓ Complete`));
865
- console.log(chalk.dim(` Billing ended`));
944
+ } catch { teardownOk = false; /* already stopped, or best-effort */ }
945
+ console.log(chalk.green(_stage(STAGE_TOTAL, STAGE_TOTAL, 'Complete')));
946
+ console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
947
+ _printFinalInfo(chalk, { exitCode, teardownOk, jobId: dep.deployment_id, rcptId });
866
948
 
867
949
  if (flags.save && config.apiKey) {
868
950
  try {