TaskService.vala 88 KB

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