TaskService.vala 79 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718
  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. /** Valid task statuses (the `tasks.status` wire values). */
  10. public const string STATUS_DRAFT = "draft";
  11. public const string STATUS_QUEUED = "queued";
  12. public const string STATUS_ASSIGNED = "assigned";
  13. public const string STATUS_WORKING = "working";
  14. public const string STATUS_BLOCKED = "blocked";
  15. public const string STATUS_REVIEW_REQUESTED = "review_requested";
  16. public const string STATUS_SUSPENDED = "suspended";
  17. public const string STATUS_DONE = "done";
  18. public const string STATUS_FAILED = "failed";
  19. /** Valid {@link TaskBlock.block_type} values. */
  20. public const string BLOCK_QUESTION = "question";
  21. public const string BLOCK_RFC = "rfc";
  22. public const string BLOCK_REVIEW = "review";
  23. public const string BLOCK_PLAN = "plan";
  24. /**
  25. * The goal agent's completion report (plan 15 C1): raised on the
  26. * goal-run task by {@link GoalService.submit_completion} — the
  27. * goal's review kinds are plan and completion.
  28. */
  29. public const string BLOCK_COMPLETION = "completion";
  30. /**
  31. * The system-event comment author (plan 15 C2): harness-authored
  32. * bookkeeping rows (aborts, requeues, retries, session
  33. * restorations) — never agent content.
  34. */
  35. public const string COMMENT_AUTHOR_SYSTEM = "system";
  36. /**
  37. * Plan 16 S5 (U2): MECHANICAL harness bookkeeping that every
  38. * user-facing timeline filters out — the session-restore footnote
  39. * and the park-until notice (both remain in the raw terminal log,
  40. * which keeps everything). Matched by substring so the daemon's
  41. * [harness]-prefixed wordings and the web's own phrasing all stay
  42. * covered; substantive system rows (aborts, requeues, retries)
  43. * never match.
  44. */
  45. public static bool comment_is_mechanical(string body) {
  46. return body.contains("session context restored")
  47. || body.contains("parked until");
  48. }
  49. /**
  50. * The agent-celebration entry type (`saddle yeehaw <message>`): the
  51. * daemon pushes it as a round-results entry carrying the message in
  52. * the output field, so it stores like any other `session_commands`
  53. * row — nothing new is persisted for the badge or the card.
  54. */
  55. public const string ENTRY_YEEHAW = "yeehaw";
  56. /** The task page's yeehaw card cap (newest entries kept). */
  57. public const int YEEHAW_CARD_MAX = 3;
  58. /**
  59. * The stable verdict wordings (plan 11): they are stored as the
  60. * block answers AND ride the resuming allocation's
  61. * `resume_notice` verbatim, so agent and human always read the
  62. * same text.
  63. */
  64. public const string NOTICE_PLAN_APPROVED = "Plan approved. Proceed with implementation.";
  65. public const string NOTICE_PLAN_FEEDBACK = "Plan feedback received:";
  66. public const string NOTICE_REVIEW_APPROVED = "Review approved. Continue with the task.";
  67. public const string NOTICE_REVIEW_FEEDBACK = "Review feedback received:";
  68. /** The display label of a goal-run parked on a review-family block (U3). */
  69. public const string DISPLAY_AWAITING_REVIEW = "awaiting your review";
  70. /**
  71. * A task's computed presentation state (stored status + unanswered
  72. * blocks): `review requested` while a review block is unanswered
  73. * (even when the stored status is terminal — the post-mortem's
  74. * `done`-with-open-review case), `parked` for the suspended state
  75. * (a goal agent between events), else the stored status. Plan 16
  76. * S5 (U3): a GOAL-RUN task parked on a review-family block (plan
  77. * or completion review) reads "awaiting your review" — agreeing
  78. * with its goal's label, never a bare "blocked". Pure
  79. * presentation — the stored statuses and the dispatcher are
  80. * untouched.
  81. */
  82. public class TaskDisplay : Object {
  83. public string status { get; set; default = ""; }
  84. public bool terminal { get; set; }
  85. public bool failed { get; set; }
  86. public static TaskDisplay compute(TaskEntity task, bool review_pending,
  87. bool goal_review_pending = false) {
  88. var view = new TaskDisplay() {
  89. terminal = task.status == STATUS_DONE || task.status == STATUS_FAILED,
  90. failed = task.status == STATUS_FAILED
  91. };
  92. if (goal_review_pending && task.kind == TASK_KIND_GOAL_RUN) {
  93. view.status = DISPLAY_AWAITING_REVIEW;
  94. } else if (review_pending) {
  95. view.status = "review requested";
  96. } else if (task.status == STATUS_SUSPENDED) {
  97. view.status = "parked";
  98. } else {
  99. view.status = task.status;
  100. }
  101. return view;
  102. }
  103. }
  104. /**
  105. * The full task aggregate loaded by {@link TaskService.get_task}: the
  106. * task itself plus its comments, blocks, progress history and
  107. * session history.
  108. */
  109. public class TaskDetail : Object {
  110. public TaskEntity task { get; set; }
  111. public ImmutableLot<TaskComment> comments { get; set; }
  112. public ImmutableLot<TaskBlock> blocks { get; set; }
  113. public ImmutableLot<TaskProgress> progress { get; set; }
  114. public ImmutableLot<Session> sessions { get; set; }
  115. }
  116. /**
  117. * One agent celebration (`saddle yeehaw`) read back for display: the
  118. * message from the entry's output field and when it landed.
  119. */
  120. public class YeehawEntry : Object {
  121. public string message { get; set; default = ""; }
  122. public DateTime? at { get; set; }
  123. }
  124. /**
  125. * The task's celebration feed for the yeehaw card: the newest
  126. * {@link YEEHAW_CARD_MAX} entries plus the uncapped total (the
  127. * "+N more" hint derives from the two).
  128. */
  129. public class YeehawFeed : Object {
  130. public Vector<YeehawEntry> latest { get; set; default = new Vector<YeehawEntry>(); }
  131. public int64 total { get; set; }
  132. }
  133. /**
  134. * Task storage and lifecycle: creation, enqueue-after-aware
  135. * readiness, agent interaction primitives (comments, blocks,
  136. * progress, reviews) and completion. Saddle bookkeeping rows are
  137. * kept consistent on every terminal transition. The plan-12 task
  138. * graph is goal-shaped: no parents, no dependencies, no plans —
  139. * goals own their tasks.
  140. */
  141. public class TaskService : Object {
  142. private OrmSession db = inject<OrmSession>();
  143. private Connection connection = inject<Connection>();
  144. private StatePulse pulse = inject<StatePulse>();
  145. private PushService push = inject<PushService>();
  146. /**
  147. * Inserts a task (status 'draft' until its creator enqueues
  148. * it). `description`, `task_branch` and `branch_from` may be
  149. * null; `branch_from` prefills from the project's default
  150. * branch; priority clamps to 1–9 (5 is the default, 1 the
  151. * highest). The plan-12 shape: `kind` ("task"/"goal_run"),
  152. * `mode` (implement/investigate/review/test, orchestrate for
  153. * goal runs), `creator` ("human"/"goal" — enqueueing is
  154. * creator-only), `goal_id` (the owning goal) and
  155. * `enqueue_after_task_id` (human-created tasks only: the
  156. * auto-enqueue predecessor, cycle-checked by the caller).
  157. */
  158. public async TaskEntity create_task(int64 project_id, string title, string? description,
  159. int64 priority,
  160. string? task_branch = null, string? branch_from = null,
  161. string status = STATUS_DRAFT,
  162. string kind = TASK_KIND_TASK, string mode = "implement",
  163. string creator = CREATOR_HUMAN, int64 goal_id = 0,
  164. int64 enqueue_after_task_id = 0) throws Error {
  165. string resolved_branch_from = branch_from ?? "";
  166. if (resolved_branch_from.length == 0) {
  167. var project = yield db.query<Project>()
  168. .where(expr("id == $0", elem<int64?>(project_id)))
  169. .first_async();
  170. if (project != null) {
  171. resolved_branch_from = ((!)project).default_branch;
  172. }
  173. }
  174. string? resolved_task_branch = task_branch != null && task_branch.length > 0 ? task_branch : null;
  175. if (goal_id > 0 && kind == TASK_KIND_TASK) {
  176. // Goal worker tasks exist only under a live goal.
  177. var goal = yield db.query<Goal>()
  178. .where(expr("id == $0", elem<int64?>(goal_id)))
  179. .first_async();
  180. if (goal == null) {
  181. throw new Error(Quark.from_string("clanker-saddle"), 75,
  182. @"No goal exists with id $(goal_id)");
  183. }
  184. }
  185. var task = new TaskEntity() {
  186. project_id = project_id,
  187. title = title,
  188. description = description != null && description.length > 0 ? description : null,
  189. priority = priority.clamp(1, 9),
  190. status = status,
  191. task_branch = resolved_task_branch,
  192. branch_from = resolved_branch_from.length > 0 ? resolved_branch_from : null,
  193. kind = kind,
  194. mode = mode,
  195. creator = creator,
  196. goal_id = goal_id,
  197. enqueue_after_task_id = enqueue_after_task_id,
  198. created_at = new DateTime.now_local()
  199. };
  200. yield db.insert_async<TaskEntity>(task);
  201. if (goal_id > 0 && kind == TASK_KIND_TASK) {
  202. // Goal worker tasks run on ephemeral auto-named
  203. // branches cut from the goal branch (the daemon builds
  204. // the worktrees; the stored name feeds compare links).
  205. var goal = yield db.query<Goal>()
  206. .where(expr("id == $0", elem<int64?>(goal_id)))
  207. .first_async();
  208. if (goal != null) {
  209. task.task_branch = @"task/$(goal_id)-$(task.id)";
  210. task.branch_from = ((!)goal).branch_name;
  211. yield db.update_async<TaskEntity>(task);
  212. }
  213. }
  214. yield pulse.bump(task.id, "task created");
  215. return task;
  216. }
  217. /**
  218. * Loads the full aggregate for `id`: the task, its comments,
  219. * blocks, progress and sessions. Returns null when the task
  220. * does not exist.
  221. */
  222. public async TaskDetail? get_task(int64 id) throws Error {
  223. var task = yield db.query<TaskEntity>()
  224. .where(expr("id == $0", elem<int64?>(id)))
  225. .first_async();
  226. if (task == null) {
  227. return null;
  228. }
  229. return new TaskDetail() {
  230. task = (!)task,
  231. comments = yield list_comments(id),
  232. blocks = yield list_blocks(id),
  233. progress = yield list_progress(id),
  234. sessions = yield db.query<Session>()
  235. .where(expr("task_id == $0", elem<int64?>(id)))
  236. .order_by_desc(expr("id"))
  237. .materialise_async()
  238. };
  239. }
  240. /**
  241. * Computed display states for a batch of tasks (all lists share
  242. * this), keyed by task id string. Unanswered review-family
  243. * blocks load in ONE batched query over the wanted tasks, not
  244. * per task. Plan 16 S5 (U3): a goal-run task with a pending
  245. * review-family block (plan/completion/review) reads "awaiting
  246. * your review" — the run-task card agrees with its goal.
  247. */
  248. public async Dictionary<string, TaskDisplay> display_map_for(
  249. Lot<TaskEntity> tasks) throws Error {
  250. var wanted = new Invercargill.DataStructures.HashSet<string>();
  251. var goal_runs = new Invercargill.DataStructures.HashSet<string>();
  252. foreach (var task in tasks) {
  253. wanted.add(task.id.to_string());
  254. if (task.kind == TASK_KIND_GOAL_RUN) {
  255. goal_runs.add(task.id.to_string());
  256. }
  257. }
  258. var review_pending = new Invercargill.DataStructures.HashSet<string>();
  259. var goal_review_pending = new Invercargill.DataStructures.HashSet<string>();
  260. if (wanted.length > 0) {
  261. foreach (var block in yield db.query<TaskBlock>()
  262. .where(id_in("task_id", wanted))
  263. .materialise_async()) {
  264. if (block.answered_at != null
  265. || !wanted.has(block.task_id.to_string())) {
  266. continue;
  267. }
  268. if (block.block_type == BLOCK_REVIEW) {
  269. review_pending.add(block.task_id.to_string());
  270. }
  271. if (block.block_type == BLOCK_REVIEW
  272. || block.block_type == BLOCK_COMPLETION
  273. || block.block_type == BLOCK_PLAN) {
  274. if (goal_runs.has(block.task_id.to_string())) {
  275. goal_review_pending.add(block.task_id.to_string());
  276. }
  277. }
  278. }
  279. }
  280. var map = new Dictionary<string, TaskDisplay>();
  281. foreach (var task in tasks) {
  282. map.set(task.id.to_string(),
  283. TaskDisplay.compute(task, review_pending.has(task.id.to_string()),
  284. goal_review_pending.has(task.id.to_string())));
  285. }
  286. return map;
  287. }
  288. /**
  289. * The display state of a single task — the detail page's path,
  290. * computed from its already-loaded aggregate (no extra queries).
  291. */
  292. public static TaskDisplay display_of_detail(TaskDetail detail) {
  293. bool review_pending = false;
  294. bool goal_review_pending = false;
  295. foreach (var block in detail.blocks) {
  296. if (block.answered_at != null) {
  297. continue;
  298. }
  299. if (block.block_type == BLOCK_REVIEW) {
  300. review_pending = true;
  301. }
  302. if (detail.task.kind == TASK_KIND_GOAL_RUN
  303. && (block.block_type == BLOCK_REVIEW
  304. || block.block_type == BLOCK_COMPLETION
  305. || block.block_type == BLOCK_PLAN)) {
  306. goal_review_pending = true;
  307. }
  308. }
  309. return TaskDisplay.compute(detail.task, review_pending, goal_review_pending);
  310. }
  311. /**
  312. * Lists tasks, optionally filtered by status and/or project,
  313. * newest first (creation descending, id as the same-second
  314. * tiebreak) — the list-page consistency sweep: every table
  315. * leads with the most recent row. Priority stays a visible
  316. * column, no longer a sort key; the dispatcher's candidate
  317. * order ({@link get_ready_tasks}) keeps priority-first.
  318. */
  319. public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
  320. var query = db.query<TaskEntity>();
  321. if (status != null && status.length > 0) {
  322. query.where(expr("status == $0", elem(status)));
  323. }
  324. if (project_id != null) {
  325. query.where(expr("project_id == $0", elem<int64?>(project_id)));
  326. }
  327. return yield query
  328. .order_by_desc(expr("created_at"))
  329. .order_by_desc(expr("id"))
  330. .materialise_async();
  331. }
  332. /**
  333. * Every standalone human-workable task row (kind task, no
  334. * goal), any status, ordered like {@link list_tasks}
  335. * (newest first) — the auto-enqueue-after dropdowns' option
  336. * list, queried for exactly the rows they show instead of
  337. * every task in the table.
  338. */
  339. public async ImmutableLot<TaskEntity> list_standalone_tasks() throws Error {
  340. return yield db.query<TaskEntity>()
  341. .where(expr("kind == $0 && goal_id == $1",
  342. elem(TASK_KIND_TASK), elem<int64?>(0)))
  343. .order_by_desc(expr("created_at"))
  344. .order_by_desc(expr("id"))
  345. .materialise_async();
  346. }
  347. /**
  348. * Saves the human-editable fields of the task identified by `id`
  349. * under the draft-lifecycle edit matrix: in DRAFT all fields
  350. * except status are editable (title, description/mandate,
  351. * priority, branch fields, the plan-12 work mode and —
  352. * human-created tasks only — the auto-enqueue predecessor,
  353. * cycle-validated); in non-draft non-terminal
  354. * states only priority applies; in terminal states nothing
  355. * does. Fields not editable in the current state are silently
  356. * left unchanged (null arguments always mean "leave
  357. * unchanged"; an empty branch field clears it; an
  358. * `enqueue_after_task_id` of -1 CLEARS the predecessor).
  359. * Returns null when the task does not exist. The pulse only
  360. * fires when a value actually moved, so periodic status
  361. * refreshes do not spam live pages.
  362. */
  363. public async TaskEntity? edit_task(int64 id, string? title, string? description,
  364. int64? priority, string? task_branch, string? branch_from,
  365. string? mode = null, int64? enqueue_after_task_id = null) throws Error {
  366. var task = yield db.query<TaskEntity>()
  367. .where(expr("id == $0", elem<int64?>(id)))
  368. .first_async();
  369. if (task == null) {
  370. return null;
  371. }
  372. bool changed = false;
  373. bool draft = ((!)task).status == STATUS_DRAFT;
  374. if (draft) {
  375. if (title != null && title.length > 0 && ((!)task).title != title) {
  376. ((!)task).title = title;
  377. changed = true;
  378. }
  379. if (description != null) {
  380. string? next_description = description.length > 0 ? description : null;
  381. if (((!)task).description != next_description) {
  382. ((!)task).description = next_description;
  383. changed = true;
  384. }
  385. }
  386. if (task_branch != null) {
  387. string? next_branch = task_branch.length > 0 ? task_branch : null;
  388. if (((!)task).task_branch != next_branch) {
  389. ((!)task).task_branch = next_branch;
  390. changed = true;
  391. }
  392. }
  393. if (branch_from != null) {
  394. string? next_from = branch_from.length > 0 ? branch_from : null;
  395. if (((!)task).branch_from != next_from) {
  396. ((!)task).branch_from = next_from;
  397. changed = true;
  398. }
  399. }
  400. if (mode != null && mode.length > 0) {
  401. if (!is_valid_mode(mode)) {
  402. throw new Error(Quark.from_string("clanker-saddle"), 79,
  403. @"Unknown mode '$(mode)' — valid modes: " + string.joinv(", ", TASK_MODES));
  404. }
  405. if (((!)task).mode != mode) {
  406. ((!)task).mode = mode;
  407. changed = true;
  408. }
  409. }
  410. // Human-created tasks only: the auto-enqueue
  411. // predecessor is settable/clearable while draft, and
  412. // setting it validates the chain (no cycles).
  413. if (enqueue_after_task_id != null
  414. && ((!)task).creator == CREATOR_HUMAN
  415. && ((!)task).kind == TASK_KIND_TASK) {
  416. int64 next = (!)enqueue_after_task_id;
  417. if (next < 0) {
  418. // The -1 sentinel clears the predecessor.
  419. next = 0;
  420. }
  421. if (next > 0) {
  422. yield validate_enqueue_after_cycle(id, next);
  423. }
  424. if (((!)task).enqueue_after_task_id != next) {
  425. ((!)task).enqueue_after_task_id = next;
  426. changed = true;
  427. }
  428. }
  429. }
  430. // Priority stays editable in every non-terminal state.
  431. if ((((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED)
  432. && priority != null) {
  433. int64 clamped = ((!)priority).clamp(1, 9);
  434. if (((!)task).priority != clamped) {
  435. ((!)task).priority = clamped;
  436. changed = true;
  437. }
  438. }
  439. yield db.update_async<TaskEntity>(task);
  440. if (changed) {
  441. yield pulse.bump(id, "task saved");
  442. }
  443. return task;
  444. }
  445. /**
  446. * The internal status-transition path (daemon pushes, park and
  447. * result machinery) — never reachable from a human form.
  448. * Stamps `completed_at` on done. Returns null when the task
  449. * does not exist.
  450. */
  451. public async TaskEntity? set_status(int64 id, string status) throws Error {
  452. var task = yield db.query<TaskEntity>()
  453. .where(expr("id == $0", elem<int64?>(id)))
  454. .first_async();
  455. if (task == null) {
  456. return null;
  457. }
  458. if (((!)task).status != status) {
  459. if (status != STATUS_DONE && status != STATUS_FAILED) {
  460. // Terminal states are sticky (plan 13 B2's
  461. // invariant): a NON-terminal transition must never
  462. // downgrade a done/failed task. The entity writes
  463. // below interleave with completions on the main
  464. // loop (the terminal checks in the callers have
  465. // already yielded by then — a late 'working' push
  466. // racing a 'done' push used to resurrect the task
  467. // as working AFTER its session closed done), so
  468. // the guard lives in the UPDATE's WHERE: atomic.
  469. // The row is re-read afterwards so the caller sees
  470. // the truth (terminal included), never a stale
  471. // entity it might write back.
  472. var guard = connection.create_command(
  473. "UPDATE tasks SET status = :status WHERE id = :id"
  474. + " AND status NOT IN ('done', 'failed')");
  475. guard.with_parameter<Invercargill.Element>("status",
  476. elem(status));
  477. guard.with_parameter<Invercargill.Element>("id",
  478. elem<int64?>(id));
  479. yield guard.execute_non_query_async();
  480. task = yield db.query<TaskEntity>()
  481. .where(expr("id == $0", elem<int64?>(id)))
  482. .first_async();
  483. if (task == null) {
  484. return null;
  485. }
  486. } else {
  487. ((!)task).status = status;
  488. if (status == STATUS_DONE && ((!)task).completed_at == null) {
  489. ((!)task).completed_at = new DateTime.now_local();
  490. }
  491. yield db.update_async<TaskEntity>((!)task);
  492. }
  493. yield pulse.bump(id, @"task $(status)");
  494. }
  495. return task;
  496. }
  497. /** Every task under `goal_id` (both kinds), newest first. */
  498. public async ImmutableLot<TaskEntity> list_tasks_for_goal(int64 goal_id) throws Error {
  499. return yield db.query<TaskEntity>()
  500. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  501. .order_by_desc(expr("id"))
  502. .materialise_async();
  503. }
  504. /**
  505. * The Enqueue action (creator-only, plan 12): moves a draft
  506. * task to the queue. Humans enqueue theirs, goal agents theirs
  507. * — an actor that is not the task's creator is refused (the
  508. * goal agent sequences its own tasks). A pending
  509. * auto-enqueue predecessor does not refuse the enqueue — the
  510. * dispatcher's readiness check holds dispatch back until it
  511. * finishes. The internal auto-enqueue-after pass calls this as
  512. * the dependent's creator (the creator's pre-authorisation).
  513. */
  514. public async void enqueue_task(int64 id, string actor = CREATOR_HUMAN) throws Error {
  515. var task = yield db.query<TaskEntity>()
  516. .where(expr("id == $0", elem<int64?>(id)))
  517. .first_async();
  518. if (task == null) {
  519. throw new Error(Quark.from_string("clanker-saddle"), 50,
  520. @"No task exists with id $(id)");
  521. }
  522. if (((!)task).status != STATUS_DRAFT) {
  523. throw new Error(Quark.from_string("clanker-saddle"), 51,
  524. "Only draft tasks can be enqueued");
  525. }
  526. if (((!)task).creator != actor) {
  527. throw new Error(Quark.from_string("clanker-saddle"), 76,
  528. @"Only the task's creator may enqueue it (task created by '$(((!)task).creator)', actor '$(actor)')");
  529. }
  530. ((!)task).status = STATUS_QUEUED;
  531. yield db.update_async<TaskEntity>((!)task);
  532. yield pulse.bump(id, "task enqueued");
  533. }
  534. /**
  535. * The cycle check for `enqueue_after_task_id` (human-created
  536. * tasks only, settable/clearable while the dependent is
  537. * draft): following the predecessor chain from
  538. * `predecessor_id` must never reach `task_id` — a cycle would
  539. * strand every task on it in draft forever.
  540. */
  541. public async void validate_enqueue_after_cycle(int64 task_id,
  542. int64 predecessor_id) throws Error {
  543. if (predecessor_id <= 0) {
  544. return;
  545. }
  546. if (predecessor_id == task_id) {
  547. throw new Error(Quark.from_string("clanker-saddle"), 77,
  548. "A task cannot enqueue after itself");
  549. }
  550. var seen = new Invercargill.DataStructures.HashSet<string>();
  551. int64 current = predecessor_id;
  552. while (current > 0 && seen.add(current.to_string())) {
  553. if (current == task_id) {
  554. throw new Error(Quark.from_string("clanker-saddle"), 78,
  555. @"enqueue-after would form a cycle through task $(predecessor_id)");
  556. }
  557. var row = yield db.query<TaskEntity>()
  558. .where(expr("id == $0", elem<int64?>(current)))
  559. .first_async();
  560. if (row == null) {
  561. return;
  562. }
  563. current = ((!)row).enqueue_after_task_id;
  564. }
  565. if (!seen.has(task_id.to_string())) {
  566. return;
  567. }
  568. throw new Error(Quark.from_string("clanker-saddle"), 78,
  569. @"enqueue-after would form a cycle through task $(predecessor_id)");
  570. }
  571. /**
  572. * The auto-enqueue-after pass (plan 12): when a predecessor
  573. * reaches `done`, its draft dependents enqueue as their
  574. * creator (the pre-authorised enqueue). A failed or cancelled
  575. * predecessor leaves them in draft — the creator decides.
  576. */
  577. private async void enqueue_dependents_of(TaskEntity predecessor) throws Error {
  578. if (predecessor.status != STATUS_DONE) {
  579. return;
  580. }
  581. foreach (var dependent in yield db.query<TaskEntity>()
  582. .where(expr("enqueue_after_task_id == $0 && status == $1",
  583. elem<int64?>(predecessor.id), elem(STATUS_DRAFT)))
  584. .materialise_async()) {
  585. try {
  586. yield enqueue_task(dependent.id, dependent.creator);
  587. } catch (Error e) {
  588. warning("[ClankerSaddle] Auto-enqueue of task %lld after %lld failed: %s",
  589. dependent.id, predecessor.id, e.message);
  590. }
  591. }
  592. }
  593. /**
  594. * The row teardown every task deletion runs (the shared core
  595. * of the human Delete action and the goal cascade's quiet
  596. * delete): attached rules, capabilities, progress, comments
  597. * and blocks die with the task, predecessor references clear,
  598. * the row goes and the pulse fires.
  599. */
  600. private async void delete_task_rows(TaskEntity task) throws Error {
  601. foreach (var link in yield db.query<TaskRule>()
  602. .where(expr("task_id == $0", elem<int64?>(task.id)))
  603. .materialise_async()) {
  604. yield db.delete_async<TaskRule>(link);
  605. }
  606. foreach (var link in yield db.query<TaskCapability>()
  607. .where(expr("task_id == $0", elem<int64?>(task.id)))
  608. .materialise_async()) {
  609. yield db.delete_async<TaskCapability>(link);
  610. }
  611. foreach (var row in yield db.query<TaskProgress>()
  612. .where(expr("task_id == $0", elem<int64?>(task.id)))
  613. .materialise_async()) {
  614. yield db.delete_async<TaskProgress>(row);
  615. }
  616. foreach (var row in yield db.query<TaskComment>()
  617. .where(expr("task_id == $0", elem<int64?>(task.id)))
  618. .materialise_async()) {
  619. yield db.delete_async<TaskComment>(row);
  620. }
  621. foreach (var row in yield db.query<TaskBlock>()
  622. .where(expr("task_id == $0", elem<int64?>(task.id)))
  623. .materialise_async()) {
  624. yield db.delete_async<TaskBlock>(row);
  625. }
  626. yield clear_enqueue_after_references(task.id);
  627. yield db.delete_async<TaskEntity>(task);
  628. yield pulse.bump(task.id, "task deleted");
  629. }
  630. /**
  631. * The delete the goal cascade uses on its own draft tasks:
  632. * every attached row dies with the task and no dependents
  633. * guard applies (the whole goal is going away together).
  634. */
  635. public async void delete_task_quietly(int64 id) throws Error {
  636. var task = yield db.query<TaskEntity>()
  637. .where(expr("id == $0", elem<int64?>(id)))
  638. .first_async();
  639. if (task == null) {
  640. return;
  641. }
  642. yield delete_task_rows((!)task);
  643. }
  644. /**
  645. * The human-only Delete action: removes a DRAFT task. Any
  646. * auto-enqueue-after predecessor references pointing at it are
  647. * cleared first (a dangling predecessor would strand its
  648. * dependents in not-ready forever). Attached rows (comments,
  649. * blocks, progress, rules, capabilities) die with the task.
  650. */
  651. public async void delete_task(int64 id) throws Error {
  652. var task = yield db.query<TaskEntity>()
  653. .where(expr("id == $0", elem<int64?>(id)))
  654. .first_async();
  655. if (task == null) {
  656. return;
  657. }
  658. if (((!)task).status != STATUS_DRAFT) {
  659. throw new Error(Quark.from_string("clanker-saddle"), 53,
  660. "Only draft tasks can be deleted (cancel or retry handles the rest)");
  661. }
  662. yield delete_task_rows((!)task);
  663. }
  664. /**
  665. * Clears `enqueue_after_task_id` references pointing at
  666. * `id`: a deleted predecessor must not strand its dependents
  667. * (their readiness check would wait forever on a task that no
  668. * longer exists).
  669. */
  670. private async void clear_enqueue_after_references(int64 id) throws Error {
  671. foreach (var dependent in yield db.query<TaskEntity>()
  672. .where(expr("enqueue_after_task_id == $0", elem<int64?>(id)))
  673. .materialise_async()) {
  674. dependent.enqueue_after_task_id = 0;
  675. yield db.update_async<TaskEntity>(dependent);
  676. }
  677. }
  678. /**
  679. * Appends a progress line (`saddle progress-update`) to the task's
  680. * history and returns the new row.
  681. */
  682. public async TaskProgress add_progress(int64 task_id, string summary) throws Error {
  683. var row = new TaskProgress() {
  684. task_id = task_id,
  685. summary = summary,
  686. created_at = new DateTime.now_local()
  687. };
  688. yield db.insert_async<TaskProgress>(row);
  689. yield pulse.bump(task_id, "progress");
  690. return row;
  691. }
  692. /** The task's progress history, newest first. */
  693. public async ImmutableLot<TaskProgress> list_progress(int64 task_id) throws Error {
  694. return yield db.query<TaskProgress>()
  695. .where(expr("task_id == $0", elem<int64?>(task_id)))
  696. .order_by_desc(expr("id"))
  697. .materialise_async();
  698. }
  699. /**
  700. * Every task id whose sessions carry a `yeehaw` entry (stringly
  701. * keyed) — the batch presence check the task list and the
  702. * dashboard's task references badge from, one DISTINCT query
  703. * instead of one EXISTS per row.
  704. */
  705. public async Invercargill.DataStructures.HashSet<string> yeehaw_task_ids() throws Error {
  706. var ids = new Invercargill.DataStructures.HashSet<string>();
  707. var command = connection.create_command(
  708. "SELECT DISTINCT s.task_id AS task_id FROM sessions s"
  709. + " JOIN session_commands sc ON sc.session_id = s.id"
  710. + " WHERE sc.type = :kind");
  711. command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
  712. foreach (var row in yield command.execute_query_async()) {
  713. var element = row.get("task_id");
  714. int64? value = 0;
  715. if (element != null && element.try_get_as<int64?>(out value) && value != null) {
  716. ids.add(((!)value).to_string());
  717. }
  718. }
  719. return ids;
  720. }
  721. /**
  722. * The task's yeehaw card data: the newest
  723. * {@link YEEHAW_CARD_MAX} celebrations (message + when, newest
  724. * first) plus the uncapped total. Reads the existing
  725. * `session_commands` rows through the sessions join — the card
  726. * stores nothing of its own.
  727. */
  728. public async YeehawFeed yeehaw_feed(int64 task_id) throws Error {
  729. var total_command = connection.create_command(
  730. "SELECT COUNT(*) AS total FROM session_commands sc"
  731. + " JOIN sessions s ON s.id = sc.session_id"
  732. + " WHERE s.task_id = :task AND sc.type = :kind");
  733. total_command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
  734. total_command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
  735. var total = yield total_command.execute_scalar_async();
  736. int64? total_value = 0;
  737. if (total != null) {
  738. total.try_get_as<int64?>(out total_value);
  739. }
  740. var feed = new YeehawFeed() { total = total_value };
  741. var rows_command = connection.create_command(
  742. "SELECT sc.output_text AS message, sc.command_text AS command,"
  743. + " sc.started_at AS started, sc.completed_at AS completed"
  744. + " FROM session_commands sc JOIN sessions s ON s.id = sc.session_id"
  745. + " WHERE s.task_id = :task AND sc.type = :kind"
  746. + " ORDER BY sc.id DESC LIMIT :cap");
  747. rows_command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
  748. rows_command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
  749. rows_command.with_parameter<Invercargill.Element>("cap", elem<int64?>(YEEHAW_CARD_MAX));
  750. foreach (var row in yield rows_command.execute_query_async()) {
  751. string message = string_of(row.get("message"));
  752. if (message.length == 0) {
  753. // The message rides the output field; the command
  754. // line is the fallback for empty-output pushes.
  755. message = string_of(row.get("command"));
  756. }
  757. DateTime? at = date_time_of(row.get("started"));
  758. if (at == null) {
  759. at = date_time_of(row.get("completed"));
  760. }
  761. feed.latest.add(new YeehawEntry() { message = message, at = at });
  762. }
  763. return feed;
  764. }
  765. /** A raw query column back as a string ("" when null/unreadable). */
  766. private static string string_of(Invercargill.Element? element) {
  767. if (element == null) {
  768. return "";
  769. }
  770. string value = "";
  771. return ((!)element).try_get_as<string>(out value) ? value : "";
  772. }
  773. /** A raw query column back as a DateTime (null when null/unreadable). */
  774. private static DateTime? date_time_of(Invercargill.Element? element) {
  775. if (element == null) {
  776. return null;
  777. }
  778. DateTime value;
  779. return ((!)element).try_get_as<DateTime>(out value) ? value : null;
  780. }
  781. /**
  782. * Appends a comment; `author` is 'agent', 'user:<name>' or
  783. * 'system' (the harness-authored bookkeeping rows — plan 15
  784. * C2's distinct system-event class, never agent content).
  785. */
  786. public async TaskComment add_comment(int64 task_id, string author, string body,
  787. string? quote = null) throws Error {
  788. var comment = new TaskComment() {
  789. task_id = task_id,
  790. author = author,
  791. body = body,
  792. quote = quote != null && quote.strip().length > 0 ? quote.strip() : null,
  793. created_at = new DateTime.now_local()
  794. };
  795. yield db.insert_async<TaskComment>(comment);
  796. yield pulse.bump(task_id, "comment");
  797. return comment;
  798. }
  799. /**
  800. * Records a harness/system event on the task's thread (plan
  801. * 15 C2): park/resume bookkeeping (aborts, requeues,
  802. * retries), the session-context restoration, unreachable
  803. * notices — authored 'system', rendered with the distinct
  804. * muted system styling, never an agent comment row.
  805. */
  806. public async TaskComment add_system_comment(int64 task_id, string body) throws Error {
  807. return yield add_comment(task_id, COMMENT_AUTHOR_SYSTEM, body);
  808. }
  809. /**
  810. * Appends a terminal entry to the task's open session so
  811. * conversation-level events (blocks, plans, progress, reviews,
  812. * human comments) appear in the streamed log. A no-op when the
  813. * task has no open session.
  814. */
  815. public async void log_session_entry(int64 task_id, string entry_type,
  816. string command, string? output) throws Error {
  817. var session = yield open_session_for_task(task_id);
  818. if (session == null) {
  819. return;
  820. }
  821. var entry = new SessionCommand() {
  822. session_id = ((!)session).id,
  823. command_text = command,
  824. output_text = output,
  825. entry_type = entry_type,
  826. started_at = new DateTime.now_local()
  827. };
  828. yield db.insert_async<SessionCommand>(entry);
  829. }
  830. /**
  831. * Raises a blocking prompt on a task — `question` (optionally with
  832. * multiple-choice `options` and a markdown `body`), `rfc` or
  833. * `review` — and flips it to 'blocked' (or 'review_requested')
  834. * while it waits for a human answer. Returns the new block.
  835. */
  836. public async TaskBlock add_block(int64 task_id, string question, string block_type = BLOCK_QUESTION,
  837. string[]? options = null, string? body = null,
  838. bool quiet = false) throws Error {
  839. string options_json = null;
  840. if (options != null && options.length > 0) {
  841. var array = new Json.Array();
  842. foreach (var option in options) {
  843. array.add_string_element(option);
  844. }
  845. options_json = Json.to_string(new Json.Node(Json.NodeType.ARRAY).init_array(array), false);
  846. }
  847. var block = new TaskBlock() {
  848. task_id = task_id,
  849. question = question,
  850. block_type = block_type.length > 0 ? block_type : BLOCK_QUESTION,
  851. options = options_json,
  852. body = body != null && body.length > 0 ? body : null,
  853. created_at = new DateTime.now_local()
  854. };
  855. yield db.insert_async<TaskBlock>(block);
  856. var task = yield db.query<TaskEntity>()
  857. .where(expr("id == $0", elem<int64?>(task_id)))
  858. .first_async();
  859. if (task != null && (((!)task).status == STATUS_ASSIGNED || ((!)task).status == STATUS_WORKING
  860. || ((!)task).status == STATUS_BLOCKED || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
  861. // The park flip is a guarded update: a completion
  862. // interleaving the raise keeps its terminal state (the
  863. // read above already yielded — the entity is stale by
  864. // write time under load; plan 13 B2's sticky rule).
  865. var guard = connection.create_command(
  866. "UPDATE tasks SET status = :status WHERE id = :id"
  867. + " AND status IN ('assigned', 'working', 'blocked', 'review_requested')");
  868. guard.with_parameter<Invercargill.Element>("status",
  869. elem(block.block_type == BLOCK_REVIEW
  870. ? STATUS_REVIEW_REQUESTED : STATUS_BLOCKED));
  871. guard.with_parameter<Invercargill.Element>("id",
  872. elem<int64?>(task_id));
  873. yield guard.execute_non_query_async();
  874. // Plan 16 D4: a raised block parks the round — the
  875. // session row CLOSES 'parked' right here (every raise
  876. // path funnels through this flip, entry-driven raises
  877. // included, so no parked task ever keeps a dangling
  878. // open session). The workspace awaits its resuming
  879. // allocation on a fresh row.
  880. yield park_open_sessions(task_id);
  881. }
  882. yield pulse.bump(task_id, "block raised");
  883. // Web Push (plan 11 task E): every raised block notifies all
  884. // push-subscribed users globally — the goal agent's mirrored
  885. // blocks stay quiet (the goal-level block owns the
  886. // notification with the goal's title and URL).
  887. if (!quiet && task != null) {
  888. push.notify_global(PushService.block_notification(
  889. ((!)task).title, task_id, block.block_type, block.question));
  890. }
  891. return block;
  892. }
  893. /**
  894. * Records a review request (`saddle review`): a review-type block
  895. * carrying the summary markdown, with the task flipped to
  896. * 'review_requested' while it waits for the human verdict.
  897. */
  898. public async TaskBlock request_review(int64 task_id, string summary) throws Error {
  899. return yield add_block(task_id, "Review requested", BLOCK_REVIEW, null,
  900. summary.length > 0 ? summary : null);
  901. }
  902. /**
  903. * Records a human's answer to a block. A parked round (its
  904. * session closed 'parked' at the raise — plan 16 D4) stays
  905. * 'blocked'/'review_requested': the dispatcher resumes the
  906. * paused workspace and hands the answer over. Only a block on
  907. * a task that NEVER RAN (no session rows at all) flips
  908. * straight back to 'working'.
  909. */
  910. public async void answer_block(int64 block_id, string answer,
  911. string? answered_by = null) throws Error {
  912. var block = yield db.query<TaskBlock>()
  913. .where(expr("id == $0", elem<int64?>(block_id)))
  914. .first_async();
  915. if (block == null) {
  916. throw new Error(Quark.from_string("clanker-saddle"), 5,
  917. @"No block exists with id $(block_id)");
  918. }
  919. ((!)block).answer = answer;
  920. ((!)block).answered_at = new DateTime.now_local();
  921. ((!)block).answered_by = answered_by;
  922. yield db.update_async<TaskBlock>((!)block);
  923. var task = yield db.query<TaskEntity>()
  924. .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
  925. .first_async();
  926. if (task != null && (((!)task).status == STATUS_BLOCKED
  927. || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
  928. var session = yield open_session_for_task(((!)block).task_id);
  929. if (session == null
  930. && (yield newest_session_for_task(((!)block).task_id)) == null) {
  931. ((!)task).status = STATUS_WORKING;
  932. yield db.update_async<TaskEntity>((!)task);
  933. }
  934. }
  935. yield pulse.bump(((!)block).task_id, "block answered");
  936. }
  937. /**
  938. * The git-host compare URL for the task's branches —
  939. * `{git_url}/compare/{branch_from}...{task_branch}` — or "" when
  940. * the task has no working branch.
  941. */
  942. public async string diff_url_for_task(int64 task_id) throws Error {
  943. var task = yield db.query<TaskEntity>()
  944. .where(expr("id == $0", elem<int64?>(task_id)))
  945. .first_async();
  946. if (task == null || ((!)task).task_branch == null
  947. || ((!)task).task_branch.length == 0) {
  948. return "";
  949. }
  950. var project = yield db.query<Project>()
  951. .where(expr("id == $0", elem<int64?>(((!)task).project_id)))
  952. .first_async();
  953. if (project == null) {
  954. return "";
  955. }
  956. string base_branch = ((!)task).branch_from ?? ((!)project).default_branch;
  957. return @"$(https_git_url(((!)project).git_url))/compare/$(base_branch)...$(((!)task).task_branch)";
  958. }
  959. /**
  960. * Translates a project's git remote into its browsable HTTPS form
  961. * so compare links open in the web UI instead of an SSH checkout:
  962. * `ssh://git@host:port/owner/repo.git` and `git@host:owner/repo(.git)`
  963. * both become `https://host/owner/repo` (the port is dropped —
  964. * SSH-only ports like fabrica.unitatem.net:222 have no HTTPS
  965. * meaning). Anything already http(s) (or unrecognised) is kept
  966. * as-is, minus a trailing `.git`.
  967. */
  968. public static string https_git_url(string git_url) {
  969. string url = git_url.strip();
  970. if (url.has_prefix("http://") || url.has_prefix("https://")) {
  971. return url.has_suffix(".git") ? url.substring(0, url.length - 4) : url;
  972. }
  973. // ssh://git@host[:port]/owner/repo.git
  974. if (url.has_prefix("ssh://")) {
  975. string rest = url.substring("ssh://".length);
  976. if (rest.has_prefix("git@")) {
  977. rest = rest.substring("git@".length);
  978. }
  979. int slash = rest.index_of("/");
  980. if (slash < 0) {
  981. return url;
  982. }
  983. string host_port = rest.substring(0, slash);
  984. string path = rest.substring(slash);
  985. if (host_port.contains(":")) {
  986. // SSH-only ports (fabrica.unitatem.net:222) carry no
  987. // HTTPS meaning — the browse host is plain.
  988. host_port = host_port.split(":")[0];
  989. }
  990. if (path.has_suffix(".git")) {
  991. path = path.substring(0, path.length - 4);
  992. }
  993. return @"https://$(host_port)$(path)";
  994. }
  995. // git@host:owner/repo(.git) — scp-style (the colon sits before
  996. // any path slash)
  997. int at = url.index_of("@");
  998. int colon = url.index_of(":");
  999. int slash = url.index_of("/");
  1000. if (at >= 0 && colon > at && (slash < 0 || colon < slash)) {
  1001. string host = url.substring(at + 1, colon - at - 1);
  1002. string path = url.substring(colon + 1);
  1003. if (path.has_suffix(".git")) {
  1004. path = path.substring(0, path.length - 4);
  1005. }
  1006. return @"https://$(host)/$(path)";
  1007. }
  1008. return url;
  1009. }
  1010. /**
  1011. * Task ids currently `assigned`/`working` whose open session sits
  1012. * on an offline or fenced saddle — the queue marks them
  1013. * "saddle unreachable — requeueing" while recovery runs.
  1014. * Batched: the candidate tasks load once, their sessions in
  1015. * one query, the saddles in another.
  1016. */
  1017. public async Invercargill.DataStructures.HashSet<string> unreachable_task_ids() throws Error {
  1018. var ids = new Invercargill.DataStructures.HashSet<string>();
  1019. var candidate_ids = new Invercargill.DataStructures.HashSet<string>();
  1020. foreach (var status in new string[] { STATUS_ASSIGNED, STATUS_WORKING }) {
  1021. foreach (var task in yield db.query<TaskEntity>()
  1022. .where(expr("status == $0", elem(status)))
  1023. .materialise_async()) {
  1024. candidate_ids.add(task.id.to_string());
  1025. }
  1026. }
  1027. if (candidate_ids.length == 0) {
  1028. return ids;
  1029. }
  1030. // The newest open session per task: sessions ordered by id
  1031. // ascending, the LAST open row per task wins (the
  1032. // pre-batch semantics) — walked backwards, first hit per
  1033. // task keeps the slot.
  1034. var row_tasks = new Vector<string>();
  1035. var row_saddles = new Vector<string>();
  1036. foreach (var session in yield db.query<Session>()
  1037. .where(id_in("task_id", candidate_ids))
  1038. .order_by(expr("id"))
  1039. .materialise_async()) {
  1040. if (session.ended_at == null) {
  1041. row_tasks.add(session.task_id.to_string());
  1042. row_saddles.add(session.saddle_id.to_string());
  1043. }
  1044. }
  1045. var open_task_ids = new Vector<string>();
  1046. var open_saddle_ids = new Vector<string>();
  1047. var taken_tasks = new Invercargill.DataStructures.HashSet<string>();
  1048. for (int index = (int) row_tasks.length - 1; index >= 0; index--) {
  1049. if (taken_tasks.add(row_tasks.get(index))) {
  1050. open_task_ids.add(row_tasks.get(index));
  1051. open_saddle_ids.add(row_saddles.get(index));
  1052. }
  1053. }
  1054. if (open_task_ids.length == 0) {
  1055. return ids;
  1056. }
  1057. var saddle_ids = new Invercargill.DataStructures.HashSet<string>();
  1058. foreach (var saddle_id in open_saddle_ids) {
  1059. saddle_ids.add(saddle_id);
  1060. }
  1061. var offline = new Invercargill.DataStructures.HashSet<string>();
  1062. foreach (var saddle in yield db.query<Saddle>()
  1063. .where(id_in("id", saddle_ids))
  1064. .materialise_async()) {
  1065. if (saddle.status == SADDLE_OFFLINE) {
  1066. offline.add(saddle.id.to_string());
  1067. }
  1068. }
  1069. for (int index = 0; index < open_task_ids.length; index++) {
  1070. if (offline.has(open_saddle_ids.get(index))) {
  1071. ids.add(open_task_ids.get(index));
  1072. }
  1073. }
  1074. return ids;
  1075. }
  1076. /**
  1077. * An OR-chain of `column == id` comparisons for a batch of ids
  1078. * (the grammar has no IN operator), or null for an empty
  1079. * batch — the WHERE-in the batched lookups build.
  1080. */
  1081. /**
  1082. * The OR-chain batch convention (plan 16 item 23 note): id
  1083. * batches compare through decimal STRINGS — Invercargill
  1084. * collections corrupt boxed int64 vectors, and the expression
  1085. * layer's string compare against integer columns reads fine
  1086. * (SQLite's dynamic typing). Shared with SaddleService's
  1087. * derived fleet lists.
  1088. */
  1089. internal static Expression? id_in(string column,
  1090. Invercargill.DataStructures.HashSet<string> ids) throws Error {
  1091. Expression? chain = null;
  1092. foreach (var id in ids) {
  1093. var comparison = expr(column + " == $0", elem(id));
  1094. chain = chain == null
  1095. ? comparison : new BinaryExpression((!)chain, comparison,
  1096. BinaryOperator.OR);
  1097. }
  1098. return chain;
  1099. }
  1100. /**
  1101. * The resume_notice wording for an answered block (the plan-11
  1102. * stable verdicts): review and plan answers are already stored
  1103. * in the stable wording and pass through verbatim; question
  1104. * replies are formatted the way the daemon used to format
  1105. * delivered answers ("Human selected: …" for option matches,
  1106. * "Human answered (other): …" otherwise); RFC/plain answers
  1107. * carry the "Human answer: …" prefix.
  1108. */
  1109. public static string verdict_notice_for(TaskBlock block) {
  1110. string answer = block.answer ?? "";
  1111. if (block.block_type == BLOCK_REVIEW || block.block_type == BLOCK_PLAN) {
  1112. return answer;
  1113. }
  1114. if (block.block_type == BLOCK_QUESTION) {
  1115. foreach (var option in EntryJson.parse_string_array(block.options)) {
  1116. if (option == answer) {
  1117. return "Human selected: " + answer;
  1118. }
  1119. }
  1120. return "Human answered (other): " + answer;
  1121. }
  1122. return "Human answer: " + answer;
  1123. }
  1124. /**
  1125. * The ONE terminal stamping path (complete/fail/cancel): owns
  1126. * the status flip, the `completed_at` stamp and the summary
  1127. * precedence — a non-empty `summary` replaces whatever the
  1128. * row holds, else `empty_summary` applies only when the row
  1129. * holds nothing. Callers keep their own idempotence guard
  1130. * (terminal states are sticky, plan 13 B2).
  1131. */
  1132. private async void mark_terminal(TaskEntity task, string status, string? summary,
  1133. string? empty_summary = null) throws Error {
  1134. task.status = status;
  1135. task.completed_at = new DateTime.now_local();
  1136. if (summary != null && summary.length > 0) {
  1137. task.summary = summary;
  1138. } else if ((task.summary ?? "").length == 0 && empty_summary != null) {
  1139. task.summary = empty_summary;
  1140. }
  1141. yield db.update_async<TaskEntity>(task);
  1142. }
  1143. /**
  1144. * The ONE park stamping path: `suspended` plus the
  1145. * `suspended_at` stamp, stamped once per parking window so the
  1146. * resume trigger only sees goal events that landed after the
  1147. * suspension. No-op when already parked (the stamp survives).
  1148. * Plan 16 D4: parking closes the round's open sessions
  1149. * ('parked') — the pause record the resume machinery keys on
  1150. * is the newest session row, open or closed.
  1151. */
  1152. public async void set_parked(int64 task_id) throws Error {
  1153. var task = yield db.query<TaskEntity>()
  1154. .where(expr("id == $0", elem<int64?>(task_id)))
  1155. .first_async();
  1156. if (task == null || ((!)task).status == STATUS_SUSPENDED) {
  1157. return;
  1158. }
  1159. ((!)task).status = STATUS_SUSPENDED;
  1160. ((!)task).suspended_at = new DateTime.now_local();
  1161. yield db.update_async<TaskEntity>((!)task);
  1162. yield park_open_sessions(task_id);
  1163. yield pulse.bump(task_id, "task suspended");
  1164. }
  1165. /**
  1166. * Marks a task done: records `summary`, stamps `completed_at`,
  1167. * closes any open session (outcome `outcome`) and frees the
  1168. * saddle that was running it. Terminal transitions project ONCE
  1169. * (plan 13 B2): a task already done or failed keeps its first
  1170. * outcome — a redelivered push or a second terminal entry with
  1171. * a fresh id re-runs no bookkeeping, no comment, no event
  1172. * (retry_task is the one path that un-terminals).
  1173. */
  1174. public async void complete_task(int64 task_id, string? summary,
  1175. string outcome) throws Error {
  1176. var task = yield db.query<TaskEntity>()
  1177. .where(expr("id == $0", elem<int64?>(task_id)))
  1178. .first_async();
  1179. if (task == null) {
  1180. throw new Error(Quark.from_string("clanker-saddle"), 8,
  1181. @"No task exists with id $(task_id)");
  1182. }
  1183. if (((!)task).status == STATUS_DONE || ((!)task).status == STATUS_FAILED) {
  1184. return;
  1185. }
  1186. yield mark_terminal((!)task, STATUS_DONE, summary);
  1187. yield release_task(task_id, outcome, false);
  1188. yield pulse.bump(task_id, "task completed");
  1189. // Web Push (plan 11 task E): completion notifies the users
  1190. // holding a "Notify me" pref for this task.
  1191. push.notify_opted_in(task_id, PushService.lifecycle_notification(
  1192. ((!)task).title, task_id, "completed", summary));
  1193. // Plan 12: the goal's narrative hears the terminal event
  1194. // (a parked goal agent resumes on it), and a done
  1195. // predecessor auto-enqueues its dependents.
  1196. yield enqueue_dependents_of(((!)task));
  1197. }
  1198. /**
  1199. * Cancels a task: status 'failed' with a cancellation summary. Any
  1200. * open session is closed with outcome 'stopped' and its saddle
  1201. * freed. Draft tasks are refused — they are deleted, not
  1202. * cancelled (nothing ever ran). Idempotent on terminal tasks
  1203. * (plan 13 B2): the first outcome stands.
  1204. */
  1205. public async void cancel_task(int64 task_id) throws Error {
  1206. var task = yield db.query<TaskEntity>()
  1207. .where(expr("id == $0", elem<int64?>(task_id)))
  1208. .first_async();
  1209. if (task == null) {
  1210. throw new Error(Quark.from_string("clanker-saddle"), 9,
  1211. @"No task exists with id $(task_id)");
  1212. }
  1213. if (((!)task).status == STATUS_DONE || ((!)task).status == STATUS_FAILED) {
  1214. return;
  1215. }
  1216. if (((!)task).status == STATUS_DRAFT) {
  1217. throw new Error(Quark.from_string("clanker-saddle"), 55,
  1218. "Draft tasks are deleted, not cancelled");
  1219. }
  1220. yield mark_terminal((!)task, STATUS_FAILED, null, "Cancelled by user");
  1221. yield release_task(task_id, OUTCOME_STOPPED, false);
  1222. yield pulse.bump(task_id, "task cancelled");
  1223. }
  1224. /**
  1225. * Records a saddle-reported failure: status 'failed' with the
  1226. * daemon's message as summary, the open session closed with
  1227. * outcome 'failed' and its saddle freed. Plan 16 S4
  1228. * (terminal-text single representation): `tasks.summary` IS
  1229. * the terminal cache — no duplicate agent comment is written
  1230. * (the narrative renders `summary_lead`; the task page renders
  1231. * the summary card). Terminal transitions project ONCE (plan
  1232. * 13 B2): the live failure's honest burst — a dozen-plus
  1233. * distinct `saddle failure` entry ids in one session — still
  1234. * fails the task exactly once, with one summary write and one
  1235. * goal event.
  1236. */
  1237. public async void fail_task(int64 task_id, string? summary) throws Error {
  1238. var task = yield db.query<TaskEntity>()
  1239. .where(expr("id == $0", elem<int64?>(task_id)))
  1240. .first_async();
  1241. if (task == null) {
  1242. throw new Error(Quark.from_string("clanker-saddle"), 25,
  1243. @"No task exists with id $(task_id)");
  1244. }
  1245. if (((!)task).status == STATUS_DONE || ((!)task).status == STATUS_FAILED) {
  1246. return;
  1247. }
  1248. yield mark_terminal((!)task, STATUS_FAILED, summary,
  1249. "Saddle reported a failure");
  1250. yield release_task(task_id, OUTCOME_FAILED, false);
  1251. yield pulse.bump(task_id, "task failed");
  1252. // Web Push (plan 11 task E): an agent-reported failure
  1253. // notifies the opted-in users (a human cancellation does
  1254. // not — the cancellator already knows).
  1255. push.notify_opted_in(task_id, PushService.lifecycle_notification(
  1256. ((!)task).title, task_id, "failed", summary));
  1257. }
  1258. /** Withdraws the suspension stamp after a successful resume. */
  1259. public async void clear_suspension(int64 task_id) throws Error {
  1260. var task = yield db.query<TaskEntity>()
  1261. .where(expr("id == $0", elem<int64?>(task_id)))
  1262. .first_async();
  1263. if (task == null || ((!)task).suspended_at == null) {
  1264. return;
  1265. }
  1266. ((!)task).suspended_at = null;
  1267. yield db.update_async<TaskEntity>((!)task);
  1268. }
  1269. /**
  1270. * Whether any OTHER task sharing this task's working context
  1271. * moved while it was parked — a sibling under the same parent,
  1272. * or any task of the same project on the same shared working
  1273. * branch. "Moved" means any recorded transition after the park
  1274. * timestamp: a completion/failure (`completed_at`), a dispatch
  1275. * (session start), or a progress entry. The park marker is the
  1276. * suspension stamp, else the newest block's creation (the
  1277. * blocked-at marker), else the session's last recorded
  1278. * activity. False with no siblings or nothing moved — the wire's
  1279. * `branch_advanced`, so the daemon can skip the pointless
  1280. * `git pull --rebase` when the shared branch did not advance.
  1281. */
  1282. public async bool branch_advanced_since(int64 task_id) throws Error {
  1283. var me = yield db.query<TaskEntity>()
  1284. .where(expr("id == $0", elem<int64?>(task_id)))
  1285. .first_async();
  1286. if (me == null) {
  1287. return false;
  1288. }
  1289. DateTime? park = ((!)me).suspended_at;
  1290. if (park == null) {
  1291. foreach (var block in yield db.query<TaskBlock>()
  1292. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1293. .order_by(expr("id"))
  1294. .materialise_async()) {
  1295. if (block.created_at != null) {
  1296. park = ((!)block).created_at;
  1297. }
  1298. }
  1299. }
  1300. if (park == null) {
  1301. foreach (var session in yield db.query<Session>()
  1302. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1303. .materialise_async()) {
  1304. foreach (var entry in yield db.query<SessionCommand>()
  1305. .where(expr("session_id == $0", elem<int64?>(session.id)))
  1306. .materialise_async()) {
  1307. if (entry.started_at != null
  1308. && (park == null
  1309. || ((!)entry.started_at).compare((!)park) > 0)) {
  1310. park = (!)entry.started_at;
  1311. }
  1312. }
  1313. }
  1314. }
  1315. if (park == null) {
  1316. // Never parked (or nothing recorded): nothing could have
  1317. // advanced past a marker that does not exist.
  1318. return false;
  1319. }
  1320. foreach (var sibling in yield sibling_tasks((!)me)) {
  1321. if (sibling.completed_at != null
  1322. && ((!)sibling.completed_at).compare((!)park) > 0) {
  1323. return true;
  1324. }
  1325. foreach (var session in yield db.query<Session>()
  1326. .where(expr("task_id == $0", elem<int64?>(sibling.id)))
  1327. .materialise_async()) {
  1328. if (session.started_at != null
  1329. && session.started_at.compare((!)park) > 0) {
  1330. return true;
  1331. }
  1332. }
  1333. foreach (var progress in yield db.query<TaskProgress>()
  1334. .where(expr("task_id == $0", elem<int64?>(sibling.id)))
  1335. .materialise_async()) {
  1336. if (progress.created_at != null
  1337. && progress.created_at.compare((!)park) > 0) {
  1338. return true;
  1339. }
  1340. }
  1341. }
  1342. return false;
  1343. }
  1344. /**
  1345. * The tasks sharing `task`'s working context (excluding it):
  1346. * same-project tasks on the same non-empty working branch, and
  1347. * — for a goal agent — every other task of its goal (their
  1348. * completions merge into the goal branch, so the goal agent's
  1349. * post-park pull must hear them).
  1350. */
  1351. private async ImmutableLot<TaskEntity> sibling_tasks(TaskEntity task) throws Error {
  1352. var rows = new Vector<TaskEntity>();
  1353. var seen = new Invercargill.DataStructures.HashSet<string>();
  1354. if (task.goal_id > 0) {
  1355. foreach (var sibling in yield db.query<TaskEntity>()
  1356. .where(expr("goal_id == $0", elem<int64?>(task.goal_id)))
  1357. .materialise_async()) {
  1358. if (sibling.id != task.id && seen.add(sibling.id.to_string())) {
  1359. rows.add(sibling);
  1360. }
  1361. }
  1362. }
  1363. if (task.task_branch != null && ((!)task.task_branch).length > 0) {
  1364. foreach (var sibling in yield db.query<TaskEntity>()
  1365. .where(expr("project_id == $0 && task_branch == $1",
  1366. elem<int64?>(task.project_id), elem(((!)task).task_branch)))
  1367. .materialise_async()) {
  1368. if (sibling.id != task.id && seen.add(sibling.id.to_string())) {
  1369. rows.add(sibling);
  1370. }
  1371. }
  1372. }
  1373. return rows.to_immutable_buffer();
  1374. }
  1375. /**
  1376. * Returns a non-terminal task to the queue: closes its open
  1377. * sessions (outcome 'stopped') and frees the saddle that was
  1378. * running it — {@link release_task} with the requeue, the
  1379. * single owner of the bookkeeping. Plan 14 B1: a parked
  1380. * (suspended) task requeues too — the goal page's
  1381. * requeue-to-resume reaches a goal agent parked on a dead
  1382. * saddle; the suspension stamp clears so the next dispatch
  1383. * starts a fresh parking window.
  1384. */
  1385. public async void requeue_task(int64 task_id) throws Error {
  1386. yield release_task(task_id, OUTCOME_STOPPED, true);
  1387. yield pulse.bump(task_id, "task requeued");
  1388. }
  1389. /**
  1390. * Returns a failed task to the queue for another attempt.
  1391. * Plan 16 S4: the failure summary is the terminal cache of the
  1392. * attempt that earned it — retry clears it and copies it
  1393. * NOWHERE (the narrative rendered its lead, the page rendered
  1394. * its card; the next attempt writes a fresh one), so no
  1395. * summary→comment duplicate joins the thread. `completed_at`
  1396. * is withdrawn, any open session closes and its saddle frees —
  1397. * the same bookkeeping as {@link requeue_task} (retry's flip
  1398. * is unconditional: the attempt resets whatever it held),
  1399. * plus the audit comment "Task retried by user".
  1400. */
  1401. public async void retry_task(int64 task_id) throws Error {
  1402. var task = yield db.query<TaskEntity>()
  1403. .where(expr("id == $0", elem<int64?>(task_id)))
  1404. .first_async();
  1405. if (task == null) {
  1406. throw new Error(Quark.from_string("clanker-saddle"), 8,
  1407. @"No task exists with id $(task_id)");
  1408. }
  1409. ((!)task).status = STATUS_QUEUED;
  1410. ((!)task).summary = null;
  1411. ((!)task).completed_at = null;
  1412. yield db.update_async<TaskEntity>((!)task);
  1413. yield release_task(task_id, OUTCOME_STOPPED, false);
  1414. yield add_system_comment(task_id, "Task retried by user");
  1415. yield pulse.bump(task_id, "task retried");
  1416. }
  1417. /**
  1418. * Queued tasks whose auto-enqueue predecessor (if any) is
  1419. * done, ordered by priority (1 first) then creation time —
  1420. * the dispatcher's candidate list. Only the CANDIDATES'
  1421. * predecessors are queried (not every task row).
  1422. */
  1423. public async ImmutableLot<TaskEntity> get_ready_tasks() throws Error {
  1424. var queued = yield db.query<TaskEntity>()
  1425. .where(expr("status == $0", elem(STATUS_QUEUED)))
  1426. .order_by(expr("priority"))
  1427. .order_by(expr("created_at"))
  1428. .materialise_async();
  1429. var predecessor_ids = new Invercargill.DataStructures.HashSet<string>();
  1430. foreach (var task in queued) {
  1431. if (task.enqueue_after_task_id > 0) {
  1432. predecessor_ids.add(task.enqueue_after_task_id.to_string());
  1433. }
  1434. }
  1435. var predecessor_status = new Dictionary<string, string>();
  1436. if (predecessor_ids.length > 0) {
  1437. foreach (var predecessor in yield db.query<TaskEntity>()
  1438. .where(id_in("id", predecessor_ids))
  1439. .materialise_async()) {
  1440. predecessor_status.set(predecessor.id.to_string(), predecessor.status);
  1441. }
  1442. }
  1443. var rows = new Vector<TaskEntity>();
  1444. foreach (var task in queued) {
  1445. bool ready = true;
  1446. if (task.enqueue_after_task_id > 0) {
  1447. // The auto-enqueue predecessor also gates dispatch:
  1448. // a dependent that reached the queue some other way
  1449. // (a predecessor set after a manual enqueue) still
  1450. // waits for it to finish. A predecessor row that
  1451. // does not resolve keeps the dependent waiting.
  1452. string? status = predecessor_status.get_or_default(
  1453. task.enqueue_after_task_id.to_string());
  1454. ready = status != null && status == STATUS_DONE;
  1455. }
  1456. if (ready) {
  1457. rows.add(task);
  1458. }
  1459. }
  1460. return rows.to_immutable_buffer();
  1461. }
  1462. /** Comments on a task, newest first — the thread leads with
  1463. * the latest remark (the list-page consistency sweep). */
  1464. public async ImmutableLot<TaskComment> list_comments(int64 task_id) throws Error {
  1465. return yield db.query<TaskComment>()
  1466. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1467. .order_by_desc(expr("id"))
  1468. .materialise_async();
  1469. }
  1470. /** Blocks on a task, newest first — the blocking sections
  1471. * lead with the most recent question, RFC or review (the
  1472. * list-page consistency sweep). */
  1473. public async ImmutableLot<TaskBlock> list_blocks(int64 task_id) throws Error {
  1474. return yield db.query<TaskBlock>()
  1475. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1476. .order_by_desc(expr("id"))
  1477. .materialise_async();
  1478. }
  1479. /** The task's newest block, or null when it has none — the
  1480. * FIRST row (list_blocks is newest-first since the ordering
  1481. * sweep; the walker keeps the semantics explicit). */
  1482. public async TaskBlock? newest_block(int64 task_id) throws Error {
  1483. foreach (var block in yield list_blocks(task_id)) {
  1484. return block;
  1485. }
  1486. return null;
  1487. }
  1488. /**
  1489. * True when the task's newest block exists and carries an
  1490. * answer — a verdict is waiting to reach the parked workspace
  1491. * (the answer-delivery pass, the affinity resume, or a later
  1492. * assignment payload). Such a task must never be requeued: the
  1493. * requeue closes the session the resume machinery needs.
  1494. * Plan 15 C1: goal blocks live on the run task as single rows
  1495. * — no mirror pair exists to fall out of sync, so the newest
  1496. * row's own answer is the whole truth.
  1497. */
  1498. public async bool newest_block_answered(int64 task_id) throws Error {
  1499. var newest = yield newest_block(task_id);
  1500. return newest != null && ((!)newest).answer != null;
  1501. }
  1502. /** All commands recorded against a session, oldest first. */
  1503. public async ImmutableLot<SessionCommand> list_commands(int64 session_id) throws Error {
  1504. return yield db.query<SessionCommand>()
  1505. .where(expr("session_id == $0", elem<int64?>(session_id)))
  1506. .order_by(expr("id"))
  1507. .materialise_async();
  1508. }
  1509. /**
  1510. * True when the session carries raw PTY log rows — the terminal
  1511. * view then renders the concatenated raw log instead of the
  1512. * typed entry stream.
  1513. */
  1514. public async bool has_raw_log(int64 session_id) throws Error {
  1515. var probe = yield db.query<SessionRawLog>()
  1516. .where(expr("session_id == $0", elem<int64?>(session_id)))
  1517. .first_async();
  1518. return probe != null;
  1519. }
  1520. /**
  1521. * The session still open for `task_id` (no `ended_at`), or null.
  1522. * The null check runs in Vala: the expression layer cannot compare
  1523. * columns against null.
  1524. */
  1525. public async Session? open_session_for_task(int64 task_id) throws Error {
  1526. var sessions = yield db.query<Session>()
  1527. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1528. .order_by(expr("id"))
  1529. .materialise_async();
  1530. foreach (var session in sessions) {
  1531. if (session.ended_at == null) {
  1532. return session;
  1533. }
  1534. }
  1535. return null;
  1536. }
  1537. /**
  1538. * The task's newest session row, open or closed (plan 16 D4:
  1539. * a parked round's row is closed 'parked' — it is still the
  1540. * pause record the affinity resume, the session-scoped task
  1541. * CLI and the goal page's run surfaces key on), or null when
  1542. * the task never ran.
  1543. */
  1544. public async Session? newest_session_for_task(int64 task_id) throws Error {
  1545. Session? newest = null;
  1546. foreach (var session in yield db.query<Session>()
  1547. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1548. .order_by(expr("id"))
  1549. .materialise_async()) {
  1550. newest = session;
  1551. }
  1552. return newest;
  1553. }
  1554. /**
  1555. * Closes the task's open sessions with outcome 'parked'
  1556. * (plan 16 D4 — the ONE park close every raise and suspend
  1557. * path runs; idempotent, nothing to do for rows already
  1558. * closed). The workspace stays on its saddle awaiting the
  1559. * resuming allocation that opens the successor row.
  1560. */
  1561. public async void park_open_sessions(int64 task_id) throws Error {
  1562. foreach (var session in yield db.query<Session>()
  1563. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1564. .materialise_async()) {
  1565. if (session.ended_at != null) {
  1566. continue;
  1567. }
  1568. session.ended_at = new DateTime.now_local();
  1569. session.outcome = OUTCOME_PARKED;
  1570. yield db.update_async<Session>(session);
  1571. }
  1572. }
  1573. /**
  1574. * The ONE release path (plan 16 R2, arch item 13): every path
  1575. * that takes a task off its saddle runs through here. Closes
  1576. * the task's open sessions with `outcome` — the close IS the
  1577. * free (plan 16 item 23: busy/idle derive from the open
  1578. * sessions; no saddle-row write remains to race an ask
  1579. * allocation). `requeue` first returns a non-terminal task to
  1580. * the queue (assigned/working/blocked/review_requested/
  1581. * suspended — the suspension stamp withdraws so the next
  1582. * dispatch opens a fresh parking window).
  1583. *
  1584. * The terminal callers (complete/cancel/fail) pass their own
  1585. * outcome with the status already stamped; requeue_task and
  1586. * the unregister path ride the requeue; retry_task does its
  1587. * own unconditional flip (summary and completed_at die with
  1588. * the attempt) and releases without one. The offline variant
  1589. * ({@link SaddleService.mark_saddle_unreachable}) calls this
  1590. * for its derived held task before its own guarded OFFLINE
  1591. * stamp; its keep-parked gate (a task that parked mid-flight)
  1592. * skips the release entirely.
  1593. */
  1594. public async void release_task(int64 task_id, string outcome, bool requeue) throws Error {
  1595. if (requeue) {
  1596. var task = yield db.query<TaskEntity>()
  1597. .where(expr("id == $0", elem<int64?>(task_id)))
  1598. .first_async();
  1599. if (task != null && (((!)task).status == STATUS_ASSIGNED
  1600. || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
  1601. || ((!)task).status == STATUS_REVIEW_REQUESTED
  1602. || ((!)task).status == STATUS_SUSPENDED)) {
  1603. ((!)task).status = STATUS_QUEUED;
  1604. if (((!)task).suspended_at != null) {
  1605. ((!)task).suspended_at = null;
  1606. }
  1607. yield db.update_async<TaskEntity>((!)task);
  1608. }
  1609. }
  1610. var sessions = yield db.query<Session>()
  1611. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1612. .materialise_async();
  1613. foreach (var session in sessions) {
  1614. if (session.ended_at != null) {
  1615. continue;
  1616. }
  1617. session.ended_at = new DateTime.now_local();
  1618. session.outcome = outcome;
  1619. yield db.update_async<Session>(session);
  1620. }
  1621. }
  1622. }
  1623. }