Coverage for src/lilbee/cli/tui/widgets/task_bar_controller.py: 100%

212 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

1"""TaskBarController and the per-task ProgressReporter.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import threading 

7from collections.abc import Callable 

8from enum import StrEnum 

9from typing import TYPE_CHECKING, Any 

10 

11from textual.app import App 

12 

13from lilbee.catalog.formatting import download_task_name 

14from lilbee.cli.tui import messages as msg 

15from lilbee.cli.tui.task_queue import Task, TaskQueue, TaskStatus, TaskType 

16from lilbee.cli.tui.thread_safe import call_from_thread 

17from lilbee.crawler import bootstrap_chromium, chromium_installed 

18from lilbee.runtime import asyncio_loop 

19from lilbee.runtime.cancellation import TaskCancelledError 

20from lilbee.runtime.progress import EventType, SetupProgressEvent 

21 

22if TYPE_CHECKING: 

23 from lilbee.catalog import CatalogModel 

24 

25log = logging.getLogger(__name__) 

26 

27# Each download runs in its own child process (catalog.download_process), so 

28# transfers neither share a xet session nor die with a cancelled sibling. 

29_DOWNLOAD_CONCURRENCY = 4 

30_BYTES_PER_MB = 1024 * 1024 

31 

32 

33class TaskOutcome(StrEnum): 

34 """How a task terminated. Passed from worker thread to finalizer.""" 

35 

36 DONE = "done" 

37 FAILED = "failed" 

38 CANCELLED = "cancelled" 

39 

40 

41class ProgressReporter: 

42 """Thread-safe handle a worker uses to report progress and check cancellation. 

43 

44 The worker only sees this object; it never touches ``self.app``, 

45 ``call_from_thread``, or any screen. Writes to the lock-protected 

46 ``TaskQueue`` so updates survive any UI navigation. 

47 """ 

48 

49 def __init__(self, controller: TaskBarController, task_id: str) -> None: 

50 self._controller = controller 

51 self._task_id = task_id 

52 

53 @property 

54 def task_id(self) -> str: 

55 return self._task_id 

56 

57 def is_set(self) -> bool: 

58 """True once the UI cancelled this task; the ``CancelSignal`` a download polls.""" 

59 task = self._controller.queue.get_task(self._task_id) 

60 return task is not None and task.status is TaskStatus.CANCELLED 

61 

62 def check_cancelled(self) -> None: 

63 """Raise ``TaskCancelledError`` if the task was cancelled from the UI.""" 

64 if self.is_set(): 

65 raise TaskCancelledError 

66 

67 def update( 

68 self, progress: float, detail: str = "", *, indeterminate: bool | None = None 

69 ) -> None: 

70 """Write a progress snapshot to the shared queue. 

71 

72 Raises ``TaskCancelledError`` first if the UI cancelled the task, so 

73 callers can use ``update`` as both a progress write and a cancel 

74 checkpoint. 

75 """ 

76 self.check_cancelled() 

77 self._controller.queue.update_task( 

78 self._task_id, progress, detail, indeterminate=indeterminate 

79 ) 

80 

81 

82TaskTarget = Callable[[ProgressReporter], None] 

83 

84 

85def _chromium_bootstrap_target(reporter: ProgressReporter) -> None: 

86 """Worker target for the SETUP task: run bootstrap_chromium with progress forwarding. 

87 

88 Module-level so ``TaskBarController.ensure_chromium`` stays short and 

89 tests can stub the target in isolation. 

90 """ 

91 

92 def _forward(event_type: EventType, data: Any) -> None: 

93 if event_type != EventType.SETUP_PROGRESS: 

94 return 

95 if not isinstance(data, SetupProgressEvent): 

96 return 

97 total = data.total_bytes or 0 

98 pct = int(data.downloaded_bytes * 100 / total) if total > 0 else 0 

99 mb = data.downloaded_bytes // _BYTES_PER_MB 

100 if total > 0: 

101 detail = msg.SETUP_CHROMIUM_DETAIL.format(done=mb, total=total // _BYTES_PER_MB) 

102 else: 

103 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format(done=mb) 

104 reporter.update(pct, detail) 

105 

106 asyncio_loop.run(bootstrap_chromium(on_progress=_forward)) 

107 

108 

109class TaskBarController: 

110 """App-level owner of the shared TaskQueue + all long-running work. 

111 

112 The controller is attached as ``app.task_bar`` during 

