Fix stuck "thinking…" — background task GC dropped the done signal

The QRunnable was auto-deleted the instant its work finished, destroying its
signals object before Qt delivered the queued result to the UI thread, so the
"done" callback never fired and the command bar stayed disabled. Now tasks keep
a strong reference (autoDelete off) until the result is delivered, then drop it.

Also: the WoodShop reply summary is now logged to the transcript on success
(previously computed but never shown), and the error path re-enables input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 11:15:22 -03:00
parent e9422aa133
commit 7d01144143
2 changed files with 35 additions and 9 deletions

View File

@ -88,11 +88,17 @@ class CommandBar(QWidget):
def done(summary): def done(summary):
self._busy(False) self._busy(False)
if self.speak.isChecked() and summary: if summary:
self._log("ws", summary)
if self.speak.isChecked():
run_async(self.pool, lambda: subprocess.run( run_async(self.pool, lambda: subprocess.run(
["read-aloud", "--strip-md", "true"], input=summary, text=True)) ["read-aloud", "--strip-md", "true"], input=summary, text=True))
run_async(self.pool, work, on_done=done, on_error=lambda e: (self._busy(False), self._log("sys", e))) def failed(err):
self._busy(False)
self._log("sys", err)
run_async(self.pool, work, on_done=done, on_error=failed)
# ----- voice ------------------------------------------------------- # ----- voice -------------------------------------------------------
def _listen(self) -> None: def _listen(self) -> None:

View File

@ -1,5 +1,11 @@
"""Run slow work (dictate, the LLM call, read-aloud) off the Qt event loop so """Run slow work (dictate, the LLM call, read-aloud) off the Qt event loop so
the UI never freezes.""" the UI never freezes.
Lifetime note: a QRunnable is auto-deleted by the pool the moment run() returns,
which can destroy its signals object before Qt delivers the queued result to the
UI thread (the "done" callback then never fires). We disable auto-delete and
keep a strong reference until the result is delivered, then drop it.
"""
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
@ -15,6 +21,7 @@ class _Task(QRunnable):
super().__init__() super().__init__()
self.fn = fn self.fn = fn
self.signals = _Signals() self.signals = _Signals()
self.setAutoDelete(False) # we manage lifetime (see module docstring)
def run(self): def run(self):
try: try:
@ -23,10 +30,23 @@ class _Task(QRunnable):
self.signals.error.emit(str(exc)) self.signals.error.emit(str(exc))
_active: set[_Task] = set()
def run_async(pool: QThreadPool, fn, on_done=None, on_error=None) -> None: def run_async(pool: QThreadPool, fn, on_done=None, on_error=None) -> None:
task = _Task(fn) task = _Task(fn)
_active.add(task) # keep alive until a result is delivered on the UI thread
def finish_done(result):
_active.discard(task)
if on_done: if on_done:
task.signals.done.connect(on_done) on_done(result)
def finish_error(message):
_active.discard(task)
if on_error: if on_error:
task.signals.error.connect(on_error) on_error(message)
task.signals.done.connect(finish_done)
task.signals.error.connect(finish_error)
pool.start(task) pool.start(task)