AllocationService.vala 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql;
  5. using InvercargillSql.Orm;
  6. using Inversion;
  7. using ClankerSaddle.Data;
  8. namespace ClankerSaddle.Services {
  9. /**
  10. * One offerable task for the wake computation: a fresh queued task
  11. * (dependencies met, {@link affine_saddle_id} 0) or a parked task
  12. * ready to resume — blocked/review-requested with its newest block
  13. * answered, or suspended with a due goal event behind it
  14. * parked — pinned to the saddle holding its session record
  15. * (plan 16 D4: parked rounds close 'parked'; the row still names
  16. * the workspace's saddle).
  17. */
  18. public class OfferableTask : Object {
  19. public TaskEntity task { get; set; }
  20. /** The saddle whose parked workspace holds the task (0 = fresh). */
  21. public int64 affine_saddle_id { get; set; }
  22. /** True when resuming a parked workspace (vs. a fresh dispatch). */
  23. public bool parked { get; set; }
  24. /** True when the parked task is a suspended parent's resume. */
  25. public bool suspended_resume { get; set; }
  26. }
  27. /**
  28. * A claimed allocation: the transactional half of
  29. * `POST /api/saddle/next-task` succeeded — the task is `assigned`
  30. * to the asking saddle and its open session row IS the busy record
  31. * (plan 16 item 23: no saddle-row write remains). The assignment
  32. * payload is assembled from it afterwards.
  33. */
  34. public class AllocationClaim : Object {
  35. public int64 task_id { get; set; }
  36. public int64 session_id { get; set; }
  37. public bool parked { get; set; }
  38. public bool suspended_resume { get; set; }
  39. /** The task had never started before this claim (its first dispatch). */
  40. public bool first_start { get; set; }
  41. }
  42. /**
  43. * The ask/allocation half of the plan-10 work-acquisition flip:
  44. * daemons ask (`POST /api/saddle/next-task`), the allocation picks
  45. * the best offerable task for the asking saddle and claims it in a
  46. * single write transaction, and the dispatcher's wake computation
  47. * uses the same offerable set to decide which sleeping daemons to
  48. * wake.
  49. *
  50. * Two filters, in priority order:
  51. *
  52. * 1. **Workspace affinity** — a task parked (blocked, awaiting
  53. * review or suspended) on this saddle is offered ONLY to this
  54. * saddle: its workspace lives here, and capabilities do not
  55. * apply (the parked session pins the pairing, exactly like the
  56. * pre-flip resume paths).
  57. * 2. **Capability match** — fresh queued tasks (dependencies met)
  58. * go to the asking saddle only when its capability set is a
  59. * superset of the task's required tags.
  60. *
  61. * The claim itself runs inside one `BEGIN IMMEDIATE` transaction
  62. * with no awaits, so concurrent asks cannot interleave between the
  63. * decision and the writes: exactly one ask wins a task even when
  64. * several fire simultaneously. Every candidate is re-validated
  65. * inside the transaction (a racing ask may have claimed it after
  66. * the candidate list was built), and a lost race simply falls
  67. * through to the next candidate.
  68. */
  69. public class AllocationService : Object {
  70. private OrmSession db = inject<OrmSession>();
  71. private Connection connection = inject<Connection>();
  72. private TaskService tasks = inject<TaskService>();
  73. private CapabilityService capabilities = inject<CapabilityService>();
  74. private GoalService goals = inject<GoalService>();
  75. private RuleService rules = inject<RuleService>();
  76. private StatePulse pulse = inject<StatePulse>();
  77. private PushService push = inject<PushService>();
  78. private SaddlePollState poll_state = inject<SaddlePollState>();
  79. /**
  80. * Allocates the best offerable task to `saddle`, or null when
  81. * nothing is offerable (the daemon sleeps). The transactional
  82. * claim runs first; the assignment payload (answers, resume
  83. * notice, effective rules, project notes, git identity, llm
  84. * config, session id) is assembled from the claimed rows after
  85. * the commit.
  86. */
  87. public async Json.Object? allocate_next(SaddleService saddles, ProjectService projects,
  88. Saddle saddle) throws Error {
  89. if (LlmEndpoint.from_environment() == null) {
  90. // No LLM endpoint configured: assignment stays paused
  91. // (the dispatcher logs it once); the daemon sleeps.
  92. return null;
  93. }
  94. var candidates = yield candidates_for(saddle);
  95. AllocationClaim? claim = null;
  96. foreach (var candidate in candidates) {
  97. claim = claim_candidate(saddle, candidate);
  98. if (claim != null) {
  99. break;
  100. }
  101. }
  102. if (claim == null) {
  103. return null;
  104. }
  105. return yield assemble_assignment(saddles, projects, saddle, (!)claim);
  106. }
  107. /**
  108. * Every offerable task (see {@link OfferableTask}) — the
  109. * dispatcher's wake computation walks this list: parked tasks
  110. * target their affine saddle first, fresh tasks any capable
  111. * sleeping one. A parked task without a session record has no
  112. * workspace to resume and is not offerable.
  113. */
  114. public async ImmutableLot<OfferableTask> offerable_tasks() throws Error {
  115. var rows = new Vector<OfferableTask>();
  116. foreach (var status in new string[] { STATUS_BLOCKED, STATUS_REVIEW_REQUESTED }) {
  117. foreach (var task in yield tasks.list_tasks(status, null)) {
  118. if (yield tasks.newest_block_answered(task.id)) {
  119. var parked = yield parked_offerable(task, false);
  120. if (parked != null) {
  121. rows.add((!)parked);
  122. }
  123. }
  124. }
  125. }
  126. // Plan 12: a parked goal agent resumes on its own
  127. // machinery — task-terminal events under its goal,
  128. // coalesced ~10 s ({@link GoalService.resume_due}); plain
  129. // suspended tasks (without one) stay parked until a human
  130. // or the goal acts.
  131. foreach (var run in yield tasks.list_tasks(STATUS_SUSPENDED, null)) {
  132. if (run.kind == TASK_KIND_GOAL_RUN && (yield goals.resume_due(run))) {
  133. var parked = yield parked_offerable(run, true);
  134. if (parked != null) {
  135. rows.add((!)parked);
  136. }
  137. }
  138. }
  139. foreach (var task in yield tasks.get_ready_tasks()) {
  140. rows.add(new OfferableTask() {
  141. task = task,
  142. affine_saddle_id = 0,
  143. parked = false,
  144. suspended_resume = false
  145. });
  146. }
  147. return rows.to_immutable_buffer();
  148. }
  149. /**
  150. * The offerable tasks `saddle` may be allocated: affinity
  151. * candidates first (parked on this saddle, priority order),
  152. * then fresh queued tasks whose required capabilities are
  153. * covered (ready order — priority, then creation).
  154. */
  155. private async ImmutableLot<OfferableTask> candidates_for(Saddle saddle) throws Error {
  156. var rows = new Vector<OfferableTask>();
  157. foreach (var offerable in yield offerable_tasks()) {
  158. if (offerable.parked) {
  159. if (offerable.affine_saddle_id == saddle.id) {
  160. rows.add(offerable);
  161. }
  162. } else if (yield capabilities.saddle_covers_task(saddle.id,
  163. offerable.task.id)) {
  164. rows.add(offerable);
  165. }
  166. }
  167. return rows.to_immutable_buffer();
  168. }
  169. /**
  170. * A parked offerable bound to its session's saddle, or null when
  171. * the task has no session record — nothing to resume.
  172. */
  173. private async OfferableTask? parked_offerable(TaskEntity task, bool suspended_resume)
  174. throws Error {
  175. var session = yield tasks.newest_session_for_task(task.id);
  176. if (session == null) {
  177. return null;
  178. }
  179. return new OfferableTask() {
  180. task = task,
  181. affine_saddle_id = ((!)session).saddle_id,
  182. parked = true,
  183. suspended_resume = suspended_resume
  184. };
  185. }
  186. /**
  187. * The single-transaction claim. `BEGIN IMMEDIATE` takes the
  188. * SQLite write lock up front; the section is fully synchronous
  189. * (no awaits), so no other ask, action or tick can interleave
  190. * between the re-validation reads and the claim writes —
  191. * concurrent asks race to exactly one winner. Returns null when
  192. * the candidate was claimed by a racing ask or its saddle
  193. * became unavailable (the caller falls through to the next
  194. * candidate).
  195. */
  196. private AllocationClaim? claim_candidate(Saddle saddle, OfferableTask candidate) throws Error {
  197. connection.execute("BEGIN IMMEDIATE");
  198. try {
  199. var task = db.query<TaskEntity>()
  200. .where(expr("id == $0", elem<int64?>(candidate.task.id)))
  201. .first();
  202. if (task == null || !still_claimable((!)task, candidate)) {
  203. connection.execute("ROLLBACK");
  204. return null;
  205. }
  206. var row = db.query<Saddle>()
  207. .where(expr("id == $0", elem<int64?>(saddle.id)))
  208. .first();
  209. if (row == null || ((!)row).status == SADDLE_OFFLINE) {
  210. // An offline row never wins a claim (the probe
  211. // stamp outranks the candidate list); everything
  212. // else about availability is DERIVED from the
  213. // sessions (plan 16 item 23): the saddle is free
  214. // iff it holds no running session — an open
  215. // session on an assigned/working task. The
  216. // transaction makes the check atomic against
  217. // every other ask: two asks racing for one saddle
  218. // resolve to exactly one winner, and the parked
  219. // resume needs no special case anymore (its own
  220. // session closed 'parked' at the park — D4).
  221. connection.execute("ROLLBACK");
  222. return null;
  223. }
  224. foreach (var open in db.query<Session>()
  225. .where(expr("saddle_id == $0", elem<int64?>(saddle.id)))
  226. .order_by(expr("id"))
  227. .materialise()) {
  228. if (open.ended_at != null) {
  229. continue;
  230. }
  231. var holder = db.query<TaskEntity>()
  232. .where(expr("id == $0", elem<int64?>(open.task_id)))
  233. .first();
  234. if (holder != null && (((!)holder).status == STATUS_ASSIGNED
  235. || ((!)holder).status == STATUS_WORKING)) {
  236. connection.execute("ROLLBACK");
  237. return null;
  238. }
  239. }
  240. int64 session_id = 0;
  241. if (candidate.parked) {
  242. var session = db.query<Session>()
  243. .where(expr("task_id == $0 && saddle_id == $1",
  244. elem<int64?>(candidate.task.id), elem<int64?>(saddle.id)))
  245. .order_by(expr("id"))
  246. .materialise();
  247. Session? newest = null;
  248. foreach (var prior in session) {
  249. newest = prior;
  250. }
  251. if (newest == null) {
  252. // The parked workspace's session record is gone
  253. // (a concurrent teardown); nothing to resume
  254. // onto.
  255. connection.execute("ROLLBACK");
  256. return null;
  257. }
  258. if (((!)newest).ended_at == null) {
  259. // Plan 16 D4: a straggler open row (a raise
  260. // racing this claim) closes 'parked' like
  261. // every park — the record never dangles.
  262. ((!)newest).ended_at = new DateTime.now_local();
  263. ((!)newest).outcome = OUTCOME_PARKED;
  264. db.update<Session>((!)newest);
  265. }
  266. // Plan 11 + D4: the resuming assignment carries a
  267. // FRESH session id — the parked row closed with
  268. // outcome 'parked' at its park (the park record)
  269. // and the daemon keys every telemetry push of the
  270. // resumed round on the new row.
  271. var resumed = new Session() {
  272. task_id = candidate.task.id,
  273. saddle_id = saddle.id,
  274. started_at = new DateTime.now_local()
  275. };
  276. db.insert<Session>(resumed);
  277. session_id = resumed.id;
  278. } else {
  279. var session = new Session() {
  280. task_id = candidate.task.id,
  281. saddle_id = saddle.id,
  282. started_at = new DateTime.now_local()
  283. };
  284. db.insert<Session>(session);
  285. session_id = session.id;
  286. }
  287. bool first_start = ((!)task).started_at == null;
  288. ((!)task).status = STATUS_ASSIGNED;
  289. ((!)task).started_at = new DateTime.now_local();
  290. db.update<TaskEntity>((!)task);
  291. // No saddle-row write remains (plan 16 item 23): the
  292. // open session this claim just created IS the saddle's
  293. // busy record — every reader derives it.
  294. connection.execute("COMMIT");
  295. return new AllocationClaim() {
  296. task_id = candidate.task.id,
  297. session_id = session_id,
  298. parked = candidate.parked,
  299. suspended_resume = candidate.suspended_resume,
  300. first_start = first_start
  301. };
  302. } catch (Error e) {
  303. try {
  304. connection.execute("ROLLBACK");
  305. } catch (Error rollback_error) {
  306. warning("[ClankerSaddle] Allocation rollback failed: %s",
  307. rollback_error.message);
  308. }
  309. throw e;
  310. }
  311. }
  312. /**
  313. * The in-transaction re-validation: the task must still be in
  314. * its candidate state (a racing ask that claimed it flipped the
  315. * status), and a fresh candidate's dependencies must still be
  316. * met.
  317. */
  318. private bool still_claimable(TaskEntity task, OfferableTask candidate) throws Error {
  319. if (candidate.parked) {
  320. return task.status == STATUS_BLOCKED
  321. || task.status == STATUS_REVIEW_REQUESTED
  322. || (candidate.suspended_resume && task.status == STATUS_SUSPENDED);
  323. }
  324. if (task.status != STATUS_QUEUED) {
  325. return false;
  326. }
  327. if (task.enqueue_after_task_id > 0) {
  328. // A queued dependent whose predecessor is not done yet
  329. // (a predecessor set after a manual enqueue) waits.
  330. var predecessor = db.query<TaskEntity>()
  331. .where(expr("id == $0", elem<int64?>(task.enqueue_after_task_id)))
  332. .first();
  333. if (predecessor == null || ((!)predecessor).status != STATUS_DONE) {
  334. return false;
  335. }
  336. }
  337. return true;
  338. }
  339. /**
  340. * Assembles the claimed task's assignment payload (today's
  341. * /start shape): pending block answers ride the task object,
  342. * and every parked resume carries a `resume_notice` — a
  343. * goal agent's the coalesced batched report, an answered
  344. * block's the verdict text in the stable wording ({@link
  345. * TaskService.verdict_notice_for}) — plus the
  346. * `branch_advanced` verdict. The hand-over itself starts the
  347. * session's push-liveness clock (the daemon cannot push before
  348. * it owns the assignment), so the staleness probe's window
  349. * opens here.
  350. */
  351. private async Json.Object assemble_assignment(SaddleService saddles,
  352. ProjectService projects, Saddle saddle, AllocationClaim claim) throws Error {
  353. var detail = yield tasks.get_task(claim.task_id);
  354. if (detail == null) {
  355. throw new Error(Quark.from_string("clanker-saddle"), 48,
  356. @"Task $(claim.task_id) vanished after its allocation claim");
  357. }
  358. var task = ((!)detail).task;
  359. string? resume_notice = null;
  360. bool? branch_advanced = null;
  361. string? context_update = null;
  362. if (claim.parked) {
  363. branch_advanced = yield tasks.branch_advanced_since(task.id);
  364. // The instruction-context delta since the run's last
  365. // round (plan approval, rules, notes) rides the
  366. // resume as a turn — null when nothing changed.
  367. context_update = yield context_update_for(task, claim.session_id, projects);
  368. if (claim.suspended_resume) {
  369. // Plan 12: the goal agent's batched report — every
  370. // plan task's status plus the completion reports
  371. // that landed while it was parked. Consumes the
  372. // pending goal events (the coalescing watermark)
  373. // and ends the parking window.
  374. resume_notice = yield goals.take_resume_notice(task.goal_id);
  375. yield tasks.clear_suspension(task.id);
  376. } else {
  377. // Answering = allocating (plan 11): the verdict rides
  378. // the resuming assignment's resume_notice, injected as
  379. // the restored conversation's opening turn. Plan 15
  380. // C1: the goal-run task's block IS the goal's block
  381. // — its own answer is the whole verdict, no mirror
  382. // sweep exists.
  383. var newest = yield tasks.newest_block(task.id);
  384. if (newest != null && ((!)newest).answer != null) {
  385. resume_notice = TaskService.verdict_notice_for((!)newest);
  386. }
  387. }
  388. // Plan 14 B4: persist what the resumed agent first
  389. // received on the session row — the terminal page's
  390. // resume-info panel reads it. Fresh sessions keep a
  391. // null notice.
  392. if (resume_notice != null && resume_notice.length > 0) {
  393. var resumed_row = yield db.query<Session>()
  394. .where(expr("id == $0", elem<int64?>(claim.session_id)))
  395. .first_async();
  396. if (resumed_row != null) {
  397. ((!)resumed_row).resume_notice = resume_notice;
  398. yield db.update_async<Session>((!)resumed_row);
  399. }
  400. }
  401. }
  402. poll_state.note_push(claim.session_id);
  403. var payload = yield saddles.build_assignment_payload(saddle, task,
  404. claim.session_id, projects, resume_notice, branch_advanced,
  405. context_update);
  406. payload.set_int_member("session_id", claim.session_id);
  407. yield saddles.mark_task_comments_delivered(task.id);
  408. yield pulse.bump(task.id, "task dispatched");
  409. // Web Push (plan 11 task E): the FIRST dispatch of a task
  410. // notifies its opted-in users — fired from the same place
  411. // the dispatch pulse bumps (single funnel).
  412. if (claim.first_start) {
  413. push.notify_opted_in(task.id, PushService.lifecycle_notification(
  414. task.title, task.id, "started", null));
  415. }
  416. print(@"[ClankerSaddle] Allocated task $(task.id) to saddle '$(saddle.name)' on ask (session $(claim.session_id))\n");
  417. return payload;
  418. }
  419. // ------------------------------------------------------------------
  420. // Resume-time context delta (`context_update`)
  421. // ------------------------------------------------------------------
  422. /** Clip lengths keeping the delta a SHORT markdown summary. */
  423. private const int CONTEXT_PLAN_CLIP = 4000;
  424. private const int CONTEXT_RULE_CLIP = 800;
  425. private const int CONTEXT_NOTE_CLIP = 800;
  426. /**
  427. * The instruction-context delta since the run's last round,
  428. * as the resuming assignment's `context_update` markdown — or
  429. * null when nothing changed. The watermark is the previous
  430. * session's last recorded activity (its newest entry's stamp,
  431. * else its start): anything the effective instruction set did
  432. * after that lands in the summary. Covered, simplest-correct:
  433. *
  434. * - the goal's plan approved since the run's last round (the
  435. * approved plan rides along whole — it is the operative
  436. * document the agent must now follow);
  437. * - rules in the effective set that were created or edited,
  438. * or whose admitting attachment (to the task or an involved
  439. * project) was created, after the watermark;
  440. * - project notes of the primary + co-developed projects
  441. * created after the watermark.
  442. *
  443. * Detaching a rule or deleting a note is NOT detected (the
  444. * rows carry no such timestamps) — the daemon still receives
  445. * the current effective `rules`/`project_notes` arrays in the
  446. * same payload for anything that rebuilds them wholesale.
  447. */
  448. private async string? context_update_for(TaskEntity task, int64 session_id,
  449. ProjectService projects) throws Error {
  450. // The previous round: the newest session before this
  451. // claim's fresh one; its last activity is the watermark.
  452. DateTime? watermark = null;
  453. int64 previous_session = 0;
  454. foreach (var session in yield db.query<Session>()
  455. .where(expr("task_id == $0", elem<int64?>(task.id)))
  456. .order_by(expr("id"))
  457. .materialise_async()) {
  458. if (session.id < session_id && session.started_at != null) {
  459. previous_session = session.id;
  460. watermark = session.started_at;
  461. }
  462. }
  463. if (previous_session == 0 || watermark == null) {
  464. return null;
  465. }
  466. foreach (var command in yield db.query<SessionCommand>()
  467. .where(expr("session_id == $0", elem<int64?>(previous_session)))
  468. .order_by(expr("id"))
  469. .materialise_async()) {
  470. DateTime? at = command.completed_at ?? command.started_at;
  471. if (at != null && ((!)at).compare((!)watermark) > 0) {
  472. watermark = at;
  473. }
  474. }
  475. var report = new StringBuilder();
  476. // Plan approval (goal work only): an approved plan BLOCK
  477. // (plan 15 C3 — the answered run-task row IS the record)
  478. // after the watermark names the round that missed it.
  479. if (task.goal_id > 0) {
  480. bool approved = false;
  481. var approval_run = yield db.query<TaskEntity>()
  482. .where(expr("goal_id == $0 && kind == $1",
  483. elem<int64?>(task.goal_id), elem(TASK_KIND_GOAL_RUN)))
  484. .first_async();
  485. if (approval_run != null) {
  486. foreach (var block in yield tasks.list_blocks(
  487. ((!)approval_run).id)) {
  488. if (block.block_type == BLOCK_PLAN
  489. && block.answer == NOTICE_PLAN_APPROVED
  490. && block.answered_at != null
  491. && ((!)block.answered_at).compare((!)watermark) > 0) {
  492. approved = true;
  493. }
  494. }
  495. }
  496. if (approved) {
  497. string plan = "";
  498. foreach (var goal in yield db.query<Goal>()
  499. .where(expr("id == $0", elem<int64?>(task.goal_id)))
  500. .materialise_async()) {
  501. plan = goal.plan ?? "";
  502. }
  503. if (plan.length > 0) {
  504. report.append("\n## Plan approved\n\n");
  505. report.append("The goal's plan was approved since your last round.");
  506. report.append(" The approved plan now reads:\n\n");
  507. report.append(clip_text(plan, CONTEXT_PLAN_CLIP));
  508. report.append("\n");
  509. }
  510. }
  511. }
  512. // Rules: anything effective whose row was created/edited,
  513. // or whose admitting attachment was created, after the
  514. // watermark.
  515. var involved = yield co_developed_project_ids(task, projects);
  516. var changed_rules = new Vector<Rule>();
  517. foreach (var rule in yield rules.effective_rules_for(task, projects,
  518. task.goal_id)) {
  519. DateTime? rule_stamp = rule.updated_at ?? rule.created_at;
  520. if (rule_stamp != null && ((!)rule_stamp).compare((!)watermark) > 0) {
  521. changed_rules.add(rule);
  522. continue;
  523. }
  524. if (yield rule_attached_since(rule.id, task.id, involved, (!)watermark)) {
  525. changed_rules.add(rule);
  526. }
  527. }
  528. if (changed_rules.length > 0) {
  529. report.append("\n## Rules changed\n\n");
  530. report.append("New or updated rules now apply to your work:\n\n");
  531. foreach (var rule in changed_rules) {
  532. report.append_printf("- **%s**: %s\n", rule.name,
  533. clip_text(rule.body, CONTEXT_RULE_CLIP));
  534. }
  535. }
  536. // Project notes of the primary + co-developed projects.
  537. var changed_notes = new Vector<ProjectNote>();
  538. var note_project = new Dictionary<string, string>();
  539. foreach (var project_id in involved) {
  540. var project = yield db.query<Project>()
  541. .where(expr("id == $0", elem<int64?>(int64.parse(project_id))))
  542. .first_async();
  543. if (project == null) {
  544. continue;
  545. }
  546. note_project.set(project_id, ((!)project).name);
  547. foreach (var note in yield projects.list_notes(int64.parse(project_id))) {
  548. if (note.created_at != null
  549. && ((!)note.created_at).compare((!)watermark) > 0) {
  550. changed_notes.add(note);
  551. }
  552. }
  553. }
  554. if (changed_notes.length > 0) {
  555. report.append("\n## New project notes\n\n");
  556. foreach (var note in changed_notes) {
  557. report.append_printf("- **%s**: %s\n",
  558. note_project.get_or_default(note.project_id.to_string()) ?? "",
  559. clip_text(note.body, CONTEXT_NOTE_CLIP));
  560. }
  561. }
  562. if (report.len == 0) {
  563. return null;
  564. }
  565. var summary = new StringBuilder();
  566. summary.append("Context changes since your last round (your instructions changed while you were away):\n");
  567. summary.append(report.str);
  568. return summary.str;
  569. }
  570. /**
  571. * The primary + co-developed project ids (as id strings — the
  572. * container-safe element type) of `task`'s effective context:
  573. * through the GOAL's links for goal work, the project's links
  574. * otherwise (the note/rule scopes of the assignment payload).
  575. */
  576. private async Vector<string> co_developed_project_ids(TaskEntity task,
  577. ProjectService projects) throws Error {
  578. var ids = new Vector<string>();
  579. ids.add(task.project_id.to_string());
  580. if (task.goal_id > 0) {
  581. foreach (var link in yield goals.list_links(task.goal_id)) {
  582. if (link.relationship == LINK_CO_DEVELOPED) {
  583. ids.add(link.project_id.to_string());
  584. }
  585. }
  586. } else {
  587. foreach (var link in yield projects.list_links(task.project_id)) {
  588. if (link.relationship == LINK_CO_DEVELOPED) {
  589. ids.add(link.linked_project_id.to_string());
  590. }
  591. }
  592. }
  593. return ids;
  594. }
  595. /**
  596. * True when one of the rule's admitting attachments — a
  597. * `task_rules` row for this task, or a `rule_projects` row
  598. * for an involved project — was created after `watermark`.
  599. */
  600. private async bool rule_attached_since(int64 rule_id, int64 task_id,
  601. Vector<string> involved_projects, DateTime watermark) throws Error {
  602. foreach (var link in yield db.query<TaskRule>()
  603. .where(expr("rule_id == $0 && task_id == $1",
  604. elem<int64?>(rule_id), elem<int64?>(task_id)))
  605. .materialise_async()) {
  606. if (link.created_at != null
  607. && ((!)link.created_at).compare(watermark) > 0) {
  608. return true;
  609. }
  610. }
  611. foreach (var project_id in involved_projects) {
  612. foreach (var link in yield db.query<RuleProject>()
  613. .where(expr("rule_id == $0 && project_id == $1",
  614. elem<int64?>(rule_id), elem<int64?>(int64.parse(project_id))))
  615. .materialise_async()) {
  616. if (link.created_at != null
  617. && ((!)link.created_at).compare(watermark) > 0) {
  618. return true;
  619. }
  620. }
  621. }
  622. return false;
  623. }
  624. /** A body clipped to `limit` chars (ellipsis when it cut). */
  625. private static string clip_text(string body, int limit) {
  626. return body.length > limit ? body.substring(0, limit) + "…" : body;
  627. }
  628. }
  629. }