GoalService.vala 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
  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 goal statuses (the `goals.status` values). */
  10. public const string GOAL_DRAFT = "draft";
  11. public const string GOAL_PLANNING = "planning";
  12. public const string GOAL_PLAN_REVIEW = "plan_review";
  13. public const string GOAL_ACTIVE = "active";
  14. public const string GOAL_PAUSED = "paused";
  15. public const string GOAL_COMPLETION_REVIEW = "completion_review";
  16. public const string GOAL_COMPLETE = "complete";
  17. public const string GOAL_FAILED = "failed";
  18. public const string GOAL_CANCELLED = "cancelled";
  19. /**
  20. * Valid goal block types (plan 15 C1: the values of the goal-run
  21. * task's `task_blocks.type` rows — question, rfc, plan and the
  22. * completion review).
  23. */
  24. public const string GOAL_BLOCK_QUESTION = "question";
  25. public const string GOAL_BLOCK_RFC = "rfc";
  26. public const string GOAL_BLOCK_PLAN = "plan";
  27. public const string GOAL_BLOCK_COMPLETION = "completion";
  28. /** Task kinds (the `tasks.kind` wire values). */
  29. public const string TASK_KIND_TASK = "task";
  30. public const string TASK_KIND_GOAL_RUN = "goal_run";
  31. /** Task creators: enqueueing is creator-only. */
  32. public const string CREATOR_HUMAN = "human";
  33. public const string CREATOR_GOAL = "goal";
  34. /**
  35. * The goal-run internal task's mode: the goal agent orchestrates
  36. * (prompt-level mode shaping is the daemon's job).
  37. */
  38. public const string MODE_ORCHESTRATE = "orchestrate";
  39. /** Valid work modes for human/goal-created tasks. */
  40. public const string[] TASK_MODES = { "implement", "investigate", "review", "test" };
  41. /** The priority every goal-run internal task dispatches at. */
  42. public const int64 GOAL_RUN_PRIORITY = 3;
  43. /**
  44. * Goal storage and lifecycle (plan 12; blocks consolidated plan
  45. * 15 C1): creation with branch-name uniqueness validation, the
  46. * draft → planning → plan review → active → completion review →
  47. * complete/failed/cancelled flow (with paused freezing
  48. * supervision in between), the goal agent's run task, plan/
  49. * completion review flows with revisions, goal comments, the
  50. * cancel cascade and the event-driven resume machinery (task-
  51. * terminal events coalesce ~10 s into one goal-agent resume
  52. * carrying the batched status report). The goal agent's BLOCKS
  53. * ride the run task's own `task_blocks` rows — one row per
  54. * question, answered in place through {@link answer_block}.
  55. */
  56. public class GoalService : Object {
  57. private OrmSession db = inject<OrmSession>();
  58. private StatePulse pulse = inject<StatePulse>();
  59. private PushService push = inject<PushService>();
  60. private TaskService tasks = inject<TaskService>();
  61. /**
  62. * SADDLE_GOAL_RESUME_S (default 10): the coalescing window —
  63. * a parked goal agent resumes once the OLDEST unconsumed task
  64. * event is at least this old, so bursts collapse into one
  65. * resume.
  66. */
  67. public static uint goal_resume_coalesce_seconds() {
  68. var raw = Environment.get_variable("SADDLE_GOAL_RESUME_S") ?? "";
  69. int64 parsed = int64.parse(raw);
  70. return parsed > 0 ? (uint) parsed : 10;
  71. }
  72. // ------------------------------------------------------------------
  73. // Creation and links
  74. // ------------------------------------------------------------------
  75. /**
  76. * Creates a goal in draft. The branch name is required and
  77. * uniqueness-validated: no other live (non-cancelled,
  78. * non-complete, non-failed) goal may use the same branch name
  79. * while sharing any involved project (primary or link) — one
  80. * branch of that name per involved repo. `base_branch` defaults
  81. * to the project's default branch.
  82. */
  83. public async Goal create_goal(int64 project_id, string title, string brief,
  84. string branch_name, string? base_branch, int64 priority,
  85. string creator = CREATOR_HUMAN) throws Error {
  86. var project = yield db.query<Project>()
  87. .where(expr("id == $0", elem<int64?>(project_id)))
  88. .first_async();
  89. if (project == null) {
  90. throw new Error(Quark.from_string("clanker-saddle"), 60,
  91. @"No project exists with id $(project_id)");
  92. }
  93. string trimmed_branch = branch_name.strip();
  94. if (title.strip().length == 0) {
  95. throw new Error(Quark.from_string("clanker-saddle"), 61,
  96. "A goal title is required");
  97. }
  98. if (brief.strip().length == 0) {
  99. throw new Error(Quark.from_string("clanker-saddle"), 62,
  100. "An initial brief is required");
  101. }
  102. if (trimmed_branch.length == 0) {
  103. throw new Error(Quark.from_string("clanker-saddle"), 63,
  104. "A goal branch name is required");
  105. }
  106. foreach (var other in yield db.query<Goal>().materialise_async()) {
  107. if (other.branch_name != trimmed_branch
  108. || other.status == GOAL_CANCELLED
  109. || other.status == GOAL_COMPLETE
  110. || other.status == GOAL_FAILED) {
  111. continue;
  112. }
  113. if ((yield involved_project_ids(other)).contains(project_id.to_string())) {
  114. throw new Error(Quark.from_string("clanker-saddle"), 64,
  115. @"Branch name '$(trimmed_branch)' is already used by a live goal of an involved project (#$(other.id) $(other.title)) — pick another name");
  116. }
  117. }
  118. var goal = new Goal() {
  119. project_id = project_id,
  120. title = title.strip(),
  121. brief = brief,
  122. branch_name = trimmed_branch,
  123. base_branch = base_branch != null && base_branch.strip().length > 0
  124. ? base_branch.strip() : ((!)project).default_branch,
  125. priority = priority.clamp(1, 9),
  126. creator = creator,
  127. created_at = new DateTime.now_local()
  128. };
  129. yield db.insert_async<Goal>(goal);
  130. yield record_event(goal.id, "created", @"Goal created (branch $(trimmed_branch))");
  131. yield pulse.bump(0, "goal created");
  132. return goal;
  133. }
  134. /** Loads the goal identified by `id`, or null. */
  135. public async Goal? get_goal(int64 id) throws Error {
  136. return yield db.query<Goal>()
  137. .where(expr("id == $0", elem<int64?>(id)))
  138. .first_async();
  139. }
  140. /** Every goal, newest first. */
  141. public async ImmutableLot<Goal> list_goals() throws Error {
  142. return yield db.query<Goal>()
  143. .order_by_desc(expr("id"))
  144. .materialise_async();
  145. }
  146. /**
  147. * The primary + linked project ids of `goal` (stringified —
  148. * the Invercargill collections cannot hash value types).
  149. */
  150. public async Invercargill.DataStructures.HashSet<string> involved_project_ids(Goal goal) throws Error {
  151. var ids = new Invercargill.DataStructures.HashSet<string>();
  152. ids.add(goal.project_id.to_string());
  153. foreach (var link in yield list_links(goal.id)) {
  154. ids.add(link.project_id.to_string());
  155. }
  156. return ids;
  157. }
  158. /** Adds a project link to the goal; duplicates are refused. */
  159. public async void add_link(int64 goal_id, int64 project_id,
  160. string relationship) throws Error {
  161. if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) {
  162. throw new Error(Quark.from_string("clanker-saddle"), 65,
  163. @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'");
  164. }
  165. var goal = yield get_goal(goal_id);
  166. if (goal == null) {
  167. throw new Error(Quark.from_string("clanker-saddle"), 66,
  168. @"No goal exists with id $(goal_id)");
  169. }
  170. if (project_id == ((!)goal).project_id) {
  171. throw new Error(Quark.from_string("clanker-saddle"), 67,
  172. "The primary project is implicitly linked");
  173. }
  174. var existing = yield db.query<GoalLink>()
  175. .where(expr("goal_id == $0 && project_id == $1",
  176. elem<int64?>(goal_id), elem<int64?>(project_id)))
  177. .materialise_async();
  178. if (existing.length > 0) {
  179. throw new Error(Quark.from_string("clanker-saddle"), 68,
  180. "That project is already linked to the goal");
  181. }
  182. yield db.insert_async<GoalLink>(new GoalLink() {
  183. goal_id = goal_id,
  184. project_id = project_id,
  185. relationship = relationship
  186. });
  187. yield pulse.bump(0, "goal link added");
  188. }
  189. /** Removes the goal link identified by `id`. */
  190. public async void remove_link(int64 link_id) throws Error {
  191. var link = yield db.query<GoalLink>()
  192. .where(expr("id == $0", elem<int64?>(link_id)))
  193. .first_async();
  194. if (link != null) {
  195. yield db.delete_async<GoalLink>((!)link);
  196. yield pulse.bump(0, "goal link removed");
  197. }
  198. }
  199. /** The goal's links, oldest first. */
  200. public async ImmutableLot<GoalLink> list_links(int64 goal_id) throws Error {
  201. return yield db.query<GoalLink>()
  202. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  203. .order_by(expr("id"))
  204. .materialise_async();
  205. }
  206. // ------------------------------------------------------------------
  207. // Lifecycle
  208. // ------------------------------------------------------------------
  209. /**
  210. * The goal's goal-run internal task (kind 'goal_run'), or null
  211. * before the goal starts.
  212. */
  213. public async TaskEntity? goal_run_task(int64 goal_id) throws Error {
  214. var rows = yield db.query<TaskEntity>()
  215. .where(expr("goal_id == $0 && kind == $1",
  216. elem<int64?>(goal_id), elem(TASK_KIND_GOAL_RUN)))
  217. .order_by(expr("id"))
  218. .materialise_async();
  219. TaskEntity? first = null;
  220. foreach (var row in rows) {
  221. first = row;
  222. break;
  223. }
  224. return first;
  225. }
  226. /**
  227. * Starts a draft goal: the brief locks, the goal moves to
  228. * 'planning' and the goal agent's internal run task is created
  229. * (kind 'goal_run', mode 'orchestrate', priority 3, creator
  230. * 'goal') and queued — the dispatcher hands it to a saddle and
  231. * the agent begins investigating.
  232. */
  233. public async Goal start_goal(int64 goal_id) throws Error {
  234. var goal = yield require_goal(goal_id);
  235. if (((!)goal).status != GOAL_DRAFT) {
  236. throw new Error(Quark.from_string("clanker-saddle"), 69,
  237. "Only draft goals can be started");
  238. }
  239. ((!)goal).status = GOAL_PLANNING;
  240. ((!)goal).started_at = new DateTime.now_local();
  241. yield db.update_async<Goal>((!)goal);
  242. var run = yield tasks.create_task(((!)goal).project_id,
  243. @"Goal agent — $(((!)goal).title)",
  244. ((!)goal).brief, GOAL_RUN_PRIORITY,
  245. null, ((!)goal).base_branch, STATUS_QUEUED,
  246. TASK_KIND_GOAL_RUN, MODE_ORCHESTRATE, CREATOR_GOAL,
  247. ((!)goal).id, 0);
  248. yield record_event(goal_id, "started",
  249. @"Goal started — the goal agent is planning (run task #$(run.id))", run.id);
  250. yield pulse.bump(run.id, "goal started");
  251. return goal;
  252. }
  253. /**
  254. * Pauses an active goal: supervision freezes — no goal-agent
  255. * resumes are scheduled while paused; task events keep
  256. * queueing and coalesce into the flush on resume.
  257. */
  258. public async Goal pause_goal(int64 goal_id) throws Error {
  259. var goal = yield require_goal(goal_id);
  260. if (((!)goal).status != GOAL_ACTIVE) {
  261. throw new Error(Quark.from_string("clanker-saddle"), 70,
  262. "Only active goals can be paused");
  263. }
  264. ((!)goal).status = GOAL_PAUSED;
  265. ((!)goal).paused_at = new DateTime.now_local();
  266. yield db.update_async<Goal>((!)goal);
  267. yield record_event(goal_id, "paused", "Goal paused — supervision frozen (events queue)");
  268. yield pulse.bump(0, "goal paused");
  269. return goal;
  270. }
  271. /**
  272. * Resumes a paused goal: supervision restarts and any events
  273. * that queued while paused coalesce into the next resume (the
  274. * oldest pending event usually already satisfies the window).
  275. */
  276. public async Goal resume_goal(int64 goal_id) throws Error {
  277. var goal = yield require_goal(goal_id);
  278. if (((!)goal).status != GOAL_PAUSED) {
  279. throw new Error(Quark.from_string("clanker-saddle"), 71,
  280. "Only paused goals can be resumed");
  281. }
  282. ((!)goal).status = GOAL_ACTIVE;
  283. ((!)goal).paused_at = null;
  284. yield db.update_async<Goal>((!)goal);
  285. yield record_event(goal_id, "resumed", "Goal resumed — queued events coalesce into the next agent wake");
  286. yield pulse.bump(0, "goal resumed");
  287. return goal;
  288. }
  289. /**
  290. * Cancels a goal: the cascade closes every one of its tasks —
  291. * non-terminal ones are cancelled (the running goal agent
  292. * included), draft ones deleted (they never ran). Terminal
  293. * goals refuse the action. Live daemons are NOT aborted here —
  294. * the cancel action (which can reach SaddleService without a
  295. * dependency cycle) aborts them first.
  296. */
  297. public async void cancel_goal(int64 goal_id) throws Error {
  298. var goal = yield require_goal(goal_id);
  299. string status = ((!)goal).status;
  300. if (status == GOAL_COMPLETE || status == GOAL_FAILED
  301. || status == GOAL_CANCELLED) {
  302. throw new Error(Quark.from_string("clanker-saddle"), 72,
  303. "The goal is already finished");
  304. }
  305. ((!)goal).status = GOAL_CANCELLED;
  306. ((!)goal).completed_at = new DateTime.now_local();
  307. yield db.update_async<Goal>((!)goal);
  308. foreach (var task in yield db.query<TaskEntity>()
  309. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  310. .order_by(expr("id"))
  311. .materialise_async()) {
  312. if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
  313. continue;
  314. }
  315. if (task.status == STATUS_DRAFT) {
  316. yield tasks.delete_task_quietly(task.id);
  317. } else {
  318. yield tasks.cancel_task(task.id);
  319. }
  320. }
  321. yield record_event(goal_id, "cancelled", "Goal cancelled — its tasks were cancelled with it");
  322. yield pulse.bump(0, "goal cancelled");
  323. }
  324. // ------------------------------------------------------------------
  325. // The goal agent's pushes: plan, completion, failure
  326. // ------------------------------------------------------------------
  327. /**
  328. * The goal agent submitted (or revised) its plan: ONE
  329. * review-kind block of type 'plan' on the goal-run task
  330. * carries the markdown, the goal moves to 'plan_review' and
  331. * the run parks on it (the verdict resume rides the answered
  332. * block). The caller has already parked the task via the
  333. * results state. Plan 15 C1: the block lives on the run task
  334. * — there is no second row to keep in sync.
  335. */
  336. public async TaskBlock? submit_plan(int64 goal_id, string markdown) throws Error {
  337. var goal = yield require_goal(goal_id);
  338. // Idempotent on the pending submission: a redelivered push
  339. // (outbox retry, the entry + state pair of one `saddle
  340. // plan`) must not stack duplicate revisions — a NEW row
  341. // only appears once the previous one was answered
  342. // (rejected → the agent revises).
  343. TaskBlock? raised = yield raise_run_block(((!)goal), "Plan submitted for review",
  344. GOAL_BLOCK_PLAN, null, markdown, true);
  345. if (raised == null || ((!)raised).answer != null) {
  346. // No pending submission: a fresh revision supersedes
  347. // the answered history.
  348. raised = yield raise_run_block(((!)goal), "Plan submitted for review",
  349. GOAL_BLOCK_PLAN, null, markdown, false);
  350. }
  351. if (raised == null) {
  352. return null;
  353. }
  354. ((!)goal).status = GOAL_PLAN_REVIEW;
  355. yield db.update_async<Goal>(((!)goal));
  356. yield record_event(goal_id, "plan-submitted",
  357. @"Plan revision #$(((!)raised).id) submitted for review");
  358. return raised;
  359. }
  360. /**
  361. * Records a goal-agent question/RFC as ONE block on the
  362. * goal-run task and parks it there. Idempotent on the pending
  363. * question: a redelivered blocked push (outbox retry, the
  364. * park-unwinding round-end push) must not stack duplicates.
  365. */
  366. public async TaskBlock? raise_agent_block(int64 goal_id, string question,
  367. string block_type, string[]? options = null, string? body = null) throws Error {
  368. var goal = yield require_goal(goal_id);
  369. TaskBlock? raised = yield raise_run_block(((!)goal), question,
  370. block_type.length > 0 ? block_type : GOAL_BLOCK_QUESTION,
  371. options, body, false);
  372. if (raised == null) {
  373. return null;
  374. }
  375. yield record_event(goal_id, "block-raised",
  376. @"$(((!)raised).block_type) raised: $(question)");
  377. return raised;
  378. }
  379. /**
  380. * The goal agent submitted its completion report (`saddle
  381. * done` in goal context): the goal moves to
  382. * 'completion_review', the report is stored (goal row + ONE
  383. * completion-kind block on the goal-run task) and the run
  384. * parks on it. The daemon enforces the branch-push rule
  385. * before letting `done` through. Idempotent on terminal goals
  386. * (plan 13 B2): a redelivered done push must not resurrect a
  387. * finished goal.
  388. */
  389. public async TaskBlock? submit_completion(int64 goal_id, string report) throws Error {
  390. var goal = yield require_goal(goal_id);
  391. if (((!)goal).status == GOAL_COMPLETE || ((!)goal).status == GOAL_FAILED
  392. || ((!)goal).status == GOAL_CANCELLED) {
  393. // Terminal stands: the pending-report idempotency below
  394. // cannot apply (the verdict already landed).
  395. throw new Error(Quark.from_string("clanker-saddle"), 85,
  396. @"The goal is already $(((!)goal).status) — its completion review is closed");
  397. }
  398. // Idempotent on the pending report (see submit_plan).
  399. TaskBlock? raised = yield raise_run_block(((!)goal), "Goal completion report",
  400. GOAL_BLOCK_COMPLETION, null, report, true);
  401. if (raised == null) {
  402. return null;
  403. }
  404. ((!)goal).status = GOAL_COMPLETION_REVIEW;
  405. ((!)goal).completion_report = report;
  406. yield db.update_async<Goal>(((!)goal));
  407. yield record_event(goal_id, "completion-submitted",
  408. "Completion report submitted for review");
  409. return raised;
  410. }
  411. /**
  412. * Fails the goal (`saddle failure` in goal context): status
  413. * 'failed', the report stored, the goal-run task failed (its
  414. * session closes and saddle frees), every non-terminal task
  415. * cascades exactly like a cancellation (plan 13 B3: drafts
  416. * are deleted — no orphan stays dispatchable — queued/working/
  417. * blocked tasks are cancelled) and the narrative records the
  418. * cascade. Every user is notified. Idempotent on terminal
  419. * goals (plan 13 B2): a replayed failure push — redelivered
  420. * OR a fresh entry id from the honest burst — re-runs no
  421. * bookkeeping, so exactly one goal-failed event, one cascade,
  422. * one notification, ever.
  423. */
  424. public async void fail_goal(int64 goal_id, string? report) throws Error {
  425. var goal = yield require_goal(goal_id);
  426. if (((!)goal).status == GOAL_COMPLETE || ((!)goal).status == GOAL_FAILED
  427. || ((!)goal).status == GOAL_CANCELLED) {
  428. return;
  429. }
  430. ((!)goal).status = GOAL_FAILED;
  431. ((!)goal).completed_at = new DateTime.now_local();
  432. ((!)goal).completion_report = report ?? ((!)goal).completion_report;
  433. yield db.update_async<Goal>(((!)goal));
  434. var run = yield goal_run_task(goal_id);
  435. if (run != null && ((!)run).status != STATUS_DONE
  436. && ((!)run).status != STATUS_FAILED) {
  437. yield tasks.fail_task(((!)run).id,
  438. report != null && report.length > 0 ? report : "The goal agent failed the goal");
  439. }
  440. // Plan 13 B3: the failure cascade — the cancel-cascade
  441. // semantics (the run task just failed above, so the loop's
  442. // terminal skip covers it).
  443. int64 cascaded = 0;
  444. foreach (var task in yield db.query<TaskEntity>()
  445. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  446. .order_by(expr("id"))
  447. .materialise_async()) {
  448. if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
  449. continue;
  450. }
  451. if (task.status == STATUS_DRAFT) {
  452. yield tasks.delete_task_quietly(task.id);
  453. } else {
  454. yield tasks.cancel_task(task.id);
  455. }
  456. cascaded++;
  457. }
  458. yield record_event(goal_id, "goal-failed",
  459. report != null && report.length > 0 ? report : "The goal agent failed the goal");
  460. if (cascaded > 0) {
  461. yield record_event(goal_id, "cascade",
  462. @"Goal failure cancelled $(cascaded) outstanding task(s)");
  463. }
  464. push.notify_global(PushService.goal_terminal_notification(
  465. ((!)goal).title, goal_id, false));
  466. yield pulse.bump(0, "goal failed");
  467. }
  468. /**
  469. * Answers a goal's block (plan 15 C1: THE single answer path
  470. * every surface calls — the goal page's verdict forms, the
  471. * task-page deep link on a goal-run block, the dashboard's
  472. * link-through destination). The block is the goal-run task's
  473. * OWN row: answering it in place settles everything an
  474. * observer can read before the first pulse fires, so the
  475. * completion-review-never-leaves-the-dashboard bug class is
  476. * structurally impossible — no dual rows exist to fall out of
  477. * sync. The parked-resume machinery then wakes the agent with
  478. * the verdict as its resume_notice. Plan and completion
  479. * blocks apply their goal-state transitions:
  480. *
  481. * - plan + {@link NOTICE_PLAN_APPROVED} → the submission
  482. * becomes goal.plan, status 'active';
  483. * - plan + feedback → status 'planning' (the agent revises);
  484. * - completion + {@link NOTICE_REVIEW_APPROVED} → the goal
  485. * completes and the goal-run task closes done;
  486. * - completion + feedback → status 'active' (the agent keeps
  487. * working).
  488. */
  489. public async void answer_block(int64 block_id, string answer,
  490. string? answered_by = null) throws Error {
  491. var block = yield db.query<TaskBlock>()
  492. .where(expr("id == $0", elem<int64?>(block_id)))
  493. .first_async();
  494. if (block == null) {
  495. throw new Error(Quark.from_string("clanker-saddle"), 73,
  496. @"No goal block exists with id $(block_id)");
  497. }
  498. if (((!)block).answer != null) {
  499. return;
  500. }
  501. var task = yield db.query<TaskEntity>()
  502. .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
  503. .first_async();
  504. if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN
  505. || ((!)task).goal_id <= 0) {
  506. // Not a goal-run block: the plain task answer path.
  507. yield tasks.answer_block(block_id, answer, answered_by);
  508. return;
  509. }
  510. var goal = yield get_goal(((!)task).goal_id);
  511. if (goal == null) {
  512. return;
  513. }
  514. // The verdict's events record FIRST, then the row settles
  515. // (below) before the first observer pulse fires — an
  516. // event-less resume is a lost context_update, and an
  517. // unanswered row past the pulse is exactly the stale-pile
  518. // shape plan 14 B3 defended against with mirror sweeps.
  519. // With one row there is nothing left to sweep.
  520. if (((!)block).block_type == GOAL_BLOCK_PLAN) {
  521. if (answer == NOTICE_PLAN_APPROVED) {
  522. if (((!)block).body != null && ((!)block).body.length > 0) {
  523. ((!)goal).plan = ((!)block).body;
  524. }
  525. ((!)goal).status = GOAL_ACTIVE;
  526. yield record_event(((!)goal).id, "plan-approved",
  527. @"Plan revision #$(((!)block).id) approved — the goal is active");
  528. } else {
  529. ((!)goal).status = GOAL_PLANNING;
  530. yield record_event(((!)goal).id, "plan-rejected",
  531. @"Plan revision #$(((!)block).id) rejected with feedback — the agent revises");
  532. }
  533. } else if (((!)block).block_type == GOAL_BLOCK_COMPLETION) {
  534. if (answer == NOTICE_REVIEW_APPROVED) {
  535. ((!)goal).status = GOAL_COMPLETE;
  536. ((!)goal).completed_at = new DateTime.now_local();
  537. yield record_event(((!)goal).id, "goal-approved",
  538. "Completion report approved — the goal is complete");
  539. } else {
  540. ((!)goal).status = GOAL_ACTIVE;
  541. yield record_event(((!)goal).id, "completion-rejected",
  542. "Completion report rejected with feedback — the agent continues");
  543. }
  544. }
  545. yield db.update_async<Goal>(((!)goal));
  546. yield tasks.answer_block(block_id, answer, answered_by);
  547. if (((!)block).block_type == GOAL_BLOCK_COMPLETION
  548. && answer == NOTICE_REVIEW_APPROVED) {
  549. // Approving the completion closes the goal agent's
  550. // own task: the run is finished, its saddle frees.
  551. yield tasks.complete_task(((!)task).id,
  552. ((!)goal).completion_report, null, "done");
  553. push.notify_global(PushService.goal_terminal_notification(
  554. ((!)goal).title, ((!)goal).id, true));
  555. }
  556. yield pulse.bump(0, "goal block answered");
  557. }
  558. /**
  559. * Raises the goal's ONE block on the goal-run task (plan 15
  560. * C1): the parked-resume machinery (answered blocks ride the
  561. * resuming allocation) works untouched, and the goal-run task
  562. * parks as 'blocked' waiting for the human verdict. The raise
  563. * is QUIET on the task side — the goal-level notification
  564. * (goal title, /goals/<id> URL) fires here, once, and must
  565. * not double-notify with the run task's title.
  566. *
  567. * Deduplication (`dedupe_on_type`): a pending row of the same
  568. * question — or, for the review kinds whose question text is
  569. * fixed ("Plan submitted for review", "Goal completion
  570. * report"), of the same TYPE — means the push is a
  571. * redelivery and the existing row is returned unwritten. A
  572. * missing or terminal run task raises nothing (null).
  573. */
  574. private async TaskBlock? raise_run_block(Goal goal, string question,
  575. string block_type, string[]? options, string? body,
  576. bool dedupe_on_type) throws Error {
  577. var run = yield goal_run_task(goal.id);
  578. if (run == null || ((!)run).status == STATUS_DONE
  579. || ((!)run).status == STATUS_FAILED) {
  580. warning("[ClankerSaddle] Goal %s block '%s' raised without a live run task",
  581. goal.id.to_string(), question);
  582. return null;
  583. }
  584. foreach (var existing in yield tasks.list_blocks(((!)run).id)) {
  585. bool same = dedupe_on_type
  586. ? existing.block_type == block_type : existing.question == question;
  587. if (same && existing.answer == null) {
  588. return existing;
  589. }
  590. }
  591. var raised = yield tasks.add_block(((!)run).id, question, block_type,
  592. options, body, true);
  593. yield pulse.bump(0, "goal block raised");
  594. push.notify_global(PushService.goal_block_notification(
  595. goal.title, goal.id, block_type, question));
  596. return raised;
  597. }
  598. // ------------------------------------------------------------------
  599. // Comments and events
  600. // ------------------------------------------------------------------
  601. /** Appends a goal comment; `author` is 'agent' or 'user:<name>'. */
  602. public async GoalComment add_comment(int64 goal_id, string author, string body,
  603. string? quote = null) throws Error {
  604. var comment = new GoalComment() {
  605. goal_id = goal_id,
  606. author = author,
  607. body = body,
  608. quote = quote != null && quote.strip().length > 0 ? quote.strip() : null,
  609. created_at = new DateTime.now_local()
  610. };
  611. yield db.insert_async<GoalComment>(comment);
  612. yield pulse.bump(0, "goal comment");
  613. return comment;
  614. }
  615. /** A goal's comments, oldest first. */
  616. public async ImmutableLot<GoalComment> list_comments(int64 goal_id) throws Error {
  617. return yield db.query<GoalComment>()
  618. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  619. .order_by(expr("id"))
  620. .materialise_async();
  621. }
  622. /**
  623. * A goal's blocks (plan 15 C1): the goal-run task's
  624. * `task_blocks` rows, oldest first — the run task is the
  625. * single home of the goal agent's questions, plans and
  626. * completion reports. Empty while the goal has no run task.
  627. */
  628. public async ImmutableLot<TaskBlock> list_blocks(int64 goal_id) throws Error {
  629. var run = yield goal_run_task(goal_id);
  630. if (run == null) {
  631. return new Vector<TaskBlock>().to_immutable_buffer();
  632. }
  633. return yield tasks.list_blocks(((!)run).id);
  634. }
  635. /** A goal's narrative events, oldest first. */
  636. public async ImmutableLot<GoalEvent> list_events(int64 goal_id) throws Error {
  637. return yield db.query<GoalEvent>()
  638. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  639. .order_by(expr("id"))
  640. .materialise_async();
  641. }
  642. /**
  643. * Records one supervision-narrative event. Called by the
  644. * lifecycle flows, the goal agent's task operations and the
  645. * task-terminal hook — every goal_events row is also a
  646. * potential resume signal (see {@link resume_due}).
  647. */
  648. public async GoalEvent record_event(int64 goal_id, string kind, string text,
  649. int64 task_id = 0, int64 ref_task_id = 0) throws Error {
  650. var event = new GoalEvent() {
  651. goal_id = goal_id,
  652. kind = kind,
  653. text = text,
  654. task_id = task_id,
  655. created_at = new DateTime.now_local()
  656. };
  657. yield db.insert_async<GoalEvent>(event);
  658. return event;
  659. }
  660. // ------------------------------------------------------------------
  661. // Event-driven resume (the parked goal agent)
  662. // ------------------------------------------------------------------
  663. /**
  664. * True when the goal agent should be resumed: its run task is
  665. * parked (suspended), the goal is active (paused goals freeze
  666. * supervision; review states park on blocks instead), and at
  667. * least one task event has waited out the coalescing window
  668. * (the OLDEST unconsumed event is old enough — bursts collapse
  669. * into one resume).
  670. */
  671. public async bool resume_due(TaskEntity run_task) throws Error {
  672. if (run_task.kind != TASK_KIND_GOAL_RUN || run_task.status != STATUS_SUSPENDED) {
  673. return false;
  674. }
  675. var goal = yield get_goal(run_task.goal_id);
  676. if (goal == null || ((!)goal).status != GOAL_ACTIVE) {
  677. return false;
  678. }
  679. DateTime? oldest = null;
  680. // ONLY owned-task terminal events resume the parked agent —
  681. // the agent's own task-created/task-enqueued narrative (and
  682. // human comments) must never wake it; the first terminal
  683. // event ages the window and bursts coalesce behind it.
  684. foreach (var event in yield db.query<GoalEvent>()
  685. .where(expr("goal_id == $0 && id > $1",
  686. elem<int64?>(run_task.goal_id),
  687. elem<int64?>(((!)goal).last_seen_event_id)))
  688. .order_by(expr("id"))
  689. .materialise_async()) {
  690. if (event.kind != "task-done" && event.kind != "task-failed") {
  691. continue;
  692. }
  693. if (event.created_at != null) {
  694. oldest = ((!)event).created_at;
  695. break;
  696. }
  697. }
  698. if (oldest == null) {
  699. return false;
  700. }
  701. double age = new DateTime.now_local().difference((!)oldest) / 1000000.0;
  702. return age >= goal_resume_coalesce_seconds();
  703. }
  704. /**
  705. * The batched markdown report riding a goal-agent resume (the
  706. * assignment's `resume_notice`): the current status of ALL of
  707. * the goal's tasks plus the full completion reports of the
  708. * tasks that finished since the agent parked. Consumes the
  709. * pending events (the watermark advances), so the window
  710. * starts fresh for the next park.
  711. */
  712. public async string take_resume_notice(int64 goal_id) throws Error {
  713. var goal = yield require_goal(goal_id);
  714. DateTime? since = null;
  715. var run = yield goal_run_task(goal_id);
  716. if (run != null) {
  717. since = ((!)run).suspended_at;
  718. }
  719. var report = new StringBuilder();
  720. report.append("Goal update — resuming your parked session:\n\n");
  721. report.append("## Task statuses\n");
  722. int64 newest_event = ((!)goal).last_seen_event_id;
  723. var finished = new Vector<TaskEntity>();
  724. foreach (var task in yield db.query<TaskEntity>()
  725. .where(expr("goal_id == $0 && kind == $1",
  726. elem<int64?>(goal_id), elem(TASK_KIND_TASK)))
  727. .order_by(expr("id"))
  728. .materialise_async()) {
  729. if (task.status == STATUS_DONE) {
  730. report.append_printf("- #%s %s — done: %s\n", task.id.to_string(), task.title,
  731. task.summary ?? "no summary reported");
  732. if (since == null || task.completed_at == null
  733. || ((!)task.completed_at).compare((!)since) > 0) {
  734. finished.add(task);
  735. }
  736. } else if (task.status == STATUS_FAILED) {
  737. report.append_printf("- #%s %s — failed: %s\n", task.id.to_string(), task.title,
  738. task.summary ?? "no summary reported");
  739. } else {
  740. report.append_printf("- #%s %s — %s\n", task.id.to_string(), task.title, task.status);
  741. }
  742. }
  743. if (finished.length > 0) {
  744. report.append("\n## New completion reports\n");
  745. foreach (var task in finished) {
  746. report.append_printf("\n### #%s %s\n%s\n", task.id.to_string(), task.title,
  747. task.summary ?? "no summary reported");
  748. }
  749. }
  750. report.append("\nSupervise your tasks: merge completed work into the goal branch,")
  751. .append(" prune worktrees, create or enqueue follow-ups as needed, then")
  752. .append(" `saddle suspend` again when you are parked.\n");
  753. foreach (var event in yield db.query<GoalEvent>()
  754. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  755. .order_by_desc(expr("id"))
  756. .limit(1)
  757. .materialise_async()) {
  758. newest_event = event.id;
  759. }
  760. if (newest_event != ((!)goal).last_seen_event_id) {
  761. ((!)goal).last_seen_event_id = newest_event;
  762. yield db.update_async<Goal>((!)goal);
  763. }
  764. return report.str;
  765. }
  766. // ------------------------------------------------------------------
  767. // Branch push state (daemon /status exposure)
  768. // ------------------------------------------------------------------
  769. /**
  770. * Stores a daemon-reported goal-branch push state on the goal
  771. * (raw JSON as the daemon sent it — the web only renders it).
  772. * Called from the /status observation path when the probed
  773. * saddle is running a goal agent.
  774. */
  775. public async void store_branch_push_state(int64 goal_id, string state_json) throws Error {
  776. var goal = yield get_goal(goal_id);
  777. if (goal == null || ((!)goal).branch_push_state == state_json) {
  778. return;
  779. }
  780. ((!)goal).branch_push_state = state_json;
  781. yield db.update_async<Goal>((!)goal);
  782. }
  783. // ------------------------------------------------------------------
  784. // Helpers
  785. // ------------------------------------------------------------------
  786. private async Goal require_goal(int64 goal_id) throws Error {
  787. var goal = yield get_goal(goal_id);
  788. if (goal == null) {
  789. throw new Error(Quark.from_string("clanker-saddle"), 74,
  790. @"No goal exists with id $(goal_id)");
  791. }
  792. return (!)goal;
  793. }
  794. }
  795. }