AllocationService.vala 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql;
  5. using InvercargillSql.Orm;
  6. using Inversion;
  7. using ClankerSaddle.Data;
  8. namespace ClankerSaddle.Services {
  9. /**
  10. * One offerable task for the wake computation: a fresh queued task
  11. * (dependencies met, {@link affine_saddle_id} 0) or a parked task
  12. * ready to resume — blocked/review-requested with its newest block
  13. * answered, or suspended with a terminal subtask event since it
  14. * parked — pinned to the saddle holding its open session.
  15. */
  16. public class OfferableTask : Object {
  17. public TaskEntity task { get; set; }
  18. /** The saddle whose parked workspace holds the task (0 = fresh). */
  19. public int64 affine_saddle_id { get; set; }
  20. /** True when resuming a parked workspace (vs. a fresh dispatch). */
  21. public bool parked { get; set; }
  22. /** True when the parked task is a suspended parent's resume. */
  23. public bool suspended_resume { get; set; }
  24. }
  25. /**
  26. * A claimed allocation: the transactional half of
  27. * `POST /api/saddle/next-task` succeeded — the task is `assigned`
  28. * to the asking saddle, its session row exists and the saddle row
  29. * is busy. The assignment payload is assembled from it afterwards.
  30. */
  31. public class AllocationClaim : Object {
  32. public int64 task_id { get; set; }
  33. public int64 session_id { get; set; }
  34. public bool parked { get; set; }
  35. public bool suspended_resume { get; set; }
  36. }
  37. /**
  38. * The ask/allocation half of the plan-10 work-acquisition flip:
  39. * daemons ask (`POST /api/saddle/next-task`), the allocation picks
  40. * the best offerable task for the asking saddle and claims it in a
  41. * single write transaction, and the dispatcher's wake computation
  42. * uses the same offerable set to decide which sleeping daemons to
  43. * wake.
  44. *
  45. * Two filters, in priority order:
  46. *
  47. * 1. **Workspace affinity** — a task parked (blocked, awaiting
  48. * review or suspended) on this saddle is offered ONLY to this
  49. * saddle: its workspace lives here, and capabilities do not
  50. * apply (the parked session pins the pairing, exactly like the
  51. * pre-flip resume paths).
  52. * 2. **Capability match** — fresh queued tasks (dependencies met)
  53. * go to the asking saddle only when its capability set is a
  54. * superset of the task's required tags.
  55. *
  56. * The claim itself runs inside one `BEGIN IMMEDIATE` transaction
  57. * with no awaits, so concurrent asks cannot interleave between the
  58. * decision and the writes: exactly one ask wins a task even when
  59. * several fire simultaneously. Every candidate is re-validated
  60. * inside the transaction (a racing ask may have claimed it after
  61. * the candidate list was built), and a lost race simply falls
  62. * through to the next candidate.
  63. */
  64. public class AllocationService : Object {
  65. private OrmSession db = inject<OrmSession>();
  66. private Connection connection = inject<Connection>();
  67. private TaskService tasks = inject<TaskService>();
  68. private CapabilityService capabilities = inject<CapabilityService>();
  69. private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
  70. private StatePulse pulse = inject<StatePulse>();
  71. private SaddlePollState poll_state = inject<SaddlePollState>();
  72. /**
  73. * Allocates the best offerable task to `saddle`, or null when
  74. * nothing is offerable (the daemon sleeps). The transactional
  75. * claim runs first; the assignment payload (answers, resume
  76. * notice, effective rules, project notes, git identity, llm
  77. * config, session id) is assembled from the claimed rows after
  78. * the commit.
  79. */
  80. public async Json.Object? allocate_next(SaddleService saddles, ProjectService projects,
  81. Saddle saddle) throws Error {
  82. if (LlmEndpoint.from_environment() == null) {
  83. // No LLM endpoint configured: assignment stays paused
  84. // (the dispatcher logs it once); the daemon sleeps.
  85. return null;
  86. }
  87. var candidates = yield candidates_for(saddle);
  88. AllocationClaim? claim = null;
  89. foreach (var candidate in candidates) {
  90. claim = claim_candidate(saddle, candidate);
  91. if (claim != null) {
  92. break;
  93. }
  94. }
  95. if (claim == null) {
  96. return null;
  97. }
  98. return yield assemble_assignment(saddles, projects, saddle, (!)claim);
  99. }
  100. /**
  101. * Every offerable task (see {@link OfferableTask}) — the
  102. * dispatcher's wake computation walks this list: parked tasks
  103. * target their affine saddle first, fresh tasks any capable
  104. * sleeping one. A parked task without an open session has no
  105. * workspace to resume and is not offerable.
  106. */
  107. public async ImmutableLot<OfferableTask> offerable_tasks() throws Error {
  108. var rows = new Vector<OfferableTask>();
  109. foreach (var status in new string[] { STATUS_BLOCKED, STATUS_REVIEW_REQUESTED }) {
  110. foreach (var task in yield tasks.list_tasks(status, null)) {
  111. if (yield tasks.newest_block_answered(task.id)) {
  112. var parked = yield parked_offerable(task, false);
  113. if (parked != null) {
  114. rows.add((!)parked);
  115. }
  116. }
  117. }
  118. }
  119. foreach (var parent in yield tasks.list_tasks(STATUS_SUSPENDED, null)) {
  120. if (yield tasks.has_terminal_subtask_since(parent)) {
  121. var parked = yield parked_offerable(parent, true);
  122. if (parked != null) {
  123. rows.add((!)parked);
  124. }
  125. }
  126. }
  127. foreach (var task in yield tasks.get_ready_tasks()) {
  128. rows.add(new OfferableTask() {
  129. task = task,
  130. affine_saddle_id = 0,
  131. parked = false,
  132. suspended_resume = false
  133. });
  134. }
  135. return rows.to_immutable_buffer();
  136. }
  137. /**
  138. * The offerable tasks `saddle` may be allocated: affinity
  139. * candidates first (parked on this saddle, priority order),
  140. * then fresh queued tasks whose required capabilities are
  141. * covered (ready order — priority, then creation).
  142. */
  143. private async ImmutableLot<OfferableTask> candidates_for(Saddle saddle) throws Error {
  144. var rows = new Vector<OfferableTask>();
  145. foreach (var offerable in yield offerable_tasks()) {
  146. if (offerable.parked) {
  147. if (offerable.affine_saddle_id == saddle.id) {
  148. rows.add(offerable);
  149. }
  150. } else if (yield capabilities.saddle_covers_task(saddle.id,
  151. offerable.task.id)) {
  152. rows.add(offerable);
  153. }
  154. }
  155. return rows.to_immutable_buffer();
  156. }
  157. /**
  158. * A parked offerable bound to its session's saddle, or null when
  159. * the task has no open (parked) session — nothing to resume.
  160. */
  161. private async OfferableTask? parked_offerable(TaskEntity task, bool suspended_resume)
  162. throws Error {
  163. var session = yield tasks.open_session_for_task(task.id);
  164. if (session == null) {
  165. return null;
  166. }
  167. return new OfferableTask() {
  168. task = task,
  169. affine_saddle_id = ((!)session).saddle_id,
  170. parked = true,
  171. suspended_resume = suspended_resume
  172. };
  173. }
  174. /**
  175. * The single-transaction claim. `BEGIN IMMEDIATE` takes the
  176. * SQLite write lock up front; the section is fully synchronous
  177. * (no awaits), so no other ask, action or tick can interleave
  178. * between the re-validation reads and the claim writes —
  179. * concurrent asks race to exactly one winner. Returns null when
  180. * the candidate was claimed by a racing ask or its saddle
  181. * became unavailable (the caller falls through to the next
  182. * candidate).
  183. */
  184. private AllocationClaim? claim_candidate(Saddle saddle, OfferableTask candidate) throws Error {
  185. connection.execute("BEGIN IMMEDIATE");
  186. try {
  187. var task = db.query<TaskEntity>()
  188. .where(expr("id == $0", elem<int64?>(candidate.task.id)))
  189. .first();
  190. if (task == null || !still_claimable((!)task, candidate)) {
  191. connection.execute("ROLLBACK");
  192. return null;
  193. }
  194. var row = db.query<Saddle>()
  195. .where(expr("id == $0", elem<int64?>(saddle.id)))
  196. .first();
  197. if (row == null || (((!)row).status != "idle"
  198. && (!(candidate.parked && ((!)row).current_task_id == candidate.task.id)))) {
  199. connection.execute("ROLLBACK");
  200. return null;
  201. }
  202. int64 session_id = 0;
  203. if (candidate.parked) {
  204. var session = db.query<Session>()
  205. .where(expr("task_id == $0 && saddle_id == $1",
  206. elem<int64?>(candidate.task.id), elem<int64?>(saddle.id)))
  207. .order_by(expr("id"))
  208. .materialise();
  209. Session? newest_open = null;
  210. foreach (var open in session) {
  211. if (open.ended_at == null) {
  212. newest_open = open;
  213. }
  214. }
  215. if (newest_open == null) {
  216. // The parked session closed underneath us (a
  217. // concurrent requeue); nothing to resume onto.
  218. connection.execute("ROLLBACK");
  219. return null;
  220. }
  221. // Plan 11: the resuming assignment carries a FRESH
  222. // session id — the parked row closes with outcome
  223. // 'resumed' (the park record) and the daemon keys
  224. // every telemetry push of the resumed round on the
  225. // new row.
  226. ((!)newest_open).ended_at = new DateTime.now_local();
  227. ((!)newest_open).outcome = "resumed";
  228. db.update<Session>((!)newest_open);
  229. var resumed = new Session() {
  230. task_id = candidate.task.id,
  231. saddle_id = saddle.id,
  232. started_at = new DateTime.now_local()
  233. };
  234. db.insert<Session>(resumed);
  235. session_id = resumed.id;
  236. } else {
  237. var session = new Session() {
  238. task_id = candidate.task.id,
  239. saddle_id = saddle.id,
  240. started_at = new DateTime.now_local()
  241. };
  242. db.insert<Session>(session);
  243. session_id = session.id;
  244. }
  245. ((!)task).status = STATUS_ASSIGNED;
  246. ((!)task).started_at = new DateTime.now_local();
  247. db.update<TaskEntity>((!)task);
  248. ((!)row).status = "busy";
  249. ((!)row).current_task_id = candidate.task.id;
  250. ((!)row).allocated_at = new DateTime.now_local();
  251. db.update<Saddle>((!)row);
  252. connection.execute("COMMIT");
  253. return new AllocationClaim() {
  254. task_id = candidate.task.id,
  255. session_id = session_id,
  256. parked = candidate.parked,
  257. suspended_resume = candidate.suspended_resume
  258. };
  259. } catch (Error e) {
  260. try {
  261. connection.execute("ROLLBACK");
  262. } catch (Error rollback_error) {
  263. warning("[ClankerSaddle] Allocation rollback failed: %s",
  264. rollback_error.message);
  265. }
  266. throw e;
  267. }
  268. }
  269. /**
  270. * The in-transaction re-validation: the task must still be in
  271. * its candidate state (a racing ask that claimed it flipped the
  272. * status), and a fresh candidate's dependencies must still be
  273. * met.
  274. */
  275. private bool still_claimable(TaskEntity task, OfferableTask candidate) throws Error {
  276. if (candidate.parked) {
  277. return task.status == STATUS_BLOCKED
  278. || task.status == STATUS_REVIEW_REQUESTED
  279. || (candidate.suspended_resume && task.status == STATUS_SUSPENDED);
  280. }
  281. if (task.status != STATUS_QUEUED) {
  282. return false;
  283. }
  284. foreach (var dependency in db.query<TaskDependency>()
  285. .where(expr("task_id == $0", elem<int64?>(task.id)))
  286. .materialise()) {
  287. var other = db.query<TaskEntity>()
  288. .where(expr("id == $0", elem<int64?>(dependency.depends_on_task_id)))
  289. .first();
  290. if (other == null || ((!)other).status != STATUS_DONE) {
  291. return false;
  292. }
  293. }
  294. return true;
  295. }
  296. /**
  297. * Assembles the claimed task's assignment payload (today's
  298. * /start shape): pending block answers ride the task object,
  299. * and every parked resume carries a `resume_notice` — a
  300. * suspended parent's the coalesced subtask report, an answered
  301. * block's the verdict text in the stable wording ({@link
  302. * TaskService.verdict_notice_for}) — plus the
  303. * `branch_advanced` verdict. The hand-over itself starts the
  304. * session's push-liveness clock (the daemon cannot push before
  305. * it owns the assignment), so the staleness probe's window
  306. * opens here.
  307. */
  308. private async Json.Object assemble_assignment(SaddleService saddles,
  309. ProjectService projects, Saddle saddle, AllocationClaim claim) throws Error {
  310. var detail = yield tasks.get_task(claim.task_id);
  311. if (detail == null) {
  312. throw new Error(Quark.from_string("clanker-saddle"), 48,
  313. @"Task $(claim.task_id) vanished after its allocation claim");
  314. }
  315. var task = ((!)detail).task;
  316. string? resume_notice = null;
  317. bool? branch_advanced = null;
  318. if (claim.parked) {
  319. branch_advanced = yield tasks.branch_advanced_since(task.id);
  320. if (claim.suspended_resume) {
  321. resume_notice = yield tasks.subtask_status_report(task.id);
  322. // The parking window is over: a later suspension
  323. // starts a fresh event window.
  324. yield tasks.clear_suspension(task.id);
  325. } else {
  326. // Answering = allocating (plan 11): the verdict rides
  327. // the resuming assignment's resume_notice, injected as
  328. // the restored conversation's opening turn.
  329. var newest = yield tasks.newest_block(task.id);
  330. if (newest != null && ((!)newest).answer != null) {
  331. resume_notice = TaskService.verdict_notice_for((!)newest);
  332. }
  333. }
  334. }
  335. poll_state.note_push(claim.session_id);
  336. var payload = yield saddles.build_assignment_payload(saddle, task,
  337. claim.session_id, projects, resume_notice, branch_advanced);
  338. payload.set_int_member("session_id", claim.session_id);
  339. yield saddles.mark_task_comments_delivered(task.id);
  340. subtask_events.record_dispatch(task);
  341. yield pulse.bump(task.id, "task dispatched");
  342. print(@"[ClankerSaddle] Allocated task $(task.id) to saddle '$(saddle.name)' on ask (session $(claim.session_id))\n");
  343. return payload;
  344. }
  345. }
  346. }