TaskService.vala 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548
  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 agent-celebration entry type (`saddle yeehaw <message>`): the
  32. * daemon pushes it as a round-results entry carrying the message in
  33. * the output field, so it stores like any other `session_commands`
  34. * row — nothing new is persisted for the badge or the card.
  35. */
  36. public const string ENTRY_YEEHAW = "yeehaw";
  37. /** The task page's yeehaw card cap (newest entries kept). */
  38. public const int YEEHAW_CARD_MAX = 3;
  39. /**
  40. * The stable verdict wordings (plan 11): they are stored as the
  41. * block answers AND ride the resuming allocation's
  42. * `resume_notice` verbatim, so agent and human always read the
  43. * same text.
  44. */
  45. public const string NOTICE_PLAN_APPROVED = "Plan approved. Proceed with implementation.";
  46. public const string NOTICE_PLAN_FEEDBACK = "Plan feedback received:";
  47. public const string NOTICE_REVIEW_APPROVED = "Review approved. Continue with the task.";
  48. public const string NOTICE_REVIEW_FEEDBACK = "Review feedback received:";
  49. /**
  50. * A task's computed presentation state (stored status + unanswered
  51. * blocks): `review requested` while a review block is unanswered
  52. * (even when the stored status is terminal — the post-mortem's
  53. * `done`-with-open-review case), `parked` for the suspended state
  54. * (a goal agent between events), else the stored status. Pure
  55. * presentation — the stored statuses and the dispatcher are
  56. * untouched.
  57. */
  58. public class TaskDisplay : Object {
  59. public string status { get; set; default = ""; }
  60. public bool terminal { get; set; }
  61. public bool failed { get; set; }
  62. public static TaskDisplay compute(TaskEntity task, bool review_pending) {
  63. var view = new TaskDisplay() {
  64. terminal = task.status == STATUS_DONE || task.status == STATUS_FAILED,
  65. failed = task.status == STATUS_FAILED
  66. };
  67. if (review_pending) {
  68. view.status = "review requested";
  69. } else if (task.status == STATUS_SUSPENDED) {
  70. view.status = "parked";
  71. } else {
  72. view.status = task.status;
  73. }
  74. return view;
  75. }
  76. }
  77. /**
  78. * The full task aggregate loaded by {@link TaskService.get_task}: the
  79. * task itself plus its comments, blocks, progress history and
  80. * session history.
  81. */
  82. public class TaskDetail : Object {
  83. public TaskEntity task { get; set; }
  84. public ImmutableLot<TaskComment> comments { get; set; }
  85. public ImmutableLot<TaskBlock> blocks { get; set; }
  86. public ImmutableLot<TaskProgress> progress { get; set; }
  87. public ImmutableLot<Session> sessions { get; set; }
  88. }
  89. /**
  90. * One agent celebration (`saddle yeehaw`) read back for display: the
  91. * message from the entry's output field and when it landed.
  92. */
  93. public class YeehawEntry : Object {
  94. public string message { get; set; default = ""; }
  95. public DateTime? at { get; set; }
  96. }
  97. /**
  98. * The task's celebration feed for the yeehaw card: the newest
  99. * {@link YEEHAW_CARD_MAX} entries plus the uncapped total (the
  100. * "+N more" hint derives from the two).
  101. */
  102. public class YeehawFeed : Object {
  103. public Vector<YeehawEntry> latest { get; set; default = new Vector<YeehawEntry>(); }
  104. public int64 total { get; set; }
  105. }
  106. /**
  107. * Task storage and lifecycle: creation, enqueue-after-aware
  108. * readiness, agent interaction primitives (comments, blocks,
  109. * progress, reviews) and completion. Saddle bookkeeping rows are
  110. * kept consistent on every terminal transition. The plan-12 task
  111. * graph is goal-shaped: no parents, no dependencies, no plans —
  112. * goals own their tasks.
  113. */
  114. public class TaskService : Object {
  115. private OrmSession db = inject<OrmSession>();
  116. private Connection connection = inject<Connection>();
  117. private StatePulse pulse = inject<StatePulse>();
  118. private PushService push = inject<PushService>();
  119. /**
  120. * The goal's task-terminal hook (plan 12): a worker task under
  121. * a goal records a supervision-narrative event — the parked
  122. * goal agent's resume machinery coalesces on these rows (see
  123. * {@link GoalService.resume_due}). Inline (not via
  124. * GoalService) because GoalService depends on this service.
  125. */
  126. private async void note_goal_task_event(TaskEntity task, string outcome_word) throws Error {
  127. if (task.goal_id <= 0 || task.kind != TASK_KIND_TASK) {
  128. return;
  129. }
  130. string lead = "";
  131. if (task.summary != null && ((!)task.summary).length > 0) {
  132. int cut = ((!)task.summary).index_of("\n");
  133. lead = cut >= 0 ? ((!)task.summary).substring(0, cut) : (!)task.summary;
  134. }
  135. string text = lead.length > 0
  136. ? @"Task #$(task.id) $(task.title) — $(outcome_word): $(lead)"
  137. : @"Task #$(task.id) $(task.title) — $(outcome_word)";
  138. yield db.insert_async<GoalEvent>(new GoalEvent() {
  139. goal_id = task.goal_id,
  140. kind = task.status == STATUS_DONE ? "task-done" : "task-failed",
  141. text = text,
  142. task_id = task.id,
  143. created_at = new DateTime.now_local()
  144. });
  145. }
  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(expr("id"))
  237. .materialise_async()
  238. };
  239. }
  240. /**
  241. * Lists tasks, optionally filtered by status and/or project, ordered
  242. * by priority (1 first) then creation time.
  243. */
  244. /**
  245. * Computed display states for a batch of tasks (all lists share
  246. * this), keyed by task id string. Unanswered review blocks load
  247. * once, not per task.
  248. */
  249. public async Dictionary<string, TaskDisplay> display_map_for(
  250. Lot<TaskEntity> tasks) throws Error {
  251. var wanted = new Invercargill.DataStructures.HashSet<string>();
  252. foreach (var task in tasks) {
  253. wanted.add(task.id.to_string());
  254. }
  255. var review_pending = new Invercargill.DataStructures.HashSet<string>();
  256. foreach (var block in yield db.query<TaskBlock>()
  257. .where(expr("type == $0", elem(BLOCK_REVIEW)))
  258. .materialise_async()) {
  259. if (block.answered_at == null && wanted.has(block.task_id.to_string())) {
  260. review_pending.add(block.task_id.to_string());
  261. }
  262. }
  263. var map = new Dictionary<string, TaskDisplay>();
  264. foreach (var task in tasks) {
  265. map.set(task.id.to_string(),
  266. TaskDisplay.compute(task, review_pending.has(task.id.to_string())));
  267. }
  268. return map;
  269. }
  270. /**
  271. * The display state of a single task — the detail page's path,
  272. * computed from its already-loaded aggregate (no extra queries).
  273. */
  274. public static TaskDisplay display_of_detail(TaskDetail detail) {
  275. bool review_pending = false;
  276. foreach (var block in detail.blocks) {
  277. if (block.block_type == BLOCK_REVIEW && block.answered_at == null) {
  278. review_pending = true;
  279. }
  280. }
  281. return TaskDisplay.compute(detail.task, review_pending);
  282. }
  283. public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
  284. var tasks = yield db.query<TaskEntity>().materialise_async();
  285. var rows = new Vector<TaskEntity>();
  286. foreach (var task in tasks) {
  287. if (status != null && status.length > 0 && task.status != status) {
  288. continue;
  289. }
  290. if (project_id != null && task.project_id != project_id) {
  291. continue;
  292. }
  293. rows.add(task);
  294. }
  295. var sorted = rows.sort((a, b) => {
  296. if (a.priority != b.priority) {
  297. return (int)(a.priority - b.priority);
  298. }
  299. int64 a_created = a.created_at != null ? ((!)a.created_at).to_unix() : 0;
  300. int64 b_created = b.created_at != null ? ((!)b.created_at).to_unix() : 0;
  301. return (int)(a_created - b_created);
  302. });
  303. return sorted.to_immutable_buffer();
  304. }
  305. /**
  306. * Saves the human-editable fields of the task identified by `id`
  307. * under the draft-lifecycle edit matrix: in DRAFT all fields
  308. * except status are editable (title, description/mandate,
  309. * priority, branch fields, the plan-12 work mode and —
  310. * human-created tasks only — the auto-enqueue predecessor,
  311. * cycle-validated); in non-draft non-terminal
  312. * states only priority applies; in terminal states nothing
  313. * does. Fields not editable in the current state are silently
  314. * left unchanged (null arguments always mean "leave
  315. * unchanged"; an empty branch field clears it; an
  316. * `enqueue_after_task_id` of -1 CLEARS the predecessor).
  317. * Returns null when the task does not exist. The pulse only
  318. * fires when a value actually moved, so periodic status
  319. * refreshes do not spam live pages.
  320. */
  321. public async TaskEntity? edit_task(int64 id, string? title, string? description,
  322. int64? priority, string? task_branch, string? branch_from,
  323. string? mode = null, int64? enqueue_after_task_id = null) throws Error {
  324. var task = yield db.query<TaskEntity>()
  325. .where(expr("id == $0", elem<int64?>(id)))
  326. .first_async();
  327. if (task == null) {
  328. return null;
  329. }
  330. bool changed = false;
  331. bool draft = ((!)task).status == STATUS_DRAFT;
  332. if (draft) {
  333. if (title != null && title.length > 0 && ((!)task).title != title) {
  334. ((!)task).title = title;
  335. changed = true;
  336. }
  337. if (description != null) {
  338. string? next_description = description.length > 0 ? description : null;
  339. if (((!)task).description != next_description) {
  340. ((!)task).description = next_description;
  341. changed = true;
  342. }
  343. }
  344. if (task_branch != null) {
  345. string? next_branch = task_branch.length > 0 ? task_branch : null;
  346. if (((!)task).task_branch != next_branch) {
  347. ((!)task).task_branch = next_branch;
  348. changed = true;
  349. }
  350. }
  351. if (branch_from != null) {
  352. string? next_from = branch_from.length > 0 ? branch_from : null;
  353. if (((!)task).branch_from != next_from) {
  354. ((!)task).branch_from = next_from;
  355. changed = true;
  356. }
  357. }
  358. if (mode != null && mode.length > 0) {
  359. bool mode_valid = false;
  360. foreach (string candidate in TASK_MODES) {
  361. if (mode == candidate) {
  362. mode_valid = true;
  363. break;
  364. }
  365. }
  366. if (!mode_valid) {
  367. throw new Error(Quark.from_string("clanker-saddle"), 79,
  368. @"Unknown mode '$(mode)' — valid modes: " + string.joinv(", ", TASK_MODES));
  369. }
  370. if (((!)task).mode != mode) {
  371. ((!)task).mode = mode;
  372. changed = true;
  373. }
  374. }
  375. // Human-created tasks only: the auto-enqueue
  376. // predecessor is settable/clearable while draft, and
  377. // setting it validates the chain (no cycles).
  378. if (enqueue_after_task_id != null
  379. && ((!)task).creator == CREATOR_HUMAN
  380. && ((!)task).kind == TASK_KIND_TASK) {
  381. int64 next = (!)enqueue_after_task_id;
  382. if (next < 0) {
  383. // The -1 sentinel clears the predecessor.
  384. next = 0;
  385. }
  386. if (next > 0) {
  387. yield validate_enqueue_after_cycle(id, next);
  388. }
  389. if (((!)task).enqueue_after_task_id != next) {
  390. ((!)task).enqueue_after_task_id = next;
  391. changed = true;
  392. }
  393. }
  394. }
  395. // Priority stays editable in every non-terminal state.
  396. if ((((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED)
  397. && priority != null) {
  398. int64 clamped = ((!)priority).clamp(1, 9);
  399. if (((!)task).priority != clamped) {
  400. ((!)task).priority = clamped;
  401. changed = true;
  402. }
  403. }
  404. yield db.update_async<TaskEntity>(task);
  405. if (changed) {
  406. yield pulse.bump(id, "task saved");
  407. }
  408. return task;
  409. }
  410. /**
  411. * The internal status-transition path (daemon pushes, park and
  412. * result machinery) — never reachable from a human form.
  413. * Stamps `completed_at` on done. Returns null when the task
  414. * does not exist.
  415. */
  416. public async TaskEntity? set_status(int64 id, string status) throws Error {
  417. var task = yield db.query<TaskEntity>()
  418. .where(expr("id == $0", elem<int64?>(id)))
  419. .first_async();
  420. if (task == null) {
  421. return null;
  422. }
  423. if (((!)task).status != status) {
  424. ((!)task).status = status;
  425. if (status == STATUS_DONE && ((!)task).completed_at == null) {
  426. ((!)task).completed_at = new DateTime.now_local();
  427. }
  428. yield db.update_async<TaskEntity>(task);
  429. yield pulse.bump(id, @"task $(status)");
  430. }
  431. return task;
  432. }
  433. /** Every task under `goal_id` (both kinds), oldest first. */
  434. public async ImmutableLot<TaskEntity> list_tasks_for_goal(int64 goal_id) throws Error {
  435. return yield db.query<TaskEntity>()
  436. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  437. .order_by(expr("id"))
  438. .materialise_async();
  439. }
  440. /**
  441. * The Enqueue action (creator-only, plan 12): moves a draft
  442. * task to the queue. Humans enqueue theirs, goal agents theirs
  443. * — an actor that is not the task's creator is refused (the
  444. * goal agent sequences its own tasks). A pending
  445. * auto-enqueue predecessor does not refuse the enqueue — the
  446. * dispatcher's readiness check holds dispatch back until it
  447. * finishes. The internal auto-enqueue-after pass calls this as
  448. * the dependent's creator (the creator's pre-authorisation).
  449. */
  450. public async void enqueue_task(int64 id, string actor = CREATOR_HUMAN) throws Error {
  451. var task = yield db.query<TaskEntity>()
  452. .where(expr("id == $0", elem<int64?>(id)))
  453. .first_async();
  454. if (task == null) {
  455. throw new Error(Quark.from_string("clanker-saddle"), 50,
  456. @"No task exists with id $(id)");
  457. }
  458. if (((!)task).status != STATUS_DRAFT) {
  459. throw new Error(Quark.from_string("clanker-saddle"), 51,
  460. "Only draft tasks can be enqueued");
  461. }
  462. if (((!)task).creator != actor) {
  463. throw new Error(Quark.from_string("clanker-saddle"), 76,
  464. @"Only the task's creator may enqueue it (task created by '$(((!)task).creator)', actor '$(actor)')");
  465. }
  466. ((!)task).status = STATUS_QUEUED;
  467. yield db.update_async<TaskEntity>((!)task);
  468. yield pulse.bump(id, "task enqueued");
  469. }
  470. /**
  471. * The cycle check for `enqueue_after_task_id` (human-created
  472. * tasks only, settable/clearable while the dependent is
  473. * draft): following the predecessor chain from
  474. * `predecessor_id` must never reach `task_id` — a cycle would
  475. * strand every task on it in draft forever.
  476. */
  477. public async void validate_enqueue_after_cycle(int64 task_id,
  478. int64 predecessor_id) throws Error {
  479. if (predecessor_id <= 0) {
  480. return;
  481. }
  482. if (predecessor_id == task_id) {
  483. throw new Error(Quark.from_string("clanker-saddle"), 77,
  484. "A task cannot enqueue after itself");
  485. }
  486. var seen = new Invercargill.DataStructures.HashSet<string>();
  487. int64 current = predecessor_id;
  488. while (current > 0 && seen.add(current.to_string())) {
  489. if (current == task_id) {
  490. throw new Error(Quark.from_string("clanker-saddle"), 78,
  491. @"enqueue-after would form a cycle through task $(predecessor_id)");
  492. }
  493. var row = yield db.query<TaskEntity>()
  494. .where(expr("id == $0", elem<int64?>(current)))
  495. .first_async();
  496. if (row == null) {
  497. return;
  498. }
  499. current = ((!)row).enqueue_after_task_id;
  500. }
  501. if (!seen.has(task_id.to_string())) {
  502. return;
  503. }
  504. throw new Error(Quark.from_string("clanker-saddle"), 78,
  505. @"enqueue-after would form a cycle through task $(predecessor_id)");
  506. }
  507. /**
  508. * The auto-enqueue-after pass (plan 12): when a predecessor
  509. * reaches `done`, its draft dependents enqueue as their
  510. * creator (the pre-authorised enqueue). A failed or cancelled
  511. * predecessor leaves them in draft — the creator decides.
  512. */
  513. private async void enqueue_dependents_of(TaskEntity predecessor) throws Error {
  514. if (predecessor.status != STATUS_DONE) {
  515. return;
  516. }
  517. foreach (var dependent in yield db.query<TaskEntity>()
  518. .where(expr("enqueue_after_task_id == $0 && status == $1",
  519. elem<int64?>(predecessor.id), elem(STATUS_DRAFT)))
  520. .materialise_async()) {
  521. try {
  522. yield enqueue_task(dependent.id, dependent.creator);
  523. } catch (Error e) {
  524. warning("[ClankerSaddle] Auto-enqueue of task %lld after %lld failed: %s",
  525. dependent.id, predecessor.id, e.message);
  526. }
  527. }
  528. }
  529. /**
  530. * The delete the goal cascade uses on its own draft tasks:
  531. * every attached row dies with the task and no dependents
  532. * guard applies (the whole goal is going away together).
  533. */
  534. public async void delete_task_quietly(int64 id) throws Error {
  535. var task = yield db.query<TaskEntity>()
  536. .where(expr("id == $0", elem<int64?>(id)))
  537. .first_async();
  538. if (task == null) {
  539. return;
  540. }
  541. foreach (var link in yield db.query<TaskRule>()
  542. .where(expr("task_id == $0", elem<int64?>(id)))
  543. .materialise_async()) {
  544. yield db.delete_async<TaskRule>(link);
  545. }
  546. foreach (var link in yield db.query<TaskCapability>()
  547. .where(expr("task_id == $0", elem<int64?>(id)))
  548. .materialise_async()) {
  549. yield db.delete_async<TaskCapability>(link);
  550. }
  551. foreach (var row in yield db.query<TaskProgress>()
  552. .where(expr("task_id == $0", elem<int64?>(id)))
  553. .materialise_async()) {
  554. yield db.delete_async<TaskProgress>(row);
  555. }
  556. foreach (var row in yield db.query<TaskComment>()
  557. .where(expr("task_id == $0", elem<int64?>(id)))
  558. .materialise_async()) {
  559. yield db.delete_async<TaskComment>(row);
  560. }
  561. foreach (var row in yield db.query<TaskBlock>()
  562. .where(expr("task_id == $0", elem<int64?>(id)))
  563. .materialise_async()) {
  564. yield db.delete_async<TaskBlock>(row);
  565. }
  566. yield clear_enqueue_after_references(id);
  567. yield db.delete_async<TaskEntity>((!)task);
  568. yield pulse.bump(id, "task deleted");
  569. }
  570. /**
  571. * The human-only Delete action: removes a DRAFT task. Any
  572. * auto-enqueue-after predecessor references pointing at it are
  573. * cleared first (a dangling predecessor would strand its
  574. * dependents in not-ready forever). Attached rows (comments,
  575. * blocks, progress, rules, capabilities) die with the task.
  576. */
  577. public async void delete_task(int64 id) throws Error {
  578. var task = yield db.query<TaskEntity>()
  579. .where(expr("id == $0", elem<int64?>(id)))
  580. .first_async();
  581. if (task == null) {
  582. return;
  583. }
  584. if (((!)task).status != STATUS_DRAFT) {
  585. throw new Error(Quark.from_string("clanker-saddle"), 53,
  586. "Only draft tasks can be deleted (cancel or retry handles the rest)");
  587. }
  588. foreach (var link in yield db.query<TaskRule>()
  589. .where(expr("task_id == $0", elem<int64?>(id)))
  590. .materialise_async()) {
  591. yield db.delete_async<TaskRule>(link);
  592. }
  593. foreach (var link in yield db.query<TaskCapability>()
  594. .where(expr("task_id == $0", elem<int64?>(id)))
  595. .materialise_async()) {
  596. yield db.delete_async<TaskCapability>(link);
  597. }
  598. foreach (var row in yield db.query<TaskProgress>()
  599. .where(expr("task_id == $0", elem<int64?>(id)))
  600. .materialise_async()) {
  601. yield db.delete_async<TaskProgress>(row);
  602. }
  603. foreach (var row in yield db.query<TaskComment>()
  604. .where(expr("task_id == $0", elem<int64?>(id)))
  605. .materialise_async()) {
  606. yield db.delete_async<TaskComment>(row);
  607. }
  608. foreach (var row in yield db.query<TaskBlock>()
  609. .where(expr("task_id == $0", elem<int64?>(id)))
  610. .materialise_async()) {
  611. yield db.delete_async<TaskBlock>(row);
  612. }
  613. yield clear_enqueue_after_references(id);
  614. yield db.delete_async<TaskEntity>((!)task);
  615. yield pulse.bump(id, "task deleted");
  616. }
  617. /**
  618. * Clears `enqueue_after_task_id` references pointing at
  619. * `id`: a deleted predecessor must not strand its dependents
  620. * (their readiness check would wait forever on a task that no
  621. * longer exists).
  622. */
  623. private async void clear_enqueue_after_references(int64 id) throws Error {
  624. foreach (var dependent in yield db.query<TaskEntity>()
  625. .where(expr("enqueue_after_task_id == $0", elem<int64?>(id)))
  626. .materialise_async()) {
  627. dependent.enqueue_after_task_id = 0;
  628. yield db.update_async<TaskEntity>(dependent);
  629. }
  630. }
  631. /**
  632. * Appends a progress line (`saddle progress-update`) to the task's
  633. * history and returns the new row.
  634. */
  635. public async TaskProgress add_progress(int64 task_id, string summary) throws Error {
  636. var row = new TaskProgress() {
  637. task_id = task_id,
  638. summary = summary,
  639. created_at = new DateTime.now_local()
  640. };
  641. yield db.insert_async<TaskProgress>(row);
  642. yield pulse.bump(task_id, "progress");
  643. return row;
  644. }
  645. /** The task's progress history, newest first. */
  646. public async ImmutableLot<TaskProgress> list_progress(int64 task_id) throws Error {
  647. return yield db.query<TaskProgress>()
  648. .where(expr("task_id == $0", elem<int64?>(task_id)))
  649. .order_by_desc(expr("id"))
  650. .materialise_async();
  651. }
  652. /**
  653. * True when any of the task's sessions carries a `yeehaw` entry —
  654. * the badge's presence check, one cheap EXISTS over the existing
  655. * `session_commands` rows (nothing new is stored for the badge).
  656. */
  657. public async bool has_yeehaw(int64 task_id) throws Error {
  658. var command = connection.create_command(
  659. "SELECT EXISTS(SELECT 1 FROM session_commands sc"
  660. + " JOIN sessions s ON s.id = sc.session_id"
  661. + " WHERE s.task_id = :task AND sc.type = :kind) AS hit");
  662. command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
  663. command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
  664. var hit = yield command.execute_scalar_async();
  665. int64? value = 0;
  666. return hit != null && hit.try_get_as<int64?>(out value) && ((!)value) != 0;
  667. }
  668. /**
  669. * Every task id whose sessions carry a `yeehaw` entry (stringly
  670. * keyed) — the batch presence check the task list and the
  671. * dashboard's task references badge from, one DISTINCT query
  672. * instead of one EXISTS per row.
  673. */
  674. public async Invercargill.DataStructures.HashSet<string> yeehaw_task_ids() throws Error {
  675. var ids = new Invercargill.DataStructures.HashSet<string>();
  676. var command = connection.create_command(
  677. "SELECT DISTINCT s.task_id AS task_id FROM sessions s"
  678. + " JOIN session_commands sc ON sc.session_id = s.id"
  679. + " WHERE sc.type = :kind");
  680. command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
  681. foreach (var row in yield command.execute_query_async()) {
  682. var element = row.get("task_id");
  683. int64? value = 0;
  684. if (element != null && element.try_get_as<int64?>(out value) && value != null) {
  685. ids.add(((!)value).to_string());
  686. }
  687. }
  688. return ids;
  689. }
  690. /**
  691. * The task's yeehaw card data: the newest
  692. * {@link YEEHAW_CARD_MAX} celebrations (message + when, newest
  693. * first) plus the uncapped total. Reads the existing
  694. * `session_commands` rows through the sessions join — the card
  695. * stores nothing of its own.
  696. */
  697. public async YeehawFeed yeehaw_feed(int64 task_id) throws Error {
  698. var total_command = connection.create_command(
  699. "SELECT COUNT(*) AS total FROM session_commands sc"
  700. + " JOIN sessions s ON s.id = sc.session_id"
  701. + " WHERE s.task_id = :task AND sc.type = :kind");
  702. total_command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
  703. total_command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
  704. var total = yield total_command.execute_scalar_async();
  705. int64? total_value = 0;
  706. if (total != null) {
  707. total.try_get_as<int64?>(out total_value);
  708. }
  709. var feed = new YeehawFeed() { total = total_value };
  710. var rows_command = connection.create_command(
  711. "SELECT sc.output_text AS message, sc.command_text AS command,"
  712. + " sc.started_at AS started, sc.completed_at AS completed"
  713. + " FROM session_commands sc JOIN sessions s ON s.id = sc.session_id"
  714. + " WHERE s.task_id = :task AND sc.type = :kind"
  715. + " ORDER BY sc.id DESC LIMIT :cap");
  716. rows_command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
  717. rows_command.with_parameter<Invercargill.Element>("kind", elem(ENTRY_YEEHAW));
  718. rows_command.with_parameter<Invercargill.Element>("cap", elem<int64?>(YEEHAW_CARD_MAX));
  719. foreach (var row in yield rows_command.execute_query_async()) {
  720. string message = string_of(row.get("message"));
  721. if (message.length == 0) {
  722. // The message rides the output field; the command
  723. // line is the fallback for empty-output pushes.
  724. message = string_of(row.get("command"));
  725. }
  726. DateTime? at = date_time_of(row.get("started"));
  727. if (at == null) {
  728. at = date_time_of(row.get("completed"));
  729. }
  730. feed.latest.add(new YeehawEntry() { message = message, at = at });
  731. }
  732. return feed;
  733. }
  734. /** A raw query column back as a string ("" when null/unreadable). */
  735. private static string string_of(Invercargill.Element? element) {
  736. if (element == null) {
  737. return "";
  738. }
  739. string value = "";
  740. return ((!)element).try_get_as<string>(out value) ? value : "";
  741. }
  742. /** A raw query column back as a DateTime (null when null/unreadable). */
  743. private static DateTime? date_time_of(Invercargill.Element? element) {
  744. if (element == null) {
  745. return null;
  746. }
  747. DateTime value;
  748. return ((!)element).try_get_as<DateTime>(out value) ? value : null;
  749. }
  750. /** Appends a comment; `author` is 'agent' or 'user:<name>'. */
  751. public async TaskComment add_comment(int64 task_id, string author, string body,
  752. string? quote = null) throws Error {
  753. var comment = new TaskComment() {
  754. task_id = task_id,
  755. author = author,
  756. body = body,
  757. quote = quote != null && quote.strip().length > 0 ? quote.strip() : null,
  758. created_at = new DateTime.now_local()
  759. };
  760. yield db.insert_async<TaskComment>(comment);
  761. yield pulse.bump(task_id, "comment");
  762. return comment;
  763. }
  764. /**
  765. * Appends a terminal entry to the task's open session so
  766. * conversation-level events (blocks, plans, progress, reviews,
  767. * human comments) appear in the streamed log. A no-op when the
  768. * task has no open session.
  769. */
  770. public async void log_session_entry(int64 task_id, string entry_type,
  771. string command, string? output) throws Error {
  772. var session = yield open_session_for_task(task_id);
  773. if (session == null) {
  774. return;
  775. }
  776. var entry = new SessionCommand() {
  777. session_id = ((!)session).id,
  778. command_text = command,
  779. output_text = output,
  780. entry_type = entry_type,
  781. started_at = new DateTime.now_local()
  782. };
  783. yield db.insert_async<SessionCommand>(entry);
  784. }
  785. /**
  786. * Raises a blocking prompt on a task — `question` (optionally with
  787. * multiple-choice `options` and a markdown `body`), `rfc` or
  788. * `review` — and flips it to 'blocked' (or 'review_requested')
  789. * while it waits for a human answer. Returns the new block.
  790. */
  791. public async TaskBlock add_block(int64 task_id, string question, string block_type = BLOCK_QUESTION,
  792. string[]? options = null, string? body = null,
  793. bool quiet = false) throws Error {
  794. string options_json = null;
  795. if (options != null && options.length > 0) {
  796. var array = new Json.Array();
  797. foreach (var option in options) {
  798. array.add_string_element(option);
  799. }
  800. options_json = Json.to_string(new Json.Node(Json.NodeType.ARRAY).init_array(array), false);
  801. }
  802. var block = new TaskBlock() {
  803. task_id = task_id,
  804. question = question,
  805. block_type = block_type.length > 0 ? block_type : BLOCK_QUESTION,
  806. options = options_json,
  807. body = body != null && body.length > 0 ? body : null,
  808. created_at = new DateTime.now_local()
  809. };
  810. yield db.insert_async<TaskBlock>(block);
  811. var task = yield db.query<TaskEntity>()
  812. .where(expr("id == $0", elem<int64?>(task_id)))
  813. .first_async();
  814. if (task != null && (((!)task).status == STATUS_ASSIGNED || ((!)task).status == STATUS_WORKING
  815. || ((!)task).status == STATUS_BLOCKED || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
  816. ((!)task).status = block.block_type == BLOCK_REVIEW
  817. ? STATUS_REVIEW_REQUESTED : STATUS_BLOCKED;
  818. yield db.update_async<TaskEntity>((!)task);
  819. }
  820. yield pulse.bump(task_id, "block raised");
  821. // Web Push (plan 11 task E): every raised block notifies all
  822. // push-subscribed users globally — the goal agent's mirrored
  823. // blocks stay quiet (the goal-level block owns the
  824. // notification with the goal's title and URL).
  825. if (!quiet && task != null) {
  826. push.notify_global(PushService.block_notification(
  827. ((!)task).title, task_id, block.block_type, block.question));
  828. }
  829. return block;
  830. }
  831. /**
  832. * Records a review request (`saddle review`): a review-type block
  833. * carrying the summary markdown, with the task flipped to
  834. * 'review_requested' while it waits for the human verdict.
  835. */
  836. public async TaskBlock request_review(int64 task_id, string summary) throws Error {
  837. return yield add_block(task_id, "Review requested", BLOCK_REVIEW, null,
  838. summary.length > 0 ? summary : null);
  839. }
  840. /**
  841. * Records a human's answer to a block. With an open session the
  842. * task stays 'blocked'/'review_requested' — the dispatcher resumes
  843. * the paused workspace and hands the answer over; without one the
  844. * task flips straight back to 'working'.
  845. */
  846. public async void answer_block(int64 block_id, string answer,
  847. string? answered_by = null) throws Error {
  848. var block = yield db.query<TaskBlock>()
  849. .where(expr("id == $0", elem<int64?>(block_id)))
  850. .first_async();
  851. if (block == null) {
  852. throw new Error(Quark.from_string("clanker-saddle"), 5,
  853. @"No block exists with id $(block_id)");
  854. }
  855. ((!)block).answer = answer;
  856. ((!)block).answered_at = new DateTime.now_local();
  857. ((!)block).answered_by = answered_by;
  858. yield db.update_async<TaskBlock>((!)block);
  859. var task = yield db.query<TaskEntity>()
  860. .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
  861. .first_async();
  862. if (task != null && (((!)task).status == STATUS_BLOCKED
  863. || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
  864. var session = yield open_session_for_task(((!)block).task_id);
  865. if (session == null) {
  866. ((!)task).status = STATUS_WORKING;
  867. yield db.update_async<TaskEntity>((!)task);
  868. }
  869. }
  870. yield pulse.bump(((!)block).task_id, "block answered");
  871. }
  872. /**
  873. * The git-host compare URL for the task's branches —
  874. * `{git_url}/compare/{branch_from}...{task_branch}` — or "" when
  875. * the task has no working branch.
  876. */
  877. public async string diff_url_for_task(int64 task_id) throws Error {
  878. var task = yield db.query<TaskEntity>()
  879. .where(expr("id == $0", elem<int64?>(task_id)))
  880. .first_async();
  881. if (task == null || ((!)task).task_branch == null
  882. || ((!)task).task_branch.length == 0) {
  883. return "";
  884. }
  885. var project = yield db.query<Project>()
  886. .where(expr("id == $0", elem<int64?>(((!)task).project_id)))
  887. .first_async();
  888. if (project == null) {
  889. return "";
  890. }
  891. string base_branch = ((!)task).branch_from ?? ((!)project).default_branch;
  892. return @"$(https_git_url(((!)project).git_url))/compare/$(base_branch)...$(((!)task).task_branch)";
  893. }
  894. /**
  895. * Translates a project's git remote into its browsable HTTPS form
  896. * so compare links open in the web UI instead of an SSH checkout:
  897. * `ssh://git@host:port/owner/repo.git` and `git@host:owner/repo(.git)`
  898. * both become `https://host/owner/repo` (the port is dropped —
  899. * SSH-only ports like fabrica.unitatem.net:222 have no HTTPS
  900. * meaning). Anything already http(s) (or unrecognised) is kept
  901. * as-is, minus a trailing `.git`.
  902. */
  903. public static string https_git_url(string git_url) {
  904. string url = git_url.strip();
  905. if (url.has_prefix("http://") || url.has_prefix("https://")) {
  906. return url.has_suffix(".git") ? url.substring(0, url.length - 4) : url;
  907. }
  908. // ssh://git@host[:port]/owner/repo.git
  909. if (url.has_prefix("ssh://")) {
  910. string rest = url.substring("ssh://".length);
  911. if (rest.has_prefix("git@")) {
  912. rest = rest.substring("git@".length);
  913. }
  914. int slash = rest.index_of("/");
  915. if (slash < 0) {
  916. return url;
  917. }
  918. string host_port = rest.substring(0, slash);
  919. string path = rest.substring(slash);
  920. if (host_port.contains(":")) {
  921. // SSH-only ports (fabrica.unitatem.net:222) carry no
  922. // HTTPS meaning — the browse host is plain.
  923. host_port = host_port.split(":")[0];
  924. }
  925. if (path.has_suffix(".git")) {
  926. path = path.substring(0, path.length - 4);
  927. }
  928. return @"https://$(host_port)$(path)";
  929. }
  930. // git@host:owner/repo(.git) — scp-style (the colon sits before
  931. // any path slash)
  932. int at = url.index_of("@");
  933. int colon = url.index_of(":");
  934. int slash = url.index_of("/");
  935. if (at >= 0 && colon > at && (slash < 0 || colon < slash)) {
  936. string host = url.substring(at + 1, colon - at - 1);
  937. string path = url.substring(colon + 1);
  938. if (path.has_suffix(".git")) {
  939. path = path.substring(0, path.length - 4);
  940. }
  941. return @"https://$(host)/$(path)";
  942. }
  943. return url;
  944. }
  945. /**
  946. * Task ids currently `assigned`/`working` whose open session sits
  947. * on an offline or fenced saddle — the queue marks them
  948. * "saddle unreachable — requeueing" while recovery runs.
  949. */
  950. public async Invercargill.DataStructures.HashSet<string> unreachable_task_ids() throws Error {
  951. var ids = new Invercargill.DataStructures.HashSet<string>();
  952. foreach (var status in new string[] { STATUS_ASSIGNED, STATUS_WORKING }) {
  953. foreach (var task in yield db.query<TaskEntity>()
  954. .where(expr("status == $0", elem(status)))
  955. .materialise_async()) {
  956. var session = yield open_session_for_task(task.id);
  957. if (session == null) {
  958. continue;
  959. }
  960. var saddle = yield db.query<Saddle>()
  961. .where(expr("id == $0", elem<int64?>(((!)session).saddle_id)))
  962. .first_async();
  963. if (saddle != null && (((!)saddle).status == "offline"
  964. || ((!)saddle).status == "recovering")) {
  965. ids.add(task.id.to_string());
  966. }
  967. }
  968. }
  969. return ids;
  970. }
  971. /**
  972. * The resume_notice wording for an answered block (the plan-11
  973. * stable verdicts): review and plan answers are already stored
  974. * in the stable wording and pass through verbatim; question
  975. * replies are formatted the way the daemon used to format
  976. * delivered answers ("Human selected: …" for option matches,
  977. * "Human answered (other): …" otherwise); RFC/plain answers
  978. * carry the "Human answer: …" prefix.
  979. */
  980. public static string verdict_notice_for(TaskBlock block) {
  981. string answer = block.answer ?? "";
  982. if (block.block_type == BLOCK_REVIEW || block.block_type == BLOCK_PLAN) {
  983. return answer;
  984. }
  985. if (block.block_type == BLOCK_QUESTION) {
  986. foreach (var option in parse_block_options(block.options)) {
  987. if (option == answer) {
  988. return "Human selected: " + answer;
  989. }
  990. }
  991. return "Human answered (other): " + answer;
  992. }
  993. return "Human answer: " + answer;
  994. }
  995. /** The stored options JSON (a string array) back into a Vala array. */
  996. public static string[] parse_block_options(string? options_json) {
  997. if (options_json == null || ((!)options_json).length == 0) {
  998. return new string[0];
  999. }
  1000. try {
  1001. var parser = new Json.Parser();
  1002. parser.load_from_data((!)options_json);
  1003. var root = parser.get_root();
  1004. if (root == null || root.get_node_type() != Json.NodeType.ARRAY) {
  1005. return new string[0];
  1006. }
  1007. var values = new Vector<string>();
  1008. foreach (var element in root.get_array().get_elements()) {
  1009. if (element.get_node_type() == Json.NodeType.VALUE
  1010. && element.get_string() != null) {
  1011. values.add(element.get_string());
  1012. }
  1013. }
  1014. return values.to_array();
  1015. } catch (Error e) {
  1016. return new string[0];
  1017. }
  1018. }
  1019. /**
  1020. * Marks a task done: records `summary` and `working_branch`, stamps
  1021. * `completed_at`, closes any open session (outcome `outcome`) and
  1022. * frees the saddle that was running it. Terminal transitions
  1023. * project ONCE (plan 13 B2): a task already done or failed keeps
  1024. * its first outcome — a redelivered push or a second terminal
  1025. * entry with a fresh id re-runs no bookkeeping, no comment, no
  1026. * event (retry_task is the one path that un-terminals).
  1027. */
  1028. public async void complete_task(int64 task_id, string? summary,
  1029. string? working_branch, string outcome) throws Error {
  1030. var task = yield db.query<TaskEntity>()
  1031. .where(expr("id == $0", elem<int64?>(task_id)))
  1032. .first_async();
  1033. if (task == null) {
  1034. throw new Error(Quark.from_string("clanker-saddle"), 8,
  1035. @"No task exists with id $(task_id)");
  1036. }
  1037. if (((!)task).status == STATUS_DONE || ((!)task).status == STATUS_FAILED) {
  1038. return;
  1039. }
  1040. ((!)task).status = STATUS_DONE;
  1041. ((!)task).completed_at = new DateTime.now_local();
  1042. if (summary != null && summary.length > 0) {
  1043. ((!)task).summary = summary;
  1044. }
  1045. if (working_branch != null && working_branch.length > 0) {
  1046. ((!)task).working_branch = working_branch;
  1047. }
  1048. yield db.update_async<TaskEntity>((!)task);
  1049. yield close_open_sessions(task_id, outcome);
  1050. yield free_saddles_running(task_id);
  1051. yield pulse.bump(task_id, "task completed");
  1052. // Web Push (plan 11 task E): completion notifies the users
  1053. // holding a "Notify me" pref for this task.
  1054. push.notify_opted_in(task_id, PushService.lifecycle_notification(
  1055. ((!)task).title, task_id, "completed", summary));
  1056. // Plan 12: the goal's narrative hears the terminal event
  1057. // (a parked goal agent resumes on it), and a done
  1058. // predecessor auto-enqueues its dependents.
  1059. yield note_goal_task_event(((!)task), "completed");
  1060. yield enqueue_dependents_of(((!)task));
  1061. }
  1062. /**
  1063. * Cancels a task: status 'failed' with a cancellation summary. Any
  1064. * open session is closed with outcome 'stopped' and its saddle
  1065. * freed. Draft tasks are refused — they are deleted, not
  1066. * cancelled (nothing ever ran). Idempotent on terminal tasks
  1067. * (plan 13 B2): the first outcome stands.
  1068. */
  1069. public async void cancel_task(int64 task_id) throws Error {
  1070. var task = yield db.query<TaskEntity>()
  1071. .where(expr("id == $0", elem<int64?>(task_id)))
  1072. .first_async();
  1073. if (task == null) {
  1074. throw new Error(Quark.from_string("clanker-saddle"), 9,
  1075. @"No task exists with id $(task_id)");
  1076. }
  1077. if (((!)task).status == STATUS_DONE || ((!)task).status == STATUS_FAILED) {
  1078. return;
  1079. }
  1080. if (((!)task).status == STATUS_DRAFT) {
  1081. throw new Error(Quark.from_string("clanker-saddle"), 55,
  1082. "Draft tasks are deleted, not cancelled");
  1083. }
  1084. ((!)task).status = STATUS_FAILED;
  1085. ((!)task).completed_at = new DateTime.now_local();
  1086. if (((!)task).summary == null || ((!)task).summary.length == 0) {
  1087. ((!)task).summary = "Cancelled by user";
  1088. }
  1089. yield db.update_async<TaskEntity>((!)task);
  1090. yield close_open_sessions(task_id, "stopped");
  1091. yield free_saddles_running(task_id);
  1092. yield pulse.bump(task_id, "task cancelled");
  1093. yield note_goal_task_event(((!)task), "cancelled");
  1094. }
  1095. /**
  1096. * Records a saddle-reported failure: status 'failed' with the
  1097. * daemon's message as summary, the explanation stored again as a
  1098. * agent comment (so the thread carries it), the open session
  1099. * closed with outcome 'failed' and its saddle freed. Terminal
  1100. * transitions project ONCE (plan 13 B2): the live failure's
  1101. * honest burst — a dozen-plus distinct `saddle failure` entry
  1102. * ids in one session — still fails the task exactly once, with
  1103. * one comment and one goal event.
  1104. */
  1105. public async void fail_task(int64 task_id, string? summary) throws Error {
  1106. var task = yield db.query<TaskEntity>()
  1107. .where(expr("id == $0", elem<int64?>(task_id)))
  1108. .first_async();
  1109. if (task == null) {
  1110. throw new Error(Quark.from_string("clanker-saddle"), 25,
  1111. @"No task exists with id $(task_id)");
  1112. }
  1113. if (((!)task).status == STATUS_DONE || ((!)task).status == STATUS_FAILED) {
  1114. return;
  1115. }
  1116. ((!)task).status = STATUS_FAILED;
  1117. ((!)task).completed_at = new DateTime.now_local();
  1118. if (summary != null && summary.length > 0) {
  1119. ((!)task).summary = summary;
  1120. yield add_comment(task_id, "agent", summary);
  1121. } else if (((!)task).summary == null || ((!)task).summary.length == 0) {
  1122. ((!)task).summary = "Saddle reported a failure";
  1123. }
  1124. yield db.update_async<TaskEntity>((!)task);
  1125. yield close_open_sessions(task_id, "failed");
  1126. yield free_saddles_running(task_id);
  1127. yield pulse.bump(task_id, "task failed");
  1128. // Web Push (plan 11 task E): an agent-reported failure
  1129. // notifies the opted-in users (a human cancellation does
  1130. // not — the cancellator already knows).
  1131. push.notify_opted_in(task_id, PushService.lifecycle_notification(
  1132. ((!)task).title, task_id, "failed", summary));
  1133. yield note_goal_task_event(((!)task), "failed");
  1134. }
  1135. /**
  1136. * Parks a suspended task (`saddle suspend`): stores the status
  1137. * and stamps `suspended_at` once per parking window, so the
  1138. * resume trigger only sees goal events that landed after the
  1139. * suspension (a parked goal agent between supervision rounds).
  1140. */
  1141. public async void suspend_task(int64 task_id) throws Error {
  1142. var task = yield db.query<TaskEntity>()
  1143. .where(expr("id == $0", elem<int64?>(task_id)))
  1144. .first_async();
  1145. if (task == null || ((!)task).status == STATUS_SUSPENDED) {
  1146. return;
  1147. }
  1148. ((!)task).status = STATUS_SUSPENDED;
  1149. ((!)task).suspended_at = new DateTime.now_local();
  1150. yield db.update_async<TaskEntity>((!)task);
  1151. yield pulse.bump(task_id, "task suspended");
  1152. }
  1153. /**
  1154. /** Withdraws the suspension stamp after a successful resume. */
  1155. public async void clear_suspension(int64 task_id) throws Error {
  1156. var task = yield db.query<TaskEntity>()
  1157. .where(expr("id == $0", elem<int64?>(task_id)))
  1158. .first_async();
  1159. if (task == null || ((!)task).suspended_at == null) {
  1160. return;
  1161. }
  1162. ((!)task).suspended_at = null;
  1163. yield db.update_async<TaskEntity>((!)task);
  1164. }
  1165. /**
  1166. * Whether any OTHER task sharing this task's working context
  1167. * moved while it was parked — a sibling under the same parent,
  1168. * or any task of the same project on the same shared working
  1169. * branch. "Moved" means any recorded transition after the park
  1170. * timestamp: a completion/failure (`completed_at`), a dispatch
  1171. * (session start), or a progress entry. The park marker is the
  1172. * suspension stamp, else the newest block's creation (the
  1173. * blocked-at marker), else the session's last recorded
  1174. * activity. False with no siblings or nothing moved — the wire's
  1175. * `branch_advanced`, so the daemon can skip the pointless
  1176. * `git pull --rebase` when the shared branch did not advance.
  1177. */
  1178. public async bool branch_advanced_since(int64 task_id) throws Error {
  1179. var me = yield db.query<TaskEntity>()
  1180. .where(expr("id == $0", elem<int64?>(task_id)))
  1181. .first_async();
  1182. if (me == null) {
  1183. return false;
  1184. }
  1185. DateTime? park = ((!)me).suspended_at;
  1186. if (park == null) {
  1187. foreach (var block in yield db.query<TaskBlock>()
  1188. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1189. .order_by(expr("id"))
  1190. .materialise_async()) {
  1191. if (block.created_at != null) {
  1192. park = ((!)block).created_at;
  1193. }
  1194. }
  1195. }
  1196. if (park == null) {
  1197. foreach (var session in yield db.query<Session>()
  1198. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1199. .materialise_async()) {
  1200. foreach (var entry in yield db.query<SessionCommand>()
  1201. .where(expr("session_id == $0", elem<int64?>(session.id)))
  1202. .materialise_async()) {
  1203. if (entry.started_at != null
  1204. && (park == null
  1205. || ((!)entry.started_at).compare((!)park) > 0)) {
  1206. park = (!)entry.started_at;
  1207. }
  1208. }
  1209. }
  1210. }
  1211. if (park == null) {
  1212. // Never parked (or nothing recorded): nothing could have
  1213. // advanced past a marker that does not exist.
  1214. return false;
  1215. }
  1216. foreach (var sibling in yield sibling_tasks((!)me)) {
  1217. if (sibling.completed_at != null
  1218. && ((!)sibling.completed_at).compare((!)park) > 0) {
  1219. return true;
  1220. }
  1221. foreach (var session in yield db.query<Session>()
  1222. .where(expr("task_id == $0", elem<int64?>(sibling.id)))
  1223. .materialise_async()) {
  1224. if (session.started_at != null
  1225. && session.started_at.compare((!)park) > 0) {
  1226. return true;
  1227. }
  1228. }
  1229. foreach (var progress in yield db.query<TaskProgress>()
  1230. .where(expr("task_id == $0", elem<int64?>(sibling.id)))
  1231. .materialise_async()) {
  1232. if (progress.created_at != null
  1233. && progress.created_at.compare((!)park) > 0) {
  1234. return true;
  1235. }
  1236. }
  1237. }
  1238. return false;
  1239. }
  1240. /**
  1241. * The tasks sharing `task`'s working context (excluding it):
  1242. * same-project tasks on the same non-empty working branch, and
  1243. * — for a goal agent — every other task of its goal (their
  1244. * completions merge into the goal branch, so the goal agent's
  1245. * post-park pull must hear them).
  1246. */
  1247. private async ImmutableLot<TaskEntity> sibling_tasks(TaskEntity task) throws Error {
  1248. var rows = new Vector<TaskEntity>();
  1249. var seen = new Invercargill.DataStructures.HashSet<string>();
  1250. if (task.goal_id > 0) {
  1251. foreach (var sibling in yield db.query<TaskEntity>()
  1252. .where(expr("goal_id == $0", elem<int64?>(task.goal_id)))
  1253. .materialise_async()) {
  1254. if (sibling.id != task.id && seen.add(sibling.id.to_string())) {
  1255. rows.add(sibling);
  1256. }
  1257. }
  1258. }
  1259. if (task.task_branch != null && ((!)task.task_branch).length > 0) {
  1260. foreach (var sibling in yield db.query<TaskEntity>()
  1261. .where(expr("project_id == $0 && task_branch == $1",
  1262. elem<int64?>(task.project_id), elem(((!)task).task_branch)))
  1263. .materialise_async()) {
  1264. if (sibling.id != task.id && seen.add(sibling.id.to_string())) {
  1265. rows.add(sibling);
  1266. }
  1267. }
  1268. }
  1269. return rows.to_immutable_buffer();
  1270. }
  1271. /**
  1272. * Returns a non-terminal task to the queue: closes its open
  1273. * sessions (outcome 'stopped') and frees the saddle that was
  1274. * running it. Plan 14 B1: a parked (suspended) task requeues
  1275. * too — the goal page's requeue-to-resume reaches a goal
  1276. * agent parked on a dead saddle; the suspension stamp clears
  1277. * so the next dispatch starts a fresh parking window.
  1278. */
  1279. public async void requeue_task(int64 task_id) throws Error {
  1280. var task = yield db.query<TaskEntity>()
  1281. .where(expr("id == $0", elem<int64?>(task_id)))
  1282. .first_async();
  1283. if (task != null && (((!)task).status == STATUS_ASSIGNED
  1284. || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
  1285. || ((!)task).status == STATUS_REVIEW_REQUESTED
  1286. || ((!)task).status == STATUS_SUSPENDED)) {
  1287. ((!)task).status = STATUS_QUEUED;
  1288. if (((!)task).suspended_at != null) {
  1289. ((!)task).suspended_at = null;
  1290. }
  1291. yield db.update_async<TaskEntity>((!)task);
  1292. }
  1293. yield close_open_sessions(task_id, "stopped");
  1294. yield free_saddles_running(task_id);
  1295. yield pulse.bump(task_id, "task requeued");
  1296. }
  1297. /**
  1298. * Returns a failed task to the queue for another attempt. The
  1299. * failure summary moves into an agent comment (so the history keeps
  1300. * it) before being cleared from the task, `completed_at` is
  1301. * withdrawn, any open session closes and its saddle frees — the
  1302. * same bookkeeping as {@link requeue_task}, plus the audit
  1303. * comment "Task retried by user".
  1304. */
  1305. public async void retry_task(int64 task_id) throws Error {
  1306. var task = yield db.query<TaskEntity>()
  1307. .where(expr("id == $0", elem<int64?>(task_id)))
  1308. .first_async();
  1309. if (task == null) {
  1310. throw new Error(Quark.from_string("clanker-saddle"), 8,
  1311. @"No task exists with id $(task_id)");
  1312. }
  1313. if (((!)task).summary != null && ((!)task).summary.length > 0) {
  1314. yield add_comment(task_id, "agent", (!)(((!)task).summary));
  1315. }
  1316. ((!)task).status = STATUS_QUEUED;
  1317. ((!)task).summary = null;
  1318. ((!)task).completed_at = null;
  1319. yield db.update_async<TaskEntity>((!)task);
  1320. yield close_open_sessions(task_id, "stopped");
  1321. yield free_saddles_running(task_id);
  1322. yield add_comment(task_id, "agent", "Task retried by user");
  1323. yield pulse.bump(task_id, "task retried");
  1324. }
  1325. /**
  1326. * Queued tasks whose auto-enqueue predecessor (if any) is
  1327. * done, ordered by priority (1 first) then creation time —
  1328. * the dispatcher's candidate list.
  1329. */
  1330. public async ImmutableLot<TaskEntity> get_ready_tasks() throws Error {
  1331. var queued = yield db.query<TaskEntity>()
  1332. .where(expr("status == $0", elem(STATUS_QUEUED)))
  1333. .materialise_async();
  1334. var all_tasks = yield db.query<TaskEntity>().materialise_async();
  1335. var rows = new Vector<TaskEntity>();
  1336. foreach (var task in queued) {
  1337. bool ready = true;
  1338. if (task.enqueue_after_task_id > 0) {
  1339. // The auto-enqueue predecessor also gates dispatch:
  1340. // a dependent that reached the queue some other way
  1341. // (a predecessor set after a manual enqueue) still
  1342. // waits for it to finish.
  1343. ready = false;
  1344. foreach (var other in all_tasks) {
  1345. if (other.id == task.enqueue_after_task_id) {
  1346. ready = other.status == STATUS_DONE;
  1347. break;
  1348. }
  1349. }
  1350. }
  1351. if (ready) {
  1352. rows.add(task);
  1353. }
  1354. }
  1355. var sorted = rows.sort((a, b) => {
  1356. if (a.priority != b.priority) {
  1357. return (int)(a.priority - b.priority);
  1358. }
  1359. int64 a_created = a.created_at != null ? ((!)a.created_at).to_unix() : 0;
  1360. int64 b_created = b.created_at != null ? ((!)b.created_at).to_unix() : 0;
  1361. return (int)(a_created - b_created);
  1362. });
  1363. return sorted.to_immutable_buffer();
  1364. }
  1365. /** Comments on a task, oldest first. */
  1366. public async ImmutableLot<TaskComment> list_comments(int64 task_id) throws Error {
  1367. return yield db.query<TaskComment>()
  1368. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1369. .order_by(expr("id"))
  1370. .materialise_async();
  1371. }
  1372. /** Blocks on a task, oldest first. */
  1373. public async ImmutableLot<TaskBlock> list_blocks(int64 task_id) throws Error {
  1374. return yield db.query<TaskBlock>()
  1375. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1376. .order_by(expr("id"))
  1377. .materialise_async();
  1378. }
  1379. /** The task's newest block, or null when it has none. */
  1380. public async TaskBlock? newest_block(int64 task_id) throws Error {
  1381. TaskBlock? newest = null;
  1382. foreach (var block in yield list_blocks(task_id)) {
  1383. newest = block;
  1384. }
  1385. return newest;
  1386. }
  1387. /**
  1388. * True when the task's newest block exists and carries an
  1389. * answer — a verdict is waiting to reach the parked workspace
  1390. * (the answer-delivery pass, the affinity resume, or a later
  1391. * assignment payload). Such a task must never be requeued: the
  1392. * requeue closes the session the resume machinery needs.
  1393. * Plan 15 C1: goal blocks live on the run task as single rows
  1394. * — no mirror pair exists to fall out of sync, so the newest
  1395. * row's own answer is the whole truth.
  1396. */
  1397. public async bool newest_block_answered(int64 task_id) throws Error {
  1398. var newest = yield newest_block(task_id);
  1399. return newest != null && ((!)newest).answer != null;
  1400. }
  1401. /** All commands recorded against a session, oldest first. */
  1402. public async ImmutableLot<SessionCommand> list_commands(int64 session_id) throws Error {
  1403. return yield db.query<SessionCommand>()
  1404. .where(expr("session_id == $0", elem<int64?>(session_id)))
  1405. .order_by(expr("id"))
  1406. .materialise_async();
  1407. }
  1408. /**
  1409. * True when the session carries raw PTY log rows — the terminal
  1410. * view then renders the concatenated raw log instead of the
  1411. * typed entry stream.
  1412. */
  1413. public async bool has_raw_log(int64 session_id) throws Error {
  1414. var probe = yield db.query<SessionRawLog>()
  1415. .where(expr("session_id == $0", elem<int64?>(session_id)))
  1416. .first_async();
  1417. return probe != null;
  1418. }
  1419. /**
  1420. * The session still open for `task_id` (no `ended_at`), or null.
  1421. * The null check runs in Vala: the expression layer cannot compare
  1422. * columns against null.
  1423. */
  1424. public async Session? open_session_for_task(int64 task_id) throws Error {
  1425. var sessions = yield db.query<Session>()
  1426. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1427. .order_by(expr("id"))
  1428. .materialise_async();
  1429. foreach (var session in sessions) {
  1430. if (session.ended_at == null) {
  1431. return session;
  1432. }
  1433. }
  1434. return null;
  1435. }
  1436. private async void close_open_sessions(int64 task_id, string outcome) throws Error {
  1437. var sessions = yield db.query<Session>()
  1438. .where(expr("task_id == $0", elem<int64?>(task_id)))
  1439. .materialise_async();
  1440. foreach (var session in sessions) {
  1441. if (session.ended_at != null) {
  1442. continue;
  1443. }
  1444. session.ended_at = new DateTime.now_local();
  1445. session.outcome = outcome;
  1446. yield db.update_async<Session>(session);
  1447. }
  1448. }
  1449. private async void free_saddles_running(int64 task_id) throws Error {
  1450. // One conditional UPDATE, atomic against a concurrent ask
  1451. // allocation claiming a new task for the saddle between a
  1452. // read and a full-row write.
  1453. var command = connection.create_command(
  1454. "UPDATE saddles SET status = 'idle', current_task_id = 0, allocated_at = NULL"
  1455. + " WHERE current_task_id = :task");
  1456. command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
  1457. yield command.execute_non_query_async();
  1458. }
  1459. }
  1460. }