inference.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  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" | "brief-update" | ...
  15. run: Callable[[], None] # the work to do
  16. priority: int = 0 # lower values run first; commands use -1
  17. evictable: bool = True # False: never dropped by submit() overflow
  18. class InferenceQueue:
  19. """Single worker thread owning every LLM inference call."""
  20. def __init__(self, size: int = 8):
  21. """Create the queue.
  22. Args:
  23. size: Max queued jobs (bounded backpressure).
  24. """
  25. self.size = size
  26. self._queue: collections.deque[InferenceJob] = collections.deque()
  27. self._in_flight: InferenceJob | None = None
  28. self._lock = threading.Lock()
  29. self._cond = threading.Condition(self._lock)
  30. self._stop = threading.Event()
  31. self._worker: threading.Thread | None = None
  32. def start(self) -> None:
  33. """Spawn the worker thread."""
  34. if self._worker is not None:
  35. return
  36. self._worker = threading.Thread(
  37. target=self._worker_loop, name="assistant-inference", daemon=True
  38. )
  39. self._worker.start()
  40. def stop(self) -> None:
  41. """Discard queued jobs, let in-flight finish, join the worker."""
  42. with self._cond:
  43. self._queue.clear()
  44. self._stop.set()
  45. self._cond.notify_all()
  46. if self._worker is not None:
  47. self._worker.join(timeout=1.0)
  48. def submit(self, job: InferenceJob) -> bool:
  49. """Queue a job for execution.
  50. Returns:
  51. False when the queue is full and the job cannot be admitted: either it
  52. is a priority job (priority < 0), or there is no evictable normal job to
  53. drop. Otherwise an evictable normal job is dropped and True is returned.
  54. """
  55. with self._cond:
  56. if self._stop.is_set():
  57. return False
  58. # Capacity includes the in-flight job (if any) and the queue
  59. capacity_used = (1 if self._in_flight else 0) + len(self._queue)
  60. # Check if at capacity
  61. if capacity_used >= self.size:
  62. if job.priority < 0 or not self._drop_oldest_normal():
  63. return False
  64. # Insert the job: priority jobs at front, normal jobs at back
  65. if job.priority < 0:
  66. self._queue.appendleft(job)
  67. else:
  68. self._queue.append(job)
  69. self._cond.notify_all()
  70. return True
  71. def submit_if_room(self, job: InferenceJob, reserve: int = 1) -> bool:
  72. """Append a job only if there is room; never drops an existing job.
  73. Admission is atomic under the existing condition lock. Capacity counts
  74. the in-flight job plus queued jobs, and the job is admitted only while
  75. ``capacity_used < size - reserve``. ``reserve`` keeps slots free for
  76. higher-priority work, so a ``reserve`` >= ``size`` makes admission
  77. always refuse (it never crashes). Unlike ``submit()``, this never evicts
  78. an existing job.
  79. Returns:
  80. True when the job was appended, False when stopped or full.
  81. """
  82. with self._cond:
  83. if self._stop.is_set():
  84. return False
  85. capacity_used = (1 if self._in_flight else 0) + len(self._queue)
  86. if capacity_used >= self.size - reserve:
  87. return False
  88. self._queue.append(job)
  89. self._cond.notify_all()
  90. return True
  91. def drop(self, predicate: Callable[[InferenceJob], bool]) -> int:
  92. """Remove queued jobs matching the predicate.
  93. Does not touch the in-flight job. Returns the count of jobs removed.
  94. """
  95. with self._cond:
  96. to_drop = [j for j in self._queue if predicate(j)]
  97. for job in to_drop:
  98. self._queue.remove(job)
  99. return len(to_drop)
  100. def depth(self) -> int:
  101. """Return the number of queued jobs (not counting in-flight)."""
  102. with self._lock:
  103. return len(self._queue)
  104. def in_flight(self) -> bool:
  105. """Return True while a job is executing."""
  106. with self._lock:
  107. return self._in_flight is not None
  108. def _drop_oldest_normal(self) -> bool:
  109. """Remove the oldest evictable normal (priority >= 0) job; False if none exists.
  110. Non-evictable jobs are skipped, so a queue of only non-evictable jobs
  111. refuses the overflow rather than dropping one.
  112. Assumes the lock is held.
  113. """
  114. for i, job in enumerate(self._queue):
  115. if job.priority >= 0 and job.evictable:
  116. del self._queue[i]
  117. return True
  118. return False
  119. def _worker_loop(self) -> None:
  120. """Worker thread: dequeue and execute jobs until stop."""
  121. while True:
  122. with self._cond:
  123. while not self._queue and not self._stop.is_set():
  124. self._cond.wait()
  125. if self._stop.is_set() and not self._queue:
  126. break
  127. if not self._queue:
  128. continue
  129. job = self._queue.popleft()
  130. self._in_flight = job
  131. # Release the lock during execution so a concurrent drop() can proceed.
  132. try:
  133. job.run()
  134. except Exception as e: # noqa: BLE001 - one bad job must not kill the lane
  135. logger.error("Inference job %r failed: %s", job.name, e)
  136. finally:
  137. with self._lock:
  138. self._in_flight = None