hermes-agent/agent/turn_queue.py
ethernet 8b1c1dfa1d fix(desktop): review fixes for the agent-side TurnQueue
Review findings on the TurnQueue PR, fixed in one pass:

1. "Send now" on an idle session was a silent no-op: session.queue.promote
   reordered the queue but never drained it, so the promoted entry (and the
   drainNextQueued rescue gesture built on it) just sat there. Promote now
   fires _drain_queued_prompt in a thread when the session is idle, same as
   an idle session.queue.add.

2. A drained entry whose dispatch raised was lost: _drain_queued_prompt
   popped the entry and emitted queue.drained (painting a user turn in the
   client transcript) before _run_prompt_submit. On exception the entry was
   gone and the transcript lied. The drain now requeues the entry at the
   head (same id, so client mirrors stay consistent) via the new
   TurnQueue.requeue_front(), and queue.drained is only emitted after a
   successful dispatch.

3. Multi-line steers never settled: settlePendingSteer split the applied
   text into a line-set, so an entry that itself contained newlines
   (Cmd+Enter on a multi-line draft) matched nothing and pinned a
   "Steering..." row forever. Now matches by whole-entry containment, plus a
   message.complete backstop sweep (a steer can't outlive its turn: applied,
   dropped, or re-queued as the next turn).

4. Speculative surface removed per the contribution rubric: QueuedTurn.mode
   (written, never read), QueuedTurn.attachments (clients resolve
   attachments to @file: refs at enqueue time), enqueue_front() (replaced by
   the requeue_front() that finding 2 actually needs), and the keep_queue
   param on session.interrupt (documented for a promote+interrupt flow that
   actually interrupts via agent.interrupt() directly, so it was dead).

Also: unused sessionId arg dropped from useComposerQueue, the steer-event
lambda no longer shadows the enclosing text parameter, and a rejected
session.queue.add now surfaces an i18n'd error toast instead of silently
no-oping (draft is kept either way).

Tests: idle-promote drains immediately, failed dispatch requeues at head
without emitting queue.drained, interrupt clears the queue, multi-line
steer settles. 16 gateway tests pass; desktop tsc/eslint/vitest clean.
2026-07-15 17:54:21 -04:00

173 lines
6.2 KiB
Python

"""TurnQueue — a thread-safe FIFO of prompts to run as the next turn(s).
This is the agent-side queue that unifies message queueing across CLI, TUI
gateway, desktop, and messaging platforms. It lives on the AIAgent instance,
alongside the existing ``_pending_steer`` mechanism.
Unlike steer (which injects into the *current* turn's tool results), queued
prompts become the *next* user turn after the current one finishes. The
drain point is the agent-loop / gateway-runner tail — wherever the current
turn ends and ``running`` flips to False.
Thread-safety: all mutations go through ``self._lock``. Safe to call from
gateway threads, CLI process_loop, and the agent execution thread.
"""
from __future__ import annotations
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
@dataclass
class QueuedTurn:
"""A single queued prompt entry."""
id: str
text: str
transport: Any = None # pinned transport for the drained turn
queued_at: float = field(default_factory=time.time)
# Where the entry came from: "queue" (explicit session.queue.add — the
# client shows it in a queue panel, not the transcript) or "busy_submit"
# (a prompt.submit that landed mid-turn — the client already echoed it
# as an optimistic user message). Lets drain events tell clients whether
# the text still needs painting.
source: str = "queue"
def to_dict(self) -> dict:
"""Serialise for RPC / event emission (no transport internals)."""
return {
"id": self.id,
"text": self.text,
"queued_at": self.queued_at,
"source": self.source,
}
class TurnQueue:
"""Thread-safe FIFO of prompts waiting to become the next turn.
Replaces the ad-hoc ``session["queued_prompt"]`` dict slot in the TUI
gateway and the ``localStorage``-backed queue in the desktop renderer.
The queue lives in the agent process so it drains even when no client
window is open.
"""
def __init__(self) -> None:
self._entries: list[QueuedTurn] = []
self._lock = threading.Lock()
# ── enqueue ──────────────────────────────────────────────
def enqueue(
self,
text: str,
transport: Any = None,
source: str = "queue",
) -> QueuedTurn:
"""Add a prompt to the back of the queue.
Consecutive text entries are *not* merged here — the gateway's
``_handle_busy_submit`` still does merge if it wants to (mirroring
``repair_message_sequence``). Callers that want merge semantics
should check ``peek()`` first.
Returns the created :class:`QueuedTurn`.
"""
entry = QueuedTurn(
id=str(uuid.uuid4()),
text=text,
transport=transport,
source=source,
)
with self._lock:
self._entries.append(entry)
return entry
def requeue_front(self, entry: QueuedTurn) -> QueuedTurn:
"""Put a drained entry back at the *front* of the queue.
Used by the gateway when the turn dispatch for a drained entry raises
— the pop already happened, so the entry (same id, so client mirrors
stay consistent) is restored to the head for the next drain attempt
instead of being silently lost.
"""
with self._lock:
self._entries.insert(0, entry)
return entry
# ── drain ────────────────────────────────────────────────
def drain(self) -> Optional[QueuedTurn]:
"""Pop and return the head entry, or None if the queue is empty."""
with self._lock:
if not self._entries:
return None
return self._entries.pop(0)
# ── peek / inspect ───────────────────────────────────────
def peek(self) -> list[QueuedTurn]:
"""Return a snapshot copy of pending entries (for UI sync)."""
with self._lock:
return list(self._entries)
def is_empty(self) -> bool:
with self._lock:
return len(self._entries) == 0
def __len__(self) -> int:
with self._lock:
return len(self._entries)
def __bool__(self) -> bool:
return len(self) > 0
# ── mutate ───────────────────────────────────────────────
def remove(self, entry_id: str) -> bool:
"""Remove a specific entry by id. Returns True if found."""
with self._lock:
before = len(self._entries)
self._entries = [e for e in self._entries if e.id != entry_id]
return len(self._entries) < before
def promote(self, entry_id: str) -> bool:
"""Move an entry to the front of the queue. Returns True if found."""
with self._lock:
idx = None
for i, e in enumerate(self._entries):
if e.id == entry_id:
idx = i
break
if idx is None or idx == 0:
return False
entry = self._entries.pop(idx)
self._entries.insert(0, entry)
return True
def update_text(self, entry_id: str, text: str) -> bool:
"""Update the text of a queued entry. Returns True if found."""
with self._lock:
for e in self._entries:
if e.id == entry_id:
e.text = text
return True
return False
def clear(self) -> int:
"""Clear all entries. Returns the number removed."""
with self._lock:
n = len(self._entries)
self._entries.clear()
return n
# ── serialisation ────────────────────────────────────────
def to_list(self) -> list[dict]:
"""Return a list of plain dicts for RPC / event emission."""
with self._lock:
return [e.to_dict() for e in self._entries]