113 ``LilbeeApp.__init__``. All task lifecycle methods 

114 (add/update/complete/fail/cancel) go through here so every ``TaskBar`` 

115 widget sees the same state, and every long-running op is spawned by 

116 this controller: never by a screen that may dismiss mid-flight. 

117 """ 

118 

119 def __init__(self, app: App[Any]) -> None: 

120 self.app = app 

121 self.queue = TaskQueue(capacity={TaskType.DOWNLOAD.value: _DOWNLOAD_CONCURRENCY}) 

122 # task_id -> (target, on_success). Worker looks up its target here 

123 # so we don't capture in a closure that outlives the task. 

124 self._task_targets: dict[str, tuple[TaskTarget, Callable[[], None] | None]] = {} 

125 # Number of files in documents/ that are out of date with the store. 

126 # Set by start_detect_pending; read by TaskBar to render the 

127 # "N docs to sync · S to sync" hint when no live tasks are running. 

128 # Atomic int writes are safe under the GIL; the bar polls at 10 Hz. 

129 self.pending_sync_count: int = 0 

130 self._detect_thread: threading.Thread | None = None 

131 # Roles whose worker is currently in the spawn window (1-3 s cold 

132 # start). Surfaced as a single TaskBar hint instead of one toast 

133 # per role so the chat screen isn't drowned in implementation 

134 # detail on first prompt. 

135 self.spawning_roles: set[str] = set() 

136 

137 def add_task( 

138 self, 

139 name: str, 

140 task_type: str, 

141 fn: Callable[[], None] | None = None, 

142 *, 

143 indeterminate: bool = False, 

144 ) -> str: 

145 """Enqueue a task. Returns the new task_id.""" 

146 return self.queue.enqueue( 

147 fn or (lambda: None), name, task_type, indeterminate=indeterminate 

148 ) 

149 

150 def update_task( 

151 self, 

152 task_id: str, 

153 progress: float, 

154 detail: str = "", 

155 *, 

156 indeterminate: bool | None = None, 

157 ) -> None: 

158 """Update progress and detail text for a task.""" 

159 self.queue.update_task(task_id, progress, detail, indeterminate=indeterminate) 

160 

161 def complete_task(self, task_id: str) -> None: 

162 """Mark a task done. Row lingers in history until the user clears it.""" 

163 task_type = self._task_type_of(task_id) 

164 self.queue.complete_task(task_id) 

165 self._after_done_hooks(task_type) 

166 self._advance_all(task_type) 

167 

168 def fail_task(self, task_id: str, detail: str = "") -> None: 

169 """Mark a task failed. Row lingers in history until the user clears it.""" 

170 self.queue.fail_task(task_id, detail) 

171 self._advance_all(self._task_type_of(task_id)) 

172 

173 def cancel_task(self, task_id: str) -> None: 

174 """Mark a task cancelled; its worker notices at the next cancel checkpoint. 

175 

176 A download worker polls the row's status and terminates its child 

177 process, which stops the bytes; other workers raise out of their next 

178 progress update. 

179 """ 

180 started = task_id in self._task_targets 

181 task_type = self._task_type_of(task_id) 

182 self.queue.cancel(task_id) 

183 if not started: 

184 # Rows put straight on the queue have no worker whose exit advances 

185 # it, so the cancel must. A started row either has a worker that 

186 # finalizes and advances when it exits, or is queued and holds no 

187 # slot worth freeing. 

188 self._advance_all(task_type) 

189 

190 def _after_done_hooks(self, task_type: str | None) -> None: 

191 """Side effects triggered by a DONE completion. 

192 

193 Callable from both the direct ``complete_task`` convenience and 

194 the worker-thread ``_finalize_task`` path so every success route 

195 stays in sync. Does NOT advance the queue; each caller picks the 

196 advance strategy that fits its context (``_advance_all`` vs 

197 ``_try_start_next``). 

198 """ 

199 if task_type == TaskType.DOWNLOAD.value: 

200 self._notify_model_installed() 

201 elif task_type in (TaskType.SYNC.value, TaskType.WIKI.value): 

202 self.reload_wiki_screens() 

203 

204 def _task_type_of(self, task_id: str) -> str | None: 

205 task = self.queue.get_task(task_id) 

206 return task.task_type if task else None 

207 

208 def _advance_all(self, task_type: str | None) -> None: 

209 """Start the freed type's next task, then any other idle type's. 

