Dispatcher.vala 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. using Invercargill.DataStructures;
  2. using InvercargillSql.Orm;
  3. using Inversion;
  4. using ClankerSaddle.Data;
  5. namespace ClankerSaddle.Services {
  6. /**
  7. * The task dispatcher: a five-second timer that pairs ready tasks with
  8. * idle saddles, resumes answered-but-paused tasks on the saddle that
  9. * holds their parked workspace, pushes human comments to busy daemons
  10. * and keeps busy saddles' output in sync.
  11. *
  12. * Each tick resolves fresh request-scoped services (so no database
  13. * scope is held between ticks) and never overlaps with itself: a slow
  14. * tick coalesces into the next one.
  15. */
  16. public class Dispatcher : Object {
  17. private Container container = inject<Container>();
  18. private bool ticking = false;
  19. private bool warned_no_llm = false;
  20. /**
  21. * Task ids that already logged a capability wait. The skip note
  22. * prints once per task (cleared on dispatch) instead of on every
  23. * five-second tick.
  24. */
  25. private Invercargill.DataStructures.HashSet<string> capability_wait_logged =
  26. new Invercargill.DataStructures.HashSet<string>();
  27. /**
  28. * Consecutive communication failures per saddle id. Transient
  29. * errors (a pooled connection dropped mid-reuse, a momentary
  30. * SQLite lock) must not tear down a running session, so a saddle
  31. * is only declared unreachable after three strikes in a row.
  32. */
  33. private Dictionary<string, string> strikes = new Dictionary<string, string>();
  34. /** Starts the repeating timer. */
  35. public void start() {
  36. Timeout.add_seconds(5, () => {
  37. run_tick.begin();
  38. return true;
  39. });
  40. print("[ClankerSaddle] Dispatcher running (5s interval)\n");
  41. }
  42. private async void run_tick() {
  43. if (ticking) {
  44. return;
  45. }
  46. ticking = true;
  47. try {
  48. yield tick();
  49. } catch (Error e) {
  50. warning("[ClankerSaddle] Dispatch tick failed: %s", e.message);
  51. }
  52. ticking = false;
  53. }
  54. private int strike_count(int64 saddle_id) {
  55. string stored = "";
  56. return strikes.try_get(saddle_id.to_string(), out stored) ? int.parse(stored) : 0;
  57. }
  58. private void record_strike(int64 saddle_id, int count) {
  59. strikes.set(saddle_id.to_string(), count.to_string());
  60. }
  61. /**
  62. * One dispatch cycle: for every ready task (priority order) grab an
  63. * idle saddle whose capability set is a superset of the task's
  64. * required capabilities (tag-based routing — a task with no
  65. * requirements matches any saddle; a task that cannot be matched
  66. * is skipped with a log note and lower-priority tasks get their
  67. * chance), then resume answered-but-paused tasks on the saddle
  68. * that holds their parked workspace, and poll busy saddles for
  69. * output and status changes. Assignment pauses while
  70. * {@link LlmEndpoint} is unconfigured (polling continues).
  71. */
  72. public async void tick() throws Error {
  73. var scope = container.create_scope();
  74. var orm = scope.resolve<OrmSession>();
  75. var tasks = scope.resolve<TaskService>();
  76. var saddles = scope.resolve<SaddleService>();
  77. var projects = scope.resolve<ProjectService>();
  78. var capabilities = scope.resolve<CapabilityService>();
  79. var subtask_events = scope.resolve<SubtaskEventQueue>();
  80. if (LlmEndpoint.from_environment() == null) {
  81. if (!warned_no_llm) {
  82. warning("[ClankerSaddle] SADDLE_LLM_BASE_URL is not set; task assignment is paused");
  83. warned_no_llm = true;
  84. }
  85. } else {
  86. warned_no_llm = false;
  87. // Stale-busy healing comes first so a freed saddle is
  88. // dispatchable within the same tick.
  89. yield saddles.heal_stale_busy_saddles(tasks);
  90. var ready = yield tasks.get_ready_tasks();
  91. foreach (var task in ready) {
  92. var saddle = yield pick_idle_capable_saddle(saddles, capabilities, task);
  93. if (saddle == null) {
  94. bool any_idle = (yield saddles.list_idle_saddles()).length > 0;
  95. if (!any_idle) {
  96. break;
  97. }
  98. // A capable saddle may free up later; meanwhile
  99. // lower-priority tasks still get their turn.
  100. if (capability_wait_logged.add(task.id.to_string())) {
  101. var required = new Invercargill.DataStructures.Vector<string>();
  102. foreach (var capability in yield capabilities.list_for_task(task.id)) {
  103. required.add(capability.name);
  104. }
  105. string required_text = string.joinv(", ", required.to_array());
  106. print(@"[ClankerSaddle] Task $(task.id) waits for a capable saddle (requires: $(required_text))\n");
  107. }
  108. continue;
  109. }
  110. capability_wait_logged.remove(task.id.to_string());
  111. var session = new Session() {
  112. task_id = task.id,
  113. saddle_id = ((!)saddle).id,
  114. started_at = new DateTime.now_local()
  115. };
  116. yield orm.insert_async<Session>(session);
  117. task.status = STATUS_ASSIGNED;
  118. task.started_at = new DateTime.now_local();
  119. yield orm.update_async<TaskEntity>(task);
  120. yield saddles.update_saddle_status(((!)saddle).id, "busy", task.id);
  121. subtask_events.record_dispatch(task);
  122. try {
  123. yield saddles.seed_session_cursor(((!)saddle).id, session.id);
  124. yield saddles.push_task_to_saddle(((!)saddle).id, task, session.id, projects);
  125. yield saddles.mark_task_comments_delivered(task.id);
  126. record_strike(((!)saddle).id, 0);
  127. print(@"[ClankerSaddle] Dispatched task $(task.id) to saddle '$(((!)saddle).name)' (session $(session.id))\n");
  128. } catch (Error e) {
  129. warning(@"[ClankerSaddle] Pushing task $(task.id) to saddle $(((!)saddle).id) failed: $(e.message)");
  130. yield abandon_saddle(((!)saddle).id, saddles, tasks);
  131. }
  132. }
  133. }
  134. yield resume_answered_tasks(tasks, saddles, projects);
  135. yield resume_suspended_parents(tasks, saddles, projects);
  136. yield saddles.deliver_pending_subtask_events(tasks);
  137. foreach (var saddle in yield saddles.list_busy_saddles()) {
  138. try {
  139. yield saddles.poll_saddle_updates(saddle.id, tasks);
  140. yield saddles.deliver_pending_comments(saddle.id, saddle.current_task_id);
  141. record_strike(saddle.id, 0);
  142. } catch (Error e) {
  143. int count = strike_count(saddle.id) + 1;
  144. record_strike(saddle.id, count);
  145. warning(@"[ClankerSaddle] Polling saddle $(saddle.id) failed (attempt $(count)/3): $(e.message)");
  146. if (count >= 3) {
  147. yield abandon_saddle(saddle.id, saddles, tasks);
  148. }
  149. }
  150. }
  151. }
  152. /**
  153. * Resumes blocked/review-requested tasks whose newest block has
  154. * been answered, on the saddle holding their open (parked)
  155. * session — the workspace-pause counterpart of the dispatch loop.
  156. * Capabilities do not apply here: the parked workspace pins the
  157. * task to the saddle that holds it.
  158. */
  159. private async void resume_answered_tasks(TaskService tasks, SaddleService saddles,
  160. ProjectService projects) throws Error {
  161. var candidates = new Vector<TaskEntity>();
  162. foreach (var status in new string[] { STATUS_BLOCKED, STATUS_REVIEW_REQUESTED }) {
  163. foreach (var task in yield tasks.list_tasks(status, null)) {
  164. candidates.add(task);
  165. }
  166. }
  167. foreach (var task in candidates) {
  168. TaskBlock? newest = null;
  169. foreach (var block in yield tasks.list_blocks(task.id)) {
  170. newest = block;
  171. }
  172. if (newest == null || ((!)newest).answer == null) {
  173. continue;
  174. }
  175. var session = yield tasks.open_session_for_task(task.id);
  176. if (session == null) {
  177. continue;
  178. }
  179. try {
  180. yield saddles.resume_answered_task(task, (!)session, tasks, projects);
  181. } catch (Error e) {
  182. warning(@"[ClankerSaddle] Resuming task $(task.id) failed: $(e.message)");
  183. }
  184. }
  185. }
  186. /**
  187. * Resumes suspended parents whose subtasks produced events since
  188. * they parked (a subtask completed or failed): a fresh /start on
  189. * the saddle holding the parked workspace, carrying the coalesced
  190. * markdown status report of every subtask as the resume notice.
  191. * Resumes on EACH such event — not only when all subtasks finish.
  192. */
  193. private async void resume_suspended_parents(TaskService tasks, SaddleService saddles,
  194. ProjectService projects) throws Error {
  195. foreach (var parent in yield tasks.list_tasks(STATUS_SUSPENDED, null)) {
  196. if (!(yield tasks.has_terminal_subtask_since(parent))) {
  197. continue;
  198. }
  199. var session = yield tasks.open_session_for_task(parent.id);
  200. if (session == null) {
  201. continue;
  202. }
  203. try {
  204. string notice = yield tasks.subtask_status_report(parent.id);
  205. bool resumed = yield saddles.resume_answered_task(parent, (!)session,
  206. tasks, projects, notice);
  207. if (resumed) {
  208. // The parking window is over: a later suspension
  209. // starts a fresh event window.
  210. yield tasks.clear_suspension(parent.id);
  211. print(@"[ClankerSaddle] Resumed suspended task $(parent.id) with a subtask report\n");
  212. }
  213. } catch (Error e) {
  214. warning(@"[ClankerSaddle] Resuming suspended task $(parent.id) failed: $(e.message)");
  215. }
  216. }
  217. }
  218. /**
  219. * Declares a saddle unreachable once its failures accumulated:
  220. * the task it held returns to the queue (with a bot comment) and
  221. * its session closes.
  222. */
  223. private async void abandon_saddle(int64 saddle_id, SaddleService saddles,
  224. TaskService tasks) throws Error {
  225. record_strike(saddle_id, 0);
  226. yield saddles.mark_saddle_unreachable(saddle_id, tasks);
  227. }
  228. /**
  229. * The first idle saddle whose capability set covers `task`'s
  230. * required capabilities (registration order), or null when every
  231. * idle saddle is missing a tag — the tag-based routing half of
  232. * the pairing loop.
  233. */
  234. private async Saddle? pick_idle_capable_saddle(SaddleService saddles,
  235. CapabilityService capabilities, TaskEntity task) throws Error {
  236. foreach (var saddle in yield saddles.list_idle_saddles()) {
  237. if (yield capabilities.saddle_covers_task(saddle.id, task.id)) {
  238. return saddle;
  239. }
  240. }
  241. return null;
  242. }
  243. }
  244. }