inference.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  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 submit_if_room(self, job: InferenceJob, reserve: int = 1) -> bool:
  70. """Append a job only if there is room; never drops an existing job.
  71. Admission is atomic under the existing condition lock. Capacity counts
  72. the in-flight job plus queued jobs, and the job is admitted only while
  73. ``capacity_used < size - reserve``. ``reserve`` keeps slots free for
  74. higher-priority work, so a ``reserve`` >= ``size`` makes admission
  75. always refuse (it never crashes). Unlike ``submit()``, this never evicts
  76. an existing job.
  77. Returns:
  78. True when the job was appended, False when stopped or full.
  79. """
  80. with self._cond:
  81. if self._stop.is_set():
  82. return False
  83. capacity_used = (1 if self._in_flight else 0) + len(self._queue)
  84. if capacity_used >= self.size - reserve:
  85. return False
  86. self._queue.append(job)
  87. self._cond.notify_all()
  88. return True
  89. def drop(self, predicate: Callable[[InferenceJob], bool]) -> int:
  90. """Remove queued jobs matching the predicate.
  91. Does not touch the in-flight job. Returns the count of jobs removed.
  92. """
  93. with self._cond:
  94. to_drop = [j for j in self._queue if predicate(j)]
  95. for job in to_drop:
  96. self._queue.remove(job)
  97. return len(to_drop)
  98. def depth(self) -> int:
  99. """Return the number of queued jobs (not counting in-flight)."""
  100. with self._lock:
  101. return len(self._queue)
  102. def in_flight(self) -> bool:
  103. """Return True while a job is executing."""
  104. with self._lock:
  105. return self._in_flight is not None
  106. def _drop_oldest_normal(self) -> bool:
  107. """Remove the oldest normal (priority >= 0) job; False if none exists.
  108. Assumes the lock is held.
  109. """
  110. for i, job in enumerate(self._queue):
  111. if job.priority >= 0:
  112. del self._queue[i]
  113. return True
  114. return False
  115. def _worker_loop(self) -> None:
  116. """Worker thread: dequeue and execute jobs until stop."""
  117. while True:
  118. with self._cond:
  119. while not self._queue and not self._stop.is_set():
  120. self._cond.wait()
  121. if self._stop.is_set() and not self._queue:
  122. break
  123. if not self._queue:
  124. continue
  125. job = self._queue.popleft()
  126. self._in_flight = job
  127. # Release the lock during execution so a concurrent drop() can proceed.
  128. try:
  129. job.run()
  130. except Exception as e: # noqa: BLE001 - one bad job must not kill the lane
  131. logger.error("Inference job %r failed: %s", job.name, e)
  132. finally:
  133. with self._lock:
  134. self._in_flight = None