From 83ccf44117df3435be3e49c93e77edd434b0e342 Mon Sep 17 00:00:00 2001 From: shuki Date: Fri, 6 Mar 2026 21:40:56 +0200 Subject: [PATCH] Fix live log not updating: tail log file directly in OperationLog The poll timer was reading from job.output in memory, which depended on run_job's async task populating it. Now OperationLog reads new content directly from the log file using seek, making it independent of the async task. Also store the asyncio task reference to prevent GC. Co-Authored-By: Claude Opus 4.6 --- tui/jobs.py | 3 ++- tui/widgets/operation_log.py | 45 ++++++++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/tui/jobs.py b/tui/jobs.py index 010cf52..c43b16e 100644 --- a/tui/jobs.py +++ b/tui/jobs.py @@ -76,7 +76,8 @@ class JobManager: self._save_registry() def start_job(self, app, job: Job, *cli_args: str) -> None: - asyncio.create_task(self.run_job(app, job, *cli_args)) + task = asyncio.create_task(self.run_job(app, job, *cli_args)) + job._tail_task = task # prevent GC of the asyncio task async def run_job(self, app, job: Job, *cli_args: str) -> int: log_path = _work_dir() / f"gniza-job-{job.id}.log" diff --git a/tui/widgets/operation_log.py b/tui/widgets/operation_log.py index ba0a6e2..b895f3b 100644 --- a/tui/widgets/operation_log.py +++ b/tui/widgets/operation_log.py @@ -1,4 +1,5 @@ import asyncio +from pathlib import Path from rich.spinner import Spinner as RichSpinner from rich.text import Text @@ -41,7 +42,7 @@ class OperationLog(ModalScreen[None]): self._mounted_event = asyncio.Event() self._buffer: list[str] = [] self._poll_timer: Timer | None = None - self._last_line_count: int = 0 + self._file_pos: int = 0 def compose(self) -> ComposeResult: with Vertical(id="op-log"): @@ -54,18 +55,26 @@ class OperationLog(ModalScreen[None]): def on_mount(self) -> None: log = self.query_one("#ol-log", RichLog) - # If attached to a job, load existing output and start polling if self._job_id: from tui.jobs import job_manager job = job_manager.get_job(self._job_id) if job: - for line in job.output: - self._write_to_log(log, line) - self._last_line_count = len(job.output) + # Load existing content from log file + if job._log_file and Path(job._log_file).is_file(): + try: + content = Path(job._log_file).read_text() + self._file_pos = len(content.encode()) + for line in content.splitlines(): + self._write_to_log(log, line) + except OSError: + pass + elif job.output: + for line in job.output: + self._write_to_log(log, line) if job.status != "running": self.finish() else: - self._poll_timer = self.set_interval(0.2, self._poll_job) + self._poll_timer = self.set_interval(0.3, self._poll_job) # Flush any buffered writes for text in self._buffer: self._write_to_log(log, text) @@ -105,15 +114,27 @@ class OperationLog(ModalScreen[None]): job = job_manager.get_job(self._job_id) if not job: return - new_lines = job.output[self._last_line_count:] - self._last_line_count = len(job.output) - for line in new_lines: - self.write(line) + try: + log = self.query_one("#ol-log", RichLog) + except Exception: + return + # Read new content directly from log file + if job._log_file and Path(job._log_file).is_file(): + try: + with open(job._log_file, "r") as f: + f.seek(self._file_pos) + new_data = f.read() + if new_data: + self._file_pos += len(new_data.encode()) + for line in new_data.splitlines(): + self._write_to_log(log, line) + except OSError: + pass if job.status != "running": if job.return_code == 0: - self.write("\n[green]Operation completed successfully.[/green]") + self._write_to_log(log, "\n[green]Operation completed successfully.[/green]") else: - self.write(f"\n[red]Operation failed (exit code {job.return_code}).[/red]") + self._write_to_log(log, f"\n[red]Operation failed (exit code {job.return_code}).[/red]") self.finish() if self._poll_timer: self._poll_timer.stop()