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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 21:40:56 +02:00
parent d60c2f06aa
commit 83ccf44117
2 changed files with 35 additions and 13 deletions

View File

@@ -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"

View File

@@ -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()