210 

211 Promotion has to spawn the worker. A task advanced without one sits 

212 ACTIVE with no thread behind it, which renders as a live row that never 

213 progresses. 

214 """ 

215 if task_type: 

216 self._try_start_next(task_type) 

217 while (task := self.queue.advance()) is not None: 

218 self._spawn_task_worker(task.task_id) 

219 

220 def downloading_label_for(self, ref: str) -> str | None: 

221 """Return the task name if *ref*'s download is queued or active, else None. 

222 

223 ``ref`` is a model reference (catalog repo id or native GGUF 

224 ref); the helper maps it to the canonical 

225 :attr:`CatalogModel.display_name` and matches against 

226 in-flight DOWNLOAD tasks. The returned label is suitable for 

227 embedding in a user-facing toast. 

228 """ 

229 label = download_task_name(ref) 

230 if not label: 

231 return None 

232 for task in self.queue.active_tasks + self.queue.queued_tasks: 

233 if task.task_type == TaskType.DOWNLOAD.value and task.name == label: 

234 return task.name 

235 return None 

236 

237 def set_pending_sync(self, count: int) -> None: 

238 """Update the pending-sync count surfaced in the TaskBar hint.""" 

239 self.pending_sync_count = max(count, 0) 

240 

241 def clear_pending_sync(self) -> None: 

242 """Drop the pending hint. Called when sync starts so the bar shows live progress instead.""" 

243 self.pending_sync_count = 0 

244 

245 def mark_role_spawning(self, role: str) -> None: 

246 """Add *role* to the set of workers whose pool process is starting.""" 

247 self.spawning_roles.add(role) 

248 

249 def mark_role_spawned(self, role: str) -> None: 

250 """Drop *role* from the spawn-in-progress set; harmless if already absent.""" 

251 self.spawning_roles.discard(role) 

252 

253 def start_detect_pending(self) -> None: 

254 """Run the cheap sync-detection (filesystem walk + hash compare) on a daemon thread. 

255 

256 Writes the result via ``set_pending_sync``. No-op if a detect job 

257 is already running. Errors are logged and silently swallowed: a 

258 failed detect just leaves the previous count in place rather 

259 than blocking the UI. 

260 """ 

261 if self._detect_thread is not None and self._detect_thread.is_alive(): 

262 return 

263 thread = threading.Thread( 

264 target=self._run_detect_pending, daemon=True, name="detect-pending" 

265 ) 

266 self._detect_thread = thread 

267 thread.start() 

268 

269 def _run_detect_pending(self) -> None: 

270 # Local import: lilbee.data.ingest pulls in lancedb + the embedder 

271 # transitively; the TUI shouldn't pay for that just to import the 

272 # task bar widget. 

273 from lilbee.data.ingest import detect_pending 

274 

275 try: 

276 count = detect_pending() 

277 except Exception: 

278 log.warning("detect_pending failed", exc_info=True) 

279 return 

280 self.set_pending_sync(count) 

281 

282 def ensure_chromium(self, on_ready: Callable[[], None]) -> None: 

283 """Kick off a Chromium bootstrap if missing, then call ``on_ready``. 

284 

285 If Chromium is already installed, ``on_ready`` runs immediately on 

286 the caller's thread. Otherwise a single SETUP task is enqueued 

287 that runs ``bootstrap_chromium``; on success the controller 

288 invokes ``on_ready`` on the worker thread via the task's 

289 ``on_success`` hook. On failure the SETUP task surfaces as FAILED 

290 and ``on_ready`` is NOT called (the follow-up work shouldn't 

291 proceed against a missing browser). 

292 

293 bb-wq8g: the on_ready hook is how callers like ``_do_crawl`` chain 

294 their real work behind the one-time bootstrap. 

295 """ 

296 if chromium_installed(): 

297 on_ready() 

298 return 

299 

300 self.start_task( 

301 msg.SETUP_CHROMIUM_NAME, 

302 TaskType.SETUP, 

303 _chromium_bootstrap_target, 

304 indeterminate=False, 

305 on_success=on_ready, 

306 ) 

307 

308 def start_task( 

309 self, 

310 name: str, 

311 task_type: TaskType, 

312 target: TaskTarget, 

313 *, 

314 indeterminate: bool = False, 

315 on_success: Callable[[], None] | None = None, 

316 dedupe_key: str | None = None, 

317 ) -> str: 

318 """Enqueue a task, spawn its worker, return task_id. 

