Skip to content

Commit ba30177

Browse files
add better cli dashboard
1 parent a141274 commit ba30177

4 files changed

Lines changed: 140 additions & 26 deletions

File tree

src/codeevolve/cli.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,6 @@ def parse_args() -> argparse.Namespace:
7373
default=0,
7474
help="Checkpoint to load: 0 for new run, -1 for latest, or specific epoch number",
7575
)
76-
parser.add_argument(
77-
"--terminal_logging",
78-
action="store_true",
79-
help="Enable dynamic log display from all islands in terminal",
80-
)
8176
parser.add_argument(
8277
"--y",
8378
action="store_true",

src/codeevolve/runner.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from codeevolve.islands.sync import GlobalSyncData
2626
from codeevolve.utils.constants import CRASH_LOG_FILE
2727
from codeevolve.utils.lock import DirectoryLock
28-
from codeevolve.utils.logging import cli_logger
28+
from codeevolve.utils.logging import DashboardShutdown, ShutdownReason, cli_dashboard
2929

3030
# ---------------------------------------------------------------------------
3131
# Global cleanup state for signal handlers
@@ -93,7 +93,7 @@ def _cleanup_on_signal(signum: int, frame: Any) -> None:
9393
log_queue: Optional[mp.Queue] = _cleanup_state["log_queue"]
9494
if log_daemon and log_daemon.is_alive():
9595
if log_queue:
96-
log_queue.put(None)
96+
log_queue.put(DashboardShutdown(reason=ShutdownReason.INTERRUPTED))
9797
log_daemon.join(timeout=2.0)
9898
if log_daemon.is_alive():
9999
log_daemon.terminate()
@@ -220,7 +220,7 @@ def start_log_daemon(
220220
return None
221221

222222
log_daemon: mp.Process = mp.Process(
223-
target=cli_logger,
223+
target=cli_dashboard,
224224
args=(args, global_data, global_data.log_queue, num_islands),
225225
daemon=True,
226226
)
@@ -231,20 +231,25 @@ def start_log_daemon(
231231
def cleanup_log_daemon(
232232
log_daemon: Optional[mp.Process],
233233
log_queue: mp.Queue,
234+
shutdown: Optional[DashboardShutdown] = None,
234235
timeout: float = 2.0,
235236
) -> None:
236237
"""Shuts down the log daemon process.
237238
238-
Sends a sentinel value to signal shutdown, waits for termination,
239-
and force-terminates if necessary.
239+
Sends a :class:`~codeevolve.utils.logging.DashboardShutdown` sentinel to
240+
signal shutdown, waits for termination, and force-terminates if necessary.
240241
241242
Args:
242243
log_daemon: The logging daemon process to shut down (may be None).
243244
log_queue: Queue to send shutdown signal through.
245+
shutdown: Shutdown context forwarded to the dashboard so it can render
246+
the appropriate final banner. Defaults to
247+
``DashboardShutdown(ShutdownReason.FINISHED)`` when not provided.
244248
timeout: Maximum seconds to wait for shutdown.
245249
"""
246250
if log_daemon and log_daemon.is_alive():
247-
log_queue.put(None)
251+
sentinel = shutdown if shutdown is not None else DashboardShutdown(reason=ShutdownReason.FINISHED)
252+
log_queue.put(sentinel)
248253
log_daemon.join(timeout=timeout)
249254
if log_daemon.is_alive():
250255
log_daemon.terminate()
@@ -354,20 +359,21 @@ def monitor_island_processes(
354359
completed[i] = True
355360

356361
if process.exitcode != 0:
357-
cleanup_log_daemon(log_daemon, global_data.log_queue)
358-
359362
error_msg: str = (
360363
f"Island {i} died unexpectedly with exit code {process.exitcode}"
361364
)
365+
crash_log_path: str = str(out_dir / CRASH_LOG_FILE.format(time=time))
362366
_write_crash_summary(out_dir, i, process.exitcode, error_msg, time)
363367

364-
print(f"\n{'=' * 46} ERROR {'=' * 47}", file=sys.stderr)
365-
print(f"{error_msg}", file=sys.stderr)
366-
print(
367-
f"See {out_dir}/{CRASH_LOG_FILE.format(time=time)} and island logs for details.",
368-
file=sys.stderr,
368+
cleanup_log_daemon(
369+
log_daemon,
370+
global_data.log_queue,
371+
DashboardShutdown(
372+
reason=ShutdownReason.ERROR,
373+
error_msg=error_msg,
374+
crash_log_path=crash_log_path,
375+
),
369376
)
370-
print(f"{'='*100}\n", file=sys.stderr)
371377

372378
other_processes: List[mp.Process] = [
373379
other_process
@@ -378,12 +384,19 @@ def monitor_island_processes(
378384

379385
return 1
380386

381-
cleanup_log_daemon(log_daemon, global_data.log_queue)
382-
print("=" * 45 + " FINISHED " + "=" * 45)
387+
cleanup_log_daemon(
388+
log_daemon,
389+
global_data.log_queue,
390+
DashboardShutdown(reason=ShutdownReason.FINISHED),
391+
)
383392
return 0
384393

385394
except KeyboardInterrupt:
386-
cleanup_log_daemon(log_daemon, global_data.log_queue)
395+
cleanup_log_daemon(
396+
log_daemon,
397+
global_data.log_queue,
398+
DashboardShutdown(reason=ShutdownReason.INTERRUPTED),
399+
)
387400

388401
_terminate_processes(processes)
389402

src/codeevolve/utils/logging.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,45 @@
1616
import re
1717
import time
1818
from collections import deque
19+
from dataclasses import dataclass, field
20+
from enum import Enum, auto
1921
from pathlib import Path
2022
from typing import Any, Dict, Optional
2123

2224
from codeevolve.islands.sync import GlobalSyncData
2325
from codeevolve.utils.constants import ASCII_NAME, DEFAULT_MAX_LOG_MSG_SIZE, ISLAND_LOG_FILE
2426

27+
# ---------------------------------------------------------------------------
28+
# Dashboard shutdown signalling
29+
# ---------------------------------------------------------------------------
30+
31+
32+
class ShutdownReason(Enum):
33+
"""Reason why the CLI dashboard is being shut down."""
34+
35+
FINISHED = auto()
36+
ERROR = auto()
37+
INTERRUPTED = auto()
38+
39+
40+
@dataclass
41+
class DashboardShutdown:
42+
"""Sentinel message sent to the dashboard queue to trigger a clean shutdown.
43+
44+
Replaces the bare ``None`` sentinel so the dashboard can render an
45+
appropriate final banner depending on *why* it is being stopped.
46+
47+
Attributes:
48+
reason: The high-level cause of shutdown.
49+
error_msg: Human-readable error summary (populated for ERROR reason).
50+
crash_log_path: Path to the crash log file (populated for ERROR reason).
51+
"""
52+
53+
reason: ShutdownReason
54+
error_msg: Optional[str] = field(default=None)
55+
crash_log_path: Optional[str] = field(default=None)
56+
57+
2558
# ---------------------------------------------------------------------------
2659
# Time formatting utilities
2760
# ---------------------------------------------------------------------------
@@ -235,7 +268,7 @@ def get_logger(
235268

236269

237270
# ---------------------------------------------------------------------------
238-
# CLI dashboard logger
271+
# CLI dashboard
239272
# ---------------------------------------------------------------------------
240273

241274

@@ -262,7 +295,7 @@ def _print_global_status(args: Dict[str, Any], global_data: GlobalSyncData) -> N
262295
print(f"> GLOBAL EARLY STOPPING COUNTER = {global_data.early_stop_counter.value}")
263296

264297

265-
def cli_logger(
298+
def cli_dashboard(
266299
args: Dict[str, Any],
267300
global_data: GlobalSyncData,
268301
queue: mp.Queue,
@@ -276,6 +309,13 @@ def cli_logger(
276309
and display them in a continuously updating console dashboard showing the status
277310
of each island and global progress.
278311
312+
Shutdown is triggered by placing a :class:`DashboardShutdown` message on the
313+
queue. The ``reason`` field controls which final banner is rendered:
314+
315+
* ``FINISHED`` — normal algorithm completion
316+
* ``ERROR`` — an island crashed; ``error_msg`` and ``crash_log_path`` are shown
317+
* ``INTERRUPTED`` — the run was cancelled by the user or a signal
318+
279319
Args:
280320
args: Dictionary containing command-line arguments and configuration.
281321
global_data: Shared data structure containing global algorithm state.
@@ -293,9 +333,20 @@ def cli_logger(
293333
while True:
294334
while not queue.empty():
295335
message = queue.get_nowait()
296-
if message is None:
336+
if isinstance(message, DashboardShutdown):
297337
os.system("cls" if os.name == "nt" else "clear")
298338
_print_global_status(args, global_data)
339+
if message.reason == ShutdownReason.FINISHED:
340+
print("=" * 45 + " FINISHED " + "=" * 45)
341+
elif message.reason == ShutdownReason.ERROR:
342+
print(f"\n{'=' * 46} ERROR {'=' * 47}")
343+
if message.error_msg:
344+
print(message.error_msg)
345+
if message.crash_log_path:
346+
print(f"See {message.crash_log_path} and island logs for details.")
347+
print("=" * 100)
348+
elif message.reason == ShutdownReason.INTERRUPTED:
349+
print("=" * 43 + " INTERRUPTED " + "=" * 44)
299350
return
300351

301352
match = island_id_pattern.search(message)

tests/test_logging.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,20 @@
1111
# ===--------------------------------------------------------------------------------------===#
1212

1313
import logging
14+
import multiprocessing as mp
15+
import threading
16+
import time
1417
from pathlib import Path
15-
from typing import Optional
18+
from typing import Any, Dict, Optional
19+
from unittest.mock import MagicMock, patch
1620

1721
import pytest
1822

1923
from codeevolve.utils.logging import (
24+
DashboardShutdown,
25+
ShutdownReason,
2026
SizeLimitedFormatter,
27+
cli_dashboard,
2128
format_elapsed_time,
2229
get_logger,
2330
)
@@ -154,3 +161,51 @@ def test_logger_unique_names(self, tmp_path: Path):
154161
logger1: logging.Logger = get_logger(island_id=0, logs_dir=dir1)
155162
logger2: logging.Logger = get_logger(island_id=1, logs_dir=dir2)
156163
assert logger1.name != logger2.name
164+
165+
166+
# ---------------------------------------------------------------------------
167+
# DashboardShutdown
168+
# ---------------------------------------------------------------------------
169+
170+
171+
class TestDashboardShutdown:
172+
"""Test suite for the DashboardShutdown dataclass and ShutdownReason enum."""
173+
174+
def test_finished_defaults(self):
175+
"""Tests that FINISHED shutdown has None error fields by default."""
176+
shutdown: DashboardShutdown = DashboardShutdown(reason=ShutdownReason.FINISHED)
177+
assert shutdown.reason == ShutdownReason.FINISHED
178+
assert shutdown.error_msg is None
179+
assert shutdown.crash_log_path is None
180+
181+
def test_interrupted_defaults(self):
182+
"""Tests that INTERRUPTED shutdown has None error fields by default."""
183+
shutdown: DashboardShutdown = DashboardShutdown(reason=ShutdownReason.INTERRUPTED)
184+
assert shutdown.reason == ShutdownReason.INTERRUPTED
185+
assert shutdown.error_msg is None
186+
assert shutdown.crash_log_path is None
187+
188+
def test_error_with_details(self):
189+
"""Tests that ERROR shutdown carries error_msg and crash_log_path."""
190+
shutdown: DashboardShutdown = DashboardShutdown(
191+
reason=ShutdownReason.ERROR,
192+
error_msg="Island 1 died unexpectedly",
193+
crash_log_path="/out/crash_42.log",
194+
)
195+
assert shutdown.reason == ShutdownReason.ERROR
196+
assert shutdown.error_msg == "Island 1 died unexpectedly"
197+
assert shutdown.crash_log_path == "/out/crash_42.log"
198+
199+
def test_error_partial_fields(self):
200+
"""Tests that ERROR shutdown can be created with only some fields set."""
201+
shutdown: DashboardShutdown = DashboardShutdown(
202+
reason=ShutdownReason.ERROR,
203+
error_msg="crash",
204+
)
205+
assert shutdown.error_msg == "crash"
206+
assert shutdown.crash_log_path is None
207+
208+
def test_all_reasons_are_distinct(self):
209+
"""Tests that each ShutdownReason value is distinct."""
210+
reasons = {ShutdownReason.FINISHED, ShutdownReason.ERROR, ShutdownReason.INTERRUPTED}
211+
assert len(reasons) == 3

0 commit comments

Comments
 (0)