fix: sshfs polling, WebSocket broadcast, prompt progress bar, resizable stats panel
- log_watcher: add --reopen-log mode (close/reopen each poll) for sshfs/network filesystems; fix nonlocal clients bug causing UnboundLocalError on first broadcast; fix list(clients) copy to prevent RuntimeError on concurrent set mutation - log_parser: capture progress=X.XX from prompt timing lines into Metrics.prompt_progress - frontend: show progress bar during processing_prompt state with PROMPT N% label; widen stats panel to 300px; remove model name truncation; add drag handle on right border of stats panel to resize between 280px–840px Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,13 @@ class LogWatcher:
|
||||
on_line: Callable[[str], Awaitable[None]],
|
||||
on_offline: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
offline_timeout: float = 60.0,
|
||||
reopen: bool = False,
|
||||
):
|
||||
self._path = Path(path)
|
||||
self._on_line = on_line
|
||||
self._on_offline = on_offline
|
||||
self._offline_timeout = offline_timeout
|
||||
self._reopen = reopen
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
|
||||
def start(self) -> asyncio.Task:
|
||||
@@ -33,6 +35,15 @@ class LogWatcher:
|
||||
async def _run(self):
|
||||
offline_since: Optional[float] = None
|
||||
|
||||
if self._reopen:
|
||||
await self._run_reopen()
|
||||
else:
|
||||
await self._run_keepopen()
|
||||
|
||||
async def _run_keepopen(self):
|
||||
"""Default: keep file open and poll readline(). Works on local filesystems."""
|
||||
offline_since: Optional[float] = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
with open(self._path, 'r', errors='replace') as f:
|
||||
@@ -54,7 +65,50 @@ class LogWatcher:
|
||||
offline_since = now
|
||||
elif now - offline_since >= self._offline_timeout and self._on_offline:
|
||||
await self._on_offline()
|
||||
offline_since = None # reset so we don't spam
|
||||
offline_since = None
|
||||
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def _run_reopen(self):
|
||||
"""Close and reopen each poll cycle. Use for network filesystems (e.g. sshfs)
|
||||
that cache file content at open time and don't propagate remote appends to
|
||||
held-open file descriptors."""
|
||||
offline_since: Optional[float] = None
|
||||
pos: int = -1 # -1 = not yet seeded
|
||||
|
||||
while True:
|
||||
try:
|
||||
with open(self._path, 'r', errors='replace') as f:
|
||||
f.seek(0, 2)
|
||||
end = f.tell()
|
||||
|
||||
if pos < 0:
|
||||
f.seek(max(0, end - self.SEED_BYTES))
|
||||
if f.tell() > 0:
|
||||
f.readline() # discard partial first line
|
||||
pos = f.tell()
|
||||
elif end < pos:
|
||||
pos = 0 # file truncated / rotated
|
||||
|
||||
f.seek(pos)
|
||||
while True:
|
||||
line = f.readline()
|
||||
if line:
|
||||
pos = f.tell()
|
||||
await self._on_line(line)
|
||||
else:
|
||||
break
|
||||
|
||||
offline_since = None
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
except OSError:
|
||||
now = asyncio.get_event_loop().time()
|
||||
if offline_since is None:
|
||||
offline_since = now
|
||||
elif now - offline_since >= self._offline_timeout and self._on_offline:
|
||||
await self._on_offline()
|
||||
offline_since = None
|
||||
await asyncio.sleep(min(self.FILE_RETRY_INTERVAL, self._offline_timeout))
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user