inference.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. """Single-lane inference queue for serialized LLM work.
  2. All LLM calls (chat, command execution, compaction) run through one worker thread,
  3. so there are no concurrent requests to the server.
  4. """
  5. import collections
  6. import logging
  7. import threading
  8. from dataclasses import dataclass
  9. from typing import Callable
  10. logger = logging.getLogger(__name__)
  11. @dataclass
  12. class InferenceJob:
  13. """A unit of work for the inference queue."""
  14. name: str # "chat" | "command" | "summary"
  15. run: Callable[[], None] # the work to do
  16. priority: int = 0 # lower values run first; commands use -1
  17. class InferenceQueue:
  18. """Single worker thread owning every LLM inference call."""
  19. def __init__(self, size: int = 8):
  20. """Create the queue.
  21. Args:
  22. size: Max queued jobs (bounded backpressure).
  23. """
  24. self.size = size
  25. self._queue: collections.deque[InferenceJob] = collections.deque()
  26. self._in_flight: InferenceJob | None = None
  27. self._lock = threading.Lock()
  28. self._cond = threading.Condition(self._lock)
  29. self._stop = threading.Event()
  30. self._worker: threading.Thread | None = None
  31. def start(self) -> None:
  32. """Spawn the worker thread."""
  33. if self._worker is not None:
  34. return
  35. self._worker = threading.Thread(
  36. target=self._worker_loop, name="assistant-inference", daemon=True
  37. )
  38. self._worker.start()
  39. def stop(self) -> None:
  40. """Discard queued jobs, let in-flight finish, join the worker."""
  41. with self._cond:
  42. self._queue.clear()
  43. self._stop.set()
  44. self._cond.notify_all()
  45. if self._worker is not None:
  46. self._worker.join(timeout=1.0)
  47. def submit(self, job: InferenceJob) -> bool:
  48. """Queue a job for execution.
  49. Returns:
  50. False only when a priority job (priority < 0) is rejected due to overflow.
  51. Normal jobs drop the oldest normal job on overflow and return True.
  52. """
  53. with self._cond:
  54. if self._stop.is_set():
  55. return False
  56. # Capacity includes the in-flight job (if any) and the queue
  57. capacity_used = (1 if self._in_flight else 0) + len(self._queue)
  58. # Check if at capacity
  59. if capacity_used >= self.size:
  60. if job.priority < 0 or not self._drop_oldest_normal():
  61. return False
  62. # Insert the job: priority jobs at front, normal jobs at back
  63. if job.priority < 0:
  64. self._queue.appendleft(job)
  65. else:
  66. self._queue.append(job)
  67. self._cond.notify_all()
  68. return True
  69. def drop(self, predicate: Callable[[InferenceJob], bool]) -> int:
  70. """Remove queued jobs matching the predicate.
  71. Does not touch the in-flight job. Returns the count of jobs removed.
  72. """
  73. with self._cond:
  74. to_drop = [j for j in self._queue if predicate(j)]
  75. for job in to_drop:
  76. self._queue.remove(job)
  77. return len(to_drop)
  78. def depth(self) -> int:
  79. """Return the number of queued jobs (not counting in-flight)."""
  80. with self._lock:
  81. return len(self._queue)
  82. def _drop_oldest_normal(self) -> bool:
  83. """Remove the oldest normal (priority >= 0) job; False if none exists.
  84. Assumes the lock is held.
  85. """
  86. for i, job in enumerate(self._queue):
  87. if job.priority >= 0:
  88. del self._queue[i]
  89. return True
  90. return False
  91. def _worker_loop(self) -> None:
  92. """Worker thread: dequeue and execute jobs until stop."""
  93. while True:
  94. with self._cond:
  95. while not self._queue and not self._stop.is_set():
  96. self._cond.wait()
  97. if self._stop.is_set() and not self._queue:
  98. break
  99. if not self._queue:
  100. continue
  101. job = self._queue.popleft()
  102. self._in_flight = job
  103. # Release the lock during execution so a concurrent drop() can proceed.
  104. try:
  105. job.run()
  106. except Exception as e: # noqa: BLE001 - one bad job must not kill the lane
  107. logger.error("Inference job %r failed: %s", job.name, e)
  108. finally:
  109. with self._lock:
  110. self._in_flight = None