fix several empty response ux deadlock issues by moving tasks to async ops

This commit is contained in:
vegu-ai-tools
2026-05-06 13:09:10 +03:00
parent ce0118934c
commit fe0826385a
6 changed files with 196 additions and 115 deletions

View File

@@ -39,6 +39,7 @@
- "Contextual Generate: Added a Cancel button to the generate dialog and moved generation to a background task so the interrupt signal is actually processed mid-generation (previously the websocket receive loop was blocked until the generation finished, defeating the purpose of any cancel)."
- "Autocomplete: Moved autocomplete (dialogue, narrative, and contextual generate variants) to a background task so the websocket receive loop is no longer blocked during typeahead generation. The status snackbar now shows a cancel button while autocomplete is running. Also fixes a deadlock where an empty model response during autocomplete would freeze the UI: the empty-response dialog (Retry/Cancel/Ignore) couldn't be processed because the receive loop was blocked, leaving the scene input permanently disabled regardless of which option was clicked."
- "UI Lock on Cancel: Fixed the scene input staying disabled after cancelling any background generation (autocomplete, narrator action, conversation turn, character progression, etc.). The set_loading wrapper now always clears the busy status on GenerationCancelled, not just when set_error is enabled, so the frontend's busy lock resolves and the input becomes usable immediately."
- "World State Manager / Editor: Fixed a deadlock when cancelling an LLM connection error dialog (Retry/Cancel/Ignore) raised during Refresh/Reset State (character and world reinforcements), Apply Template(s), Generate Dialogue Instructions, Regenerate History Entry, or Revise Message. These handlers ran the generation inline on the websocket receive loop, so the cancel response had nowhere to go and the operation hung until the underlying connection eventually timed out — leaving the World State card busy indefinitely. They now run as background tasks with a cancellable status snackbar, and the affected components clear their local busy state on cancel/error too."
- "Narrate Progress: Fixed narration sometimes generating screenplay-style dialogue entries instead of continuous narrative prose."
- "Advance Time: Fixed toolbar time advancement being completely non-functional due to a missing websocket handler. Also fixed invalid ISO 8601 duration strings for the 1 week and 2 weeks options."
- "Prompts UI: Fixed the template preview CodeMirror editor being clipped at the bottom across the Active, group, and LLM prompt template tabs, so the bottom scrollbar is fully visible."

View File

@@ -54,14 +54,20 @@ class EditorWebsocketHandler(Plugin):
if not message:
raise Exception("Message not found")
with RevisionContext(message.id):
info = RevisionInformation(
text=message.message,
character=character,
)
revised = await editor.revision_revise(info)
if isinstance(message, CharacterMessage):
if not revised.startswith(character.name + ":"):
revised = f"{character.name}: {revised}"
# Run in a background task so the websocket receive loop stays free
# to dispatch the cancel/retry/ignore dialog response — otherwise
# awaiting the dialog future inside this handler deadlocks the loop.
async def task_wrapper():
with RevisionContext(message.id):
info = RevisionInformation(
text=message.message,
character=character,
)
revised = await editor.revision_revise(info)
if isinstance(message, CharacterMessage):
if not revised.startswith(character.name + ":"):
revised = f"{character.name}: {revised}"
scene.edit_message(message.id, revised)
scene.edit_message(message.id, revised)
self.run_in_background(task_wrapper, "Revising message")

View File