319 

320 The *target* receives a ``ProgressReporter`` as its only argument. 

321 It should periodically call ``reporter.update(percent, detail)`` and 

322 may call ``reporter.check_cancelled()`` to cooperatively abort. 

323 

324 On success (target returns normally) the queue marks the task DONE 

325 and ``on_success`` (if provided) runs after on the same worker 

326 thread. On ``TaskCancelledError`` the task is marked CANCELLED. On any 

327 other exception the task is marked FAILED with ``str(exc)`` as 

328 detail. Rows linger in the Task Center under their final status 

329 until the user presses capital ``C`` to clear; the bottom bar 

330 flashes the outcome once and then hides when idle. 

331 

332 Per-type capacity in ``TaskQueue`` (1 for every type) controls 

333 concurrency: a second task of the same type queues behind the first. 

334 """ 

335 task_id = self.queue.enqueue( 

336 lambda: None, 

337 name, 

338 task_type.value, 

339 indeterminate=indeterminate, 

340 dedupe_key=dedupe_key, 

341 ) 

342 if task_id in self._task_targets: 

343 # Deduplicated: the live task keeps its original target. 

344 return task_id 

345 self._task_targets[task_id] = (target, on_success) 

346 self._try_start_next(task_type.value) 

347 return task_id 

348 

349 def _try_start_next(self, task_type: str) -> None: 

350 """Promote queued tasks of this type into any free capacity slots.""" 

351 while (task := self.queue.advance(task_type)) is not None: 

352 self._spawn_task_worker(task.task_id) 

353 

354 def _spawn_task_worker(self, task_id: str) -> None: 

355 """Start a daemon thread for the task. Safe to call from any thread.""" 

356 if task_id not in self._task_targets: 

357 return 

358 thread = threading.Thread( 

359 target=self._run_task_worker, 

360 args=(task_id,), 

361 daemon=True, 

362 name=f"task-{task_id}", 

363 ) 

364 thread.start() 

365 

366 def _run_task_worker(self, task_id: str) -> None: 

367 """Body of the daemon worker thread.""" 

368 entry = self._task_targets.get(task_id) 

369 if entry is None: 

370 return 

371 target, on_success = entry 

372 task = self.queue.get_task(task_id) 

373 task_type = task.task_type if task is not None else None 

374 reporter = ProgressReporter(self, task_id) 

375 try: 

376 target(reporter) 

377 except TaskCancelledError: 

378 log.info("Task %s cancelled", task_id) 

379 self._post_finalize(task_id, TaskOutcome.CANCELLED, "", task_type) 

380 except Exception as exc: 

381 log.warning("Task %s failed: %s", task_id, exc) 

382 self._post_finalize(task_id, TaskOutcome.FAILED, str(exc), task_type) 

383 else: 

384 self._post_finalize(task_id, TaskOutcome.DONE, "", task_type) 

385 if on_success is not None: 

386 try: 

387 on_success() 

388 except Exception: 

389 log.warning("on_success for %s raised", task_id, exc_info=True) 

390 finally: 

391 self._task_targets.pop(task_id, None) 

392 

393 def _post_finalize( 

394 self, task_id: str, outcome: TaskOutcome, detail: str, task_type: str | None 

395 ) -> None: 

396 """Marshal finalization back to the main thread. 

397 

398 Main-thread execution matters because ``set_timer`` (used for the 

399 flash-then-remove cycle) isn't safe from workers. ``call_from_thread`` 

400 targets ``self.app``: the App is long-lived; screens are not. 

401 """ 

402 call_from_thread(self.app, self._finalize_task, task_id, outcome, detail, task_type) 

403 

404 def _finalize_task( 

405 self, task_id: str, outcome: TaskOutcome, detail: str, task_type: str | None 

406 ) -> None: 

407 """Mark the queue state, refresh dependents, promote next queued task. 

408 

409 Runs on the main thread. Atomically: free the active slot, notify 

410 anything downstream that needs a repaint (e.g. model dropdowns 

411 after a download lands), and advance the queue. Rows stay in 

412 history; the bottom bar flash expires on its own. Users clear 

413 finished rows from the Task Center manually. 

414 """ 

415 if outcome is TaskOutcome.DONE: 

416 self.queue.complete_task(task_id) 

417 self._after_done_hooks(task_type) 

418 elif outcome is TaskOutcome.FAILED: 

419 self.queue.fail_task(task_id, detail) 

420 elif outcome is TaskOutcome.CANCELLED: 

421 self.queue.cancel(task_id) 

422 if task_type: 

423 self._try_start_next(task_type) 

424 

425 def _notify_model_installed(self) -> None: 

426 """Refresh any ChatScreen's ModelBar so the new model is selectable. 

427 

428 The dropdowns are built once on mount from the registry; without 

429 this nudge, a freshly-downloaded model only appears after the 

430 user reopens the screen. A bar that is not mounted yet is no longer 

431 this caller's problem: refresh_model_bar is a no-op until the bar 

432 exists, and the bar scans on its own mount. 

433 """ 

434 # Late import to avoid a circular (ChatScreen imports this module). 

435 from lilbee.cli.tui.screens.chat import ChatScreen 

436 

437 for screen in self.app.screen_stack: 

438 # screen_stack is typed Screen[Any]; narrow at runtime to 

439 # locate the one screen that owns the ModelBar. 

440 if isinstance(screen, ChatScreen): 

441 screen.refresh_model_bar() 

442 break 

443 

444 def reload_wiki_screens(self) -> None: 

445 """Rescan open wiki and drafts screens after a task rewrote pages or drafts on disk.""" 

446 from textual.css.query import QueryError 

447 

448 from lilbee.cli.tui.screens.wiki import WikiScreen 

449 from lilbee.cli.tui.screens.wiki_drafts import WikiDraftsScreen 

450 

451 for screen in self.app.screen_stack: 

452 # screen_stack is typed Screen[Any]; narrow at runtime to the 

453 # wiki views that expose a reload. 

454 if isinstance(screen, WikiScreen | WikiDraftsScreen): 

455 try: 

456 screen.reload() 

457 except QueryError: 

458 log.debug("Wiki screen not mounted yet; skipping reload", exc_info=True) 

459 

460 def start_download( 

461 self, 

462 model: CatalogModel, 

463 *, 

464 allow_unsupported: bool = False, 

465 on_success: Callable[[], None] | None = None, 

466 ) -> str: 

467 """Enqueue a download; ``on_success`` runs on the worker thread once the file is on disk. 

468 

469 Re-requesting a download that is already queued or running returns that 

470 task's id and starts nothing. 

471 """ 

472 return self.start_task( 

473 model.display_name, 

474 TaskType.DOWNLOAD, 

475 lambda reporter: _download_target(reporter, model, allow_unsupported=allow_unsupported), 

476 on_success=on_success, 

477 dedupe_key=download_key(model), 

478 ) 

479 

480 def pending_download(self, model: CatalogModel) -> Task | None: 

481 """Return the queued-or-active download for *model*, if there is one.""" 

482 return self.queue.find_pending(TaskType.DOWNLOAD.value, download_key(model)) 

483 

484 

485def download_key(model: CatalogModel) -> str: 

486 """Identity of a pull: repo plus filename, since one repo ships several quants.""" 

487 return f"{model.hf_repo}::{model.gguf_filename}" 

488 

489 

490def _download_target( 

491 reporter: ProgressReporter, model: CatalogModel, *, allow_unsupported: bool = False 

492) -> None: 

493 """``start_task`` target for a HuggingFace model download. 

494 

495 Translates ``PermissionError`` into the gated-repo friendly message and 

496 ``UnsupportedArchError`` into a user-facing arch-mismatch message so 

497 every call site gets consistent error UX. 

498 """ 

499 from lilbee.app.models import pull_model_data 

500 from lilbee.catalog import DownloadProgress 

501 from lilbee.catalog.compat import UnsupportedArchError 

502 from lilbee.catalog.types import ModelSource 

503 

504 def _on_progress(p: DownloadProgress) -> None: 

505 reporter.update(p.percent, f"{model.display_name}: {p.detail}") 

506 

507 try: 

508 pull_model_data( 

509 model.ref, 

510 ModelSource.NATIVE, 

511 on_update=_on_progress, 

512 allow_unsupported=allow_unsupported, 

513 cancel=reporter, 

514 ) 

515 except PermissionError as exc: 

516 raise RuntimeError(msg.CATALOG_GATED_REPO.format(name=model.display_name)) from exc 

517 except UnsupportedArchError as exc: 

518 raise RuntimeError( 

519 f"Architecture {exc.architecture!r} not supported by this lilbee build." 

520 ) from exc