hte-cli 0.2.31__py3-none-any.whl → 0.2.33__py3-none-any.whl
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.
- hte_cli/cli.py +164 -25
- hte_cli/image_utils.py +0 -52
- {hte_cli-0.2.31.dist-info → hte_cli-0.2.33.dist-info}/METADATA +1 -1
- {hte_cli-0.2.31.dist-info → hte_cli-0.2.33.dist-info}/RECORD +6 -6
- {hte_cli-0.2.31.dist-info → hte_cli-0.2.33.dist-info}/WHEEL +0 -0
- {hte_cli-0.2.31.dist-info → hte_cli-0.2.33.dist-info}/entry_points.txt +0 -0
hte_cli/cli.py
CHANGED
|
@@ -4,7 +4,9 @@ Uses Click for command parsing and Rich for pretty output.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
+
import signal
|
|
7
8
|
import sys
|
|
9
|
+
import threading
|
|
8
10
|
import webbrowser
|
|
9
11
|
|
|
10
12
|
import click
|
|
@@ -22,6 +24,83 @@ console = Console()
|
|
|
22
24
|
# Support email per spec
|
|
23
25
|
SUPPORT_EMAIL = "jacktpayne51@gmail.com"
|
|
24
26
|
|
|
27
|
+
# Warning before cap (15 minutes)
|
|
28
|
+
CAP_WARNING_SECONDS = 15 * 60
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CapEnforcer:
|
|
32
|
+
"""Background timer that enforces time cap on capped_completion tasks.
|
|
33
|
+
|
|
34
|
+
Shows warning 15 minutes before cap and terminates the task when cap is reached.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
time_cap_seconds: int,
|
|
40
|
+
start_time: float,
|
|
41
|
+
console: Console,
|
|
42
|
+
main_thread_id: int,
|
|
43
|
+
):
|
|
44
|
+
self.time_cap_seconds = time_cap_seconds
|
|
45
|
+
self.start_time = start_time
|
|
46
|
+
self.console = console
|
|
47
|
+
self.main_thread_id = main_thread_id
|
|
48
|
+
self._stop_event = threading.Event()
|
|
49
|
+
self._warning_shown = False
|
|
50
|
+
self._thread: threading.Thread | None = None
|
|
51
|
+
self.cap_reached = False
|
|
52
|
+
|
|
53
|
+
def start(self):
|
|
54
|
+
"""Start the background timer thread."""
|
|
55
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
56
|
+
self._thread.start()
|
|
57
|
+
|
|
58
|
+
def stop(self):
|
|
59
|
+
"""Stop the background timer."""
|
|
60
|
+
self._stop_event.set()
|
|
61
|
+
if self._thread:
|
|
62
|
+
self._thread.join(timeout=1.0)
|
|
63
|
+
|
|
64
|
+
def _run(self):
|
|
65
|
+
"""Timer loop that checks elapsed time."""
|
|
66
|
+
import time
|
|
67
|
+
|
|
68
|
+
warning_threshold = self.time_cap_seconds - CAP_WARNING_SECONDS
|
|
69
|
+
|
|
70
|
+
while not self._stop_event.is_set():
|
|
71
|
+
elapsed = time.monotonic() - self.start_time
|
|
72
|
+
|
|
73
|
+
# Show warning at 15 minutes before cap
|
|
74
|
+
if elapsed >= warning_threshold and not self._warning_shown:
|
|
75
|
+
self._warning_shown = True
|
|
76
|
+
remaining = self.time_cap_seconds - elapsed
|
|
77
|
+
minutes = int(remaining // 60)
|
|
78
|
+
self.console.print()
|
|
79
|
+
self.console.print(
|
|
80
|
+
f"[yellow bold]Warning: Time cap approaching - {minutes} minutes remaining[/yellow bold]"
|
|
81
|
+
)
|
|
82
|
+
self.console.print(
|
|
83
|
+
"[yellow]When cap is reached, session will end and you'll need to record progress in the web UI.[/yellow]"
|
|
84
|
+
)
|
|
85
|
+
self.console.print()
|
|
86
|
+
|
|
87
|
+
# Cap reached - terminate the main thread
|
|
88
|
+
if elapsed >= self.time_cap_seconds:
|
|
89
|
+
self.cap_reached = True
|
|
90
|
+
self.console.print()
|
|
91
|
+
self.console.print(
|
|
92
|
+
f"[red bold]Time cap reached ({self.time_cap_seconds // 60} minutes). Session ending.[/red bold]"
|
|
93
|
+
)
|
|
94
|
+
self.console.print(
|
|
95
|
+
"[yellow]Return to the web UI to record your progress and estimate completion time.[/yellow]"
|
|
96
|
+
)
|
|
97
|
+
# Send SIGINT to main thread to trigger KeyboardInterrupt
|
|
98
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
# Check every second
|
|
102
|
+
self._stop_event.wait(1.0)
|
|
103
|
+
|
|
25
104
|
|
|
26
105
|
def _find_eval_log_bytes(runner) -> bytes | None:
|
|
27
106
|
"""Find and read eval log bytes from runner's work directory.
|
|
@@ -45,7 +124,9 @@ def _find_eval_log_bytes(runner) -> bytes | None:
|
|
|
45
124
|
return None
|
|
46
125
|
|
|
47
126
|
|
|
48
|
-
def _upload_partial_log(
|
|
127
|
+
def _upload_partial_log(
|
|
128
|
+
api: APIClient, session_id: str, eval_log_bytes: bytes, console: Console
|
|
129
|
+
) -> None:
|
|
49
130
|
"""Upload partial eval log for interrupted session.
|
|
50
131
|
|
|
51
132
|
Best-effort: silently handles failures to not block exit.
|
|
@@ -282,7 +363,7 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
282
363
|
extract_image_platforms_from_compose,
|
|
283
364
|
pull_image_with_progress,
|
|
284
365
|
check_image_architecture_matches_host,
|
|
285
|
-
|
|
366
|
+
remove_image,
|
|
286
367
|
get_host_docker_platform,
|
|
287
368
|
is_running_in_linux_vm_on_arm,
|
|
288
369
|
)
|
|
@@ -406,34 +487,69 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
406
487
|
cached_images.append(img)
|
|
407
488
|
continue
|
|
408
489
|
else:
|
|
409
|
-
# Architecture mismatch detected -
|
|
490
|
+
# Architecture mismatch detected - remove and re-pull with correct arch
|
|
410
491
|
console.print(
|
|
411
492
|
f" [yellow]⚠[/yellow] {short_name} [yellow]architecture mismatch![/yellow]"
|
|
412
493
|
)
|
|
413
494
|
console.print(
|
|
414
495
|
f" [dim]Cached image: {image_arch} | Host: {host_arch}[/dim]"
|
|
415
496
|
)
|
|
416
|
-
console.print(
|
|
417
|
-
" [dim]Removing cached image and re-pulling correct architecture...[/dim]"
|
|
418
|
-
)
|
|
419
497
|
|
|
420
|
-
|
|
421
|
-
if
|
|
498
|
+
# Remove the wrongly-cached image
|
|
499
|
+
if not remove_image(img):
|
|
500
|
+
console.print(
|
|
501
|
+
f" [red]✗[/red] {short_name} [dim](failed to remove cached image)[/dim]"
|
|
502
|
+
)
|
|
503
|
+
failed_images.append(img)
|
|
504
|
+
pull_errors[img] = "failed to remove cached image"
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
# Re-pull with correct platform and progress display
|
|
508
|
+
last_status = ["connecting..."]
|
|
509
|
+
last_error = [""]
|
|
510
|
+
|
|
511
|
+
with console.status(
|
|
512
|
+
f"[yellow]↓[/yellow] {short_name} [dim]re-pulling for {host_arch}...[/dim]"
|
|
513
|
+
) as status:
|
|
514
|
+
|
|
515
|
+
def show_progress(image: str, line: str):
|
|
516
|
+
if ": " in line:
|
|
517
|
+
parts = line.split(": ", 1)
|
|
518
|
+
if len(parts) == 2:
|
|
519
|
+
layer_id = parts[0][-8:]
|
|
520
|
+
layer_status = parts[1][:85]
|
|
521
|
+
display = f"{layer_id}: {layer_status}"
|
|
522
|
+
if display != last_status[0]:
|
|
523
|
+
last_status[0] = display
|
|
524
|
+
status.update(
|
|
525
|
+
f"[yellow]↓[/yellow] {short_name} [dim]{display}[/dim]"
|
|
526
|
+
)
|
|
527
|
+
if "error" in line.lower() or "denied" in line.lower():
|
|
528
|
+
last_error[0] = line
|
|
529
|
+
|
|
530
|
+
success = pull_image_with_progress(
|
|
531
|
+
img, platform=host_platform, on_progress=show_progress
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
if success:
|
|
422
535
|
console.print(
|
|
423
|
-
f" [green]✓[/green] {short_name} [green]fixed![/green] [dim]({
|
|
536
|
+
f" [green]✓[/green] {short_name} [green]fixed![/green] [dim](re-pulled as {host_platform.split('/')[-1]})[/dim]"
|
|
424
537
|
)
|
|
425
538
|
pulled_images.append(img)
|
|
426
539
|
continue
|
|
427
|
-
|
|
428
|
-
#
|
|
429
|
-
# Re-pull the amd64 version and warn about QEMU
|
|
540
|
+
else:
|
|
541
|
+
# ARM re-pull failed - try without platform constraint (x86 fallback)
|
|
430
542
|
console.print(
|
|
431
|
-
" [dim]No ARM variant
|
|
543
|
+
" [dim]No ARM variant - trying x86 fallback...[/dim]"
|
|
432
544
|
)
|
|
433
|
-
|
|
545
|
+
with console.status(
|
|
546
|
+
f"[yellow]↓[/yellow] {short_name} [dim]pulling x86...[/dim]"
|
|
547
|
+
) as status:
|
|
548
|
+
success = pull_image_with_progress(img, on_progress=show_progress)
|
|
549
|
+
|
|
434
550
|
if success:
|
|
435
551
|
console.print(
|
|
436
|
-
f" [yellow]![/yellow] {short_name} [dim](x86-only
|
|
552
|
+
f" [yellow]![/yellow] {short_name} [dim](x86-only, needs QEMU)[/dim]"
|
|
437
553
|
)
|
|
438
554
|
x86_images_on_arm.append(img)
|
|
439
555
|
pulled_images.append(img)
|
|
@@ -442,14 +558,11 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
442
558
|
console.print(
|
|
443
559
|
f" [red]✗[/red] {short_name} [dim](failed to pull)[/dim]"
|
|
444
560
|
)
|
|
561
|
+
if last_error[0]:
|
|
562
|
+
console.print(f" [dim]{last_error[0][:60]}[/dim]")
|
|
563
|
+
pull_errors[img] = last_error[0]
|
|
445
564
|
failed_images.append(img)
|
|
446
|
-
pull_errors[img] = "failed to pull x86 fallback"
|
|
447
565
|
continue
|
|
448
|
-
else:
|
|
449
|
-
console.print(f" [red]✗[/red] {short_name} [dim]({fix_msg})[/dim]")
|
|
450
|
-
failed_images.append(img)
|
|
451
|
-
pull_errors[img] = fix_msg
|
|
452
|
-
continue
|
|
453
566
|
|
|
454
567
|
# Need to pull - show progress
|
|
455
568
|
last_status = ["connecting..."]
|
|
@@ -470,7 +583,7 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
470
583
|
parts = line.split(": ", 1)
|
|
471
584
|
if len(parts) == 2:
|
|
472
585
|
layer_id = parts[0][-8:]
|
|
473
|
-
layer_status = parts[1][:
|
|
586
|
+
layer_status = parts[1][:85] # Include full progress bar + size
|
|
474
587
|
display = f"{layer_id}: {layer_status}"
|
|
475
588
|
if display != last_status[0]:
|
|
476
589
|
last_status[0] = display
|
|
@@ -606,6 +719,23 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
606
719
|
|
|
607
720
|
events.docker_started()
|
|
608
721
|
|
|
722
|
+
# Start cap enforcer if this is a capped_completion task
|
|
723
|
+
time_cap_seconds = assignment.get("time_cap_seconds")
|
|
724
|
+
cap_enforcer: CapEnforcer | None = None
|
|
725
|
+
if time_cap_seconds and session_info.get("mode") == "capped_completion":
|
|
726
|
+
cap_enforcer = CapEnforcer(
|
|
727
|
+
time_cap_seconds=time_cap_seconds,
|
|
728
|
+
start_time=time.monotonic(),
|
|
729
|
+
console=console,
|
|
730
|
+
main_thread_id=threading.get_ident(),
|
|
731
|
+
)
|
|
732
|
+
cap_enforcer.start()
|
|
733
|
+
console.print(
|
|
734
|
+
f"[dim]Time cap: {time_cap_seconds // 60} minutes "
|
|
735
|
+
f"(warning at {(time_cap_seconds - CAP_WARNING_SECONDS) // 60} min)[/dim]"
|
|
736
|
+
)
|
|
737
|
+
console.print()
|
|
738
|
+
|
|
609
739
|
runner = TaskRunner()
|
|
610
740
|
eval_log_bytes = None
|
|
611
741
|
try:
|
|
@@ -624,9 +754,15 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
624
754
|
if eval_log_bytes:
|
|
625
755
|
_upload_partial_log(api, session_id, eval_log_bytes, console)
|
|
626
756
|
console.print()
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
757
|
+
# Different message if cap was reached vs user interrupt
|
|
758
|
+
if cap_enforcer and cap_enforcer.cap_reached:
|
|
759
|
+
console.print(
|
|
760
|
+
"[yellow]Time cap reached. Return to web UI to record progress and estimate completion time.[/yellow]"
|
|
761
|
+
)
|
|
762
|
+
else:
|
|
763
|
+
console.print(
|
|
764
|
+
"[yellow]Interrupted. Session remains active - you can reconnect later.[/yellow]"
|
|
765
|
+
)
|
|
630
766
|
sys.exit(0)
|
|
631
767
|
except Exception as e:
|
|
632
768
|
events.docker_stopped(exit_code=1)
|
|
@@ -637,6 +773,9 @@ def session_join(ctx, session_id: str, force_setup: bool):
|
|
|
637
773
|
console.print(f"[red]Task execution failed: {e}[/red]")
|
|
638
774
|
sys.exit(1)
|
|
639
775
|
finally:
|
|
776
|
+
# Stop cap enforcer
|
|
777
|
+
if cap_enforcer:
|
|
778
|
+
cap_enforcer.stop()
|
|
640
779
|
runner.cleanup()
|
|
641
780
|
|
|
642
781
|
events.docker_stopped(exit_code=0)
|
hte_cli/image_utils.py
CHANGED
|
@@ -222,58 +222,6 @@ def remove_image(image: str) -> bool:
|
|
|
222
222
|
return False
|
|
223
223
|
|
|
224
224
|
|
|
225
|
-
def fix_image_architecture(
|
|
226
|
-
image: str,
|
|
227
|
-
on_status: Callable[[str], None] | None = None,
|
|
228
|
-
) -> tuple[bool, str]:
|
|
229
|
-
"""
|
|
230
|
-
Check if a cached image has wrong architecture and fix it if needed.
|
|
231
|
-
|
|
232
|
-
For Linux ARM64 hosts (e.g., VM on Apple Silicon), this:
|
|
233
|
-
1. Checks if the cached image is amd64 when host is arm64
|
|
234
|
-
2. Removes the wrongly-cached image
|
|
235
|
-
3. Re-pulls with explicit --platform linux/arm64
|
|
236
|
-
|
|
237
|
-
Args:
|
|
238
|
-
image: Image name to check/fix
|
|
239
|
-
on_status: Callback for status updates
|
|
240
|
-
|
|
241
|
-
Returns:
|
|
242
|
-
Tuple of (needed_fix, message):
|
|
243
|
-
- needed_fix: True if image was re-pulled
|
|
244
|
-
- message: Description of what happened
|
|
245
|
-
"""
|
|
246
|
-
matches, image_arch, host_arch = check_image_architecture_matches_host(image)
|
|
247
|
-
|
|
248
|
-
if matches:
|
|
249
|
-
if image_arch:
|
|
250
|
-
return (False, f"architecture OK ({image_arch})")
|
|
251
|
-
else:
|
|
252
|
-
return (False, "not cached")
|
|
253
|
-
|
|
254
|
-
# Architecture mismatch detected
|
|
255
|
-
host_platform = get_host_docker_platform()
|
|
256
|
-
if not host_platform:
|
|
257
|
-
return (False, f"unknown host architecture: {host_arch}")
|
|
258
|
-
|
|
259
|
-
if on_status:
|
|
260
|
-
on_status(f"Cached image is {image_arch}, host is {host_arch} - re-pulling...")
|
|
261
|
-
|
|
262
|
-
# Remove the wrongly-cached image
|
|
263
|
-
logger.info(f"Removing wrongly-cached {image_arch} image: {image}")
|
|
264
|
-
if not remove_image(image):
|
|
265
|
-
return (False, f"failed to remove cached {image_arch} image")
|
|
266
|
-
|
|
267
|
-
# Re-pull with correct platform
|
|
268
|
-
logger.info(f"Re-pulling {image} with platform {host_platform}")
|
|
269
|
-
success = pull_image_with_progress(image, platform=host_platform)
|
|
270
|
-
|
|
271
|
-
if success:
|
|
272
|
-
return (True, f"re-pulled as {host_platform.split('/')[-1]}")
|
|
273
|
-
else:
|
|
274
|
-
return (False, f"failed to re-pull with platform {host_platform}")
|
|
275
|
-
|
|
276
|
-
|
|
277
225
|
def pull_image_with_progress(
|
|
278
226
|
image: str,
|
|
279
227
|
platform: str | None = None,
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
hte_cli/__init__.py,sha256=fDGXp-r8bIoLtlQnn5xJ_CpwMhonvk9bGjZQsjA2mDI,914
|
|
2
2
|
hte_cli/__main__.py,sha256=63n0gNGfskidWDU0aAIF2N8lylVCLYKVIkrN9QiORoo,107
|
|
3
3
|
hte_cli/api_client.py,sha256=VWl6xvP9X3Qj8Eki-7YOZqd_TkdgfhqJ-hB4BfSAveo,9881
|
|
4
|
-
hte_cli/cli.py,sha256=
|
|
4
|
+
hte_cli/cli.py,sha256=DkBAWm8mBSXqEQatjcqEXv90pjT-Z_4oBun7wjCPnGo,47506
|
|
5
5
|
hte_cli/config.py,sha256=42Xv__YMSeRLs2zhGukJkIXFKtnBtYCHnONfViGyt2g,3387
|
|
6
6
|
hte_cli/errors.py,sha256=1J5PpxcUKBu6XjigMMCPOq4Zc12tnv8LhAsiaVFWLQM,2762
|
|
7
7
|
hte_cli/events.py,sha256=oDKCS-a0IZ7bz7xkwQj5eM4DoDCYvnclAGohrMTWf8s,5644
|
|
8
|
-
hte_cli/image_utils.py,sha256=
|
|
8
|
+
hte_cli/image_utils.py,sha256=eiXD5wtYycLNUH36bAYANQ-t4_9PEBWht8OHt9rohuw,11160
|
|
9
9
|
hte_cli/runner.py,sha256=SWl9FF4X3e9eBbZyL0ujhmmSL5OK8J6st-Ty0jD5AWM,14550
|
|
10
10
|
hte_cli/scorers.py,sha256=B0ZjQ3Fh-VDkc_8CDc86yW7vpdimbV3RSqs7l-VeUIg,6629
|
|
11
11
|
hte_cli/version_check.py,sha256=WVZyGy2XfAghQYdd2N9-0Qfg-7pgp9gt4761-PnmacI,1708
|
|
12
|
-
hte_cli-0.2.
|
|
13
|
-
hte_cli-0.2.
|
|
14
|
-
hte_cli-0.2.
|
|
15
|
-
hte_cli-0.2.
|
|
12
|
+
hte_cli-0.2.33.dist-info/METADATA,sha256=kKudB7JkhFq_5ALnhXktggKQAeCwAz3lLuur8yHwzFI,3820
|
|
13
|
+
hte_cli-0.2.33.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
hte_cli-0.2.33.dist-info/entry_points.txt,sha256=XbyEEi1H14DFAt0Kdl22e_IRVEGzimSzYSh5HlhKlFA,41
|
|
15
|
+
hte_cli-0.2.33.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|