@@ -1,7 +1,8 @@
import structlog
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING, Awaitable, Callable
from talemate.emit import emit
from talemate.exceptions import GenerationCancelled
from talemate.status import set_loading
import traceback
import pydantic
import asyncio
@@ -51,6 +52,43 @@ class Plugin:
if hasattr(cls, "sub_handlers"):
cls.sub_handlers = {}
@staticmethod
def consume_task_exception(task: asyncio.Task) -> None:
"""
Mark a background task's exception as retrieved to suppress the
"Task exception was never retrieved" warning at GC time. The
exception itself is already logged by the set_loading wrapper
(or the underlying coroutine's own error handling).
"""
if task.cancelled():
return
# Calling exception() is enough to mark it retrieved; the return value
# is intentionally discarded.
task.exception()
def run_in_background(
self,
coro_fn: Callable[[], Awaitable[None]],
message: str,
*,
cancellable: bool = True,
set_error: bool = True,
) -> asyncio.Task:
"""
Schedule coro_fn() as a background task wrapped in set_loading,
with consume_task_exception attached as a done-callback.
The handler that calls this can return immediately, freeing the
websocket receive loop to dispatch follow-up messages such as the
cancel/retry/ignore dialog response from the LLM client.
"""
wrapped = set_loading(
message, cancellable=cancellable, set_error=set_error
)(coro_fn)
task = asyncio.create_task(wrapped())
task.add_done_callback(self.consume_task_exception)
return task
async def signal_operation_failed(self, message: str, emit_status: bool = True):
self.websocket_handler.queue_put(
{

View File

@@ -418,21 +418,27 @@ class WorldStateManagerPlugin(
reset=payload.reset,
)
await self.world_state_manager.run_detail_reinforcement(
payload.name, payload.question, reset=payload.reset
)
# Run in a background task so the websocket receive loop stays free
# to dispatch the cancel/retry/ignore dialog response — otherwise
# awaiting the dialog future inside this handler deadlocks the loop.
async def task_wrapper():
await self.world_state_manager.run_detail_reinforcement(
payload.name, payload.question, reset=payload.reset
)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "character_detail_reinforcement_run",
"data": payload.model_dump(),
}
)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "character_detail_reinforcement_run",
"data": payload.model_dump(),
}
)
# resend character details
await self.handle_get_character_details({"name": payload.name})
await self.signal_operation_done()
# resend character details
await self.handle_get_character_details({"name": payload.name})
await self.signal_operation_done()
self.run_in_background(task_wrapper, "Refreshing reinforcement")
async def handle_delete_character_detail_reinforcement(self, data):
payload = CharacterDetailReinforcementPayload(**data)
@@ -609,33 +615,39 @@ class WorldStateManagerPlugin(
async def handle_run_world_state_reinforcement(self, data):
payload = WorldEntryReinforcementPayload(**data)
await self.world_state_manager.run_detail_reinforcement(
None, payload.question, payload.reset
)
# Run in a background task so the websocket receive loop stays free
# to dispatch the cancel/retry/ignore dialog response — otherwise
# awaiting the dialog future inside this handler deadlocks the loop.
async def task_wrapper():
await self.world_state_manager.run_detail_reinforcement(
None, payload.question, payload.reset
)
(
_,
reinforcement,
) = await self.world_state_manager.world_state.find_reinforcement(
payload.question, None
)
(
_,
reinforcement,
) = await self.world_state_manager.world_state.find_reinforcement(
payload.question, None
)
if not reinforcement:
log.error("Reinforcement not found", question=payload.question)
if not reinforcement:
log.error("Reinforcement not found", question=payload.question)
await self.signal_operation_done()
return
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "world_state_reinforcement_ran",
"data": reinforcement.model_dump(),
}
)
# resend world
await self.handle_get_world({})
await self.signal_operation_done()
return
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "world_state_reinforcement_ran",
"data": reinforcement.model_dump(),
}
)
# resend world
await self.handle_get_world({})
await self.signal_operation_done()
self.run_in_background(task_wrapper, "Refreshing world state")
async def handle_delete_world_state_reinforcement(self, data):
payload = WorldEntryReinforcementPayload(**data)
@@ -807,25 +819,31 @@ class WorldStateManagerPlugin(
log.debug("Apply world state template", payload=payload)
result = await self.world_state_manager.apply_template(
template=payload.template,
character_name=payload.character_name or "",
run_immediately=payload.run_immediately,
)
# Run in a background task so the websocket receive loop stays free
# to dispatch the cancel/retry/ignore dialog response — otherwise
# awaiting the dialog future inside this handler deadlocks the loop.
async def task_wrapper():
result = await self.world_state_manager.apply_template(
template=payload.template,
character_name=payload.character_name or "",
run_immediately=payload.run_immediately,
)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "template_applied",
"status": "done",
"data": payload.model_dump(),
"result": result.model_dump() if result else None,
}
)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "template_applied",
"status": "done",
"data": payload.model_dump(),
"result": result.model_dump() if result else None,
}
)
await self.handle_get_world({})
await self.handle_get_templates({})
await self.signal_operation_done()
await self.handle_get_world({})
await self.handle_get_templates({})
await self.signal_operation_done()
self.run_in_background(task_wrapper, "Applying template")
async def handle_save_template(self, data):
payload = SaveWorldStateTemplatePayload(**data)
@@ -895,27 +913,33 @@ class WorldStateManagerPlugin(
}
)
await self.world_state_manager.apply_templates(
payload.templates,
callback_start=callback_start,
callback_done=callback_done,
character_name=payload.character_name,
run_immediately=payload.run_immediately,
generation_options=payload.generation_options,
)
# Run in a background task so the websocket receive loop stays free
# to dispatch the cancel/retry/ignore dialog response — otherwise
# awaiting the dialog future inside this handler deadlocks the loop.
async def task_wrapper():
await self.world_state_manager.apply_templates(
payload.templates,
callback_start=callback_start,
callback_done=callback_done,
character_name=payload.character_name,
run_immediately=payload.run_immediately,
generation_options=payload.generation_options,
)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "templates_applied",
"source": payload.source,
"data": payload.model_dump(),
}
)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "templates_applied",
"source": payload.source,
"data": payload.model_dump(),
}
)
await self.handle_get_world({})
await self.handle_get_templates({})
await self.signal_operation_done()
await self.handle_get_world({})
await self.handle_get_templates({})
await self.signal_operation_done()
self.run_in_background(task_wrapper, "Applying templates")
async def handle_save_template_group(self, data):
payload = SaveWorldStateTemplateGroupPayload(**data)
@@ -967,25 +991,33 @@ class WorldStateManagerPlugin(
creator = get_agent("creator")
instructions = await creator.determine_character_dialogue_instructions(
character
)
# Run in a background task so the websocket receive loop stays free
# to dispatch the cancel/retry/ignore dialog response — otherwise
# awaiting the dialog future inside this handler deadlocks the loop.
async def task_wrapper():
instructions = await creator.determine_character_dialogue_instructions(
character
)
character.dialogue_instructions = instructions
character.dialogue_instructions = instructions
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "character_dialogue_instructions_generated",
"data": {
"name": payload.name,
"instructions": instructions,
},
}
)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "character_dialogue_instructions_generated",
"data": {
"name": payload.name,
"instructions": instructions,
},
}
)
await self.signal_operation_done()
self.scene.emit_status()
# signal_operation_done already emits scene status on the
# non-auto-save branch (and scene.save() emits it on the auto-save
# branch), so no trailing emit_status() is needed here.
await self.signal_operation_done()
self.run_in_background(task_wrapper, "Generating dialogue instructions")
async def handle_delete_character(self, data):
payload = SelectiveCharacterPayload(**data)

View File

@@ -161,24 +161,22 @@ class HistoryMixin:
log.debug("regenerate_history_entry", payload=payload)
try:
# Run in a background task so the websocket receive loop stays free
# to dispatch the cancel/retry/ignore dialog response — otherwise
# awaiting the dialog future inside this handler deadlocks the loop.
async def task_wrapper():
entry = await regenerate_history_entry(self.scene, payload.entry)
except Exception as e:
log.error("regenerate_history_entry", error=e)
await self.signal_operation_failed(str(e))
return
log.debug("regenerate_history_entry (done)", entry=entry)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "history_entry_regenerated",
"data": entry.model_dump(),
}
)
await self.signal_operation_done()
log.debug("regenerate_history_entry (done)", entry=entry)
self.websocket_handler.queue_put(
{
"type": "world_state_manager",
"action": "history_entry_regenerated",
"data": entry.model_dump(),
}
)
await self.signal_operation_done()
self.run_in_background(task_wrapper, "Regenerating history entry")
async def handle_inspect_history_entry(self, data):
"""

View File

@@ -429,6 +429,12 @@ export default {
else if (message.action === 'character_detail_reinforcement_deleted') {
this.$emit('require-scene-save');
}
else if (message.action === 'operation_done') {
// Clear busy on cancel/error too — the success-specific
// character_detail_reinforcement_run message isn't sent
// when the operation fails or is cancelled.
this.busy = false;
}
else if (message.action === 'template_applied' && message.source === this.source){
if(this.templateApplicatorCallback && message.status === 'done') {