GoalService.vala 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311
  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. * Whether a run-task block row is THE completion review. The review is
  21. * raised from two paths — the web's completion raise (kind
  22. * {@link BLOCK_COMPLETION}) and the daemon's done-park (kind
  23. * {@link BLOCK_REVIEW}) — which adopt each other so only one row
  24. * ever exists; every completion-verdict/narrative surface matches
  25. * both kinds.
  26. */
  27. private static bool is_completion_row(TaskBlock block) {
  28. return block.block_type == BLOCK_COMPLETION
  29. || block.block_type == BLOCK_REVIEW;
  30. }
  31. /** Task kinds (the `tasks.kind` wire values). */
  32. public const string TASK_KIND_TASK = "task";
  33. public const string TASK_KIND_GOAL_RUN = "goal_run";
  34. /** Task creators: enqueueing is creator-only. */
  35. public const string CREATOR_HUMAN = "human";
  36. public const string CREATOR_GOAL = "goal";
  37. /**
  38. * The goal-run internal task's mode: the goal agent orchestrates
  39. * (prompt-level mode shaping is the daemon's job).
  40. */
  41. public const string MODE_ORCHESTRATE = "orchestrate";
  42. /** Valid work modes for human/goal-created tasks. */
  43. public const string[] TASK_MODES = { "implement", "investigate", "review", "test" };
  44. /** Whether `mode` is one of {@link TASK_MODES} (the create/edit validation). */
  45. public bool is_valid_mode(string mode) {
  46. foreach (string candidate in TASK_MODES) {
  47. if (mode == candidate) {
  48. return true;
  49. }
  50. }
  51. return false;
  52. }
  53. /** The priority every goal-run internal task dispatches at. */
  54. public const int64 GOAL_RUN_PRIORITY = 3;
  55. /**
  56. * Goal storage and lifecycle (plan 12; blocks consolidated plan
  57. * 15 C1): creation with branch-name uniqueness validation, the
  58. * draft → planning → plan review → active → completion review →
  59. * complete/failed/cancelled flow (with paused freezing
  60. * supervision in between), the goal agent's run task, plan/
  61. * completion review flows with revisions, goal comments, the
  62. * cancel cascade and the event-driven resume machinery (task-
  63. * terminal events coalesce ~10 s into one goal-agent resume
  64. * carrying the batched status report). The goal agent's BLOCKS
  65. * ride the run task's own `task_blocks` rows — one row per
  66. * question, answered in place through {@link answer_block}.
  67. */
  68. public class GoalService : Object {
  69. private OrmSession db = inject<OrmSession>();
  70. private StatePulse pulse = inject<StatePulse>();
  71. private PushService push = inject<PushService>();
  72. private TaskService tasks = inject<TaskService>();
  73. private SaddlePollState polls = inject<SaddlePollState>();
  74. /**
  75. * SADDLE_GOAL_RESUME_S (default 10): the coalescing window —
  76. * a parked goal agent resumes once the OLDEST unconsumed task
  77. * event is at least this old, so bursts collapse into one
  78. * resume.
  79. */
  80. public static uint goal_resume_coalesce_seconds() {
  81. var raw = Environment.get_variable("SADDLE_GOAL_RESUME_S") ?? "";
  82. int64 parsed = int64.parse(raw);
  83. return parsed > 0 ? (uint) parsed : 10;
  84. }
  85. // ------------------------------------------------------------------
  86. // Creation and links
  87. // ------------------------------------------------------------------
  88. /**
  89. * Creates a goal in draft. The branch name is required and
  90. * uniqueness-validated: no other live (non-cancelled,
  91. * non-complete, non-failed) goal may use the same branch name
  92. * while sharing any involved project (primary or link) — one
  93. * branch of that name per involved repo. `base_branch` defaults
  94. * to the project's default branch.
  95. */
  96. public async Goal create_goal(int64 project_id, string title, string brief,
  97. string branch_name, string? base_branch, int64 priority,
  98. string creator = CREATOR_HUMAN) throws Error {
  99. var project = yield db.query<Project>()
  100. .where(expr("id == $0", elem<int64?>(project_id)))
  101. .first_async();
  102. if (project == null) {
  103. throw new Error(Quark.from_string("clanker-saddle"), 60,
  104. @"No project exists with id $(project_id)");
  105. }
  106. string trimmed_branch = branch_name.strip();
  107. if (title.strip().length == 0) {
  108. throw new Error(Quark.from_string("clanker-saddle"), 61,
  109. "A goal title is required");
  110. }
  111. if (brief.strip().length == 0) {
  112. throw new Error(Quark.from_string("clanker-saddle"), 62,
  113. "An initial brief is required");
  114. }
  115. if (trimmed_branch.length == 0) {
  116. throw new Error(Quark.from_string("clanker-saddle"), 63,
  117. "A goal branch name is required");
  118. }
  119. foreach (var other in yield db.query<Goal>().materialise_async()) {
  120. if (other.branch_name != trimmed_branch
  121. || other.status == GOAL_CANCELLED
  122. || other.status == GOAL_COMPLETE
  123. || other.status == GOAL_FAILED) {
  124. continue;
  125. }
  126. if ((yield involved_project_ids(other)).contains(project_id.to_string())) {
  127. throw new Error(Quark.from_string("clanker-saddle"), 64,
  128. @"Branch name '$(trimmed_branch)' is already used by a live goal of an involved project (#$(other.id) $(other.title)) — pick another name");
  129. }
  130. }
  131. var goal = new Goal() {
  132. project_id = project_id,
  133. title = title.strip(),
  134. brief = brief,
  135. branch_name = trimmed_branch,
  136. base_branch = base_branch != null && base_branch.strip().length > 0
  137. ? base_branch.strip() : ((!)project).default_branch,
  138. priority = priority.clamp(1, 9),
  139. creator = creator,
  140. created_at = new DateTime.now_local()
  141. };
  142. yield db.insert_async<Goal>(goal);
  143. yield pulse.bump(0, "goal created");
  144. return goal;
  145. }
  146. /** Loads the goal identified by `id`, or null. */
  147. public async Goal? get_goal(int64 id) throws Error {
  148. return yield db.query<Goal>()
  149. .where(expr("id == $0", elem<int64?>(id)))
  150. .first_async();
  151. }
  152. /** Every goal, newest first. */
  153. public async ImmutableLot<Goal> list_goals() throws Error {
  154. return yield db.query<Goal>()
  155. .order_by_desc(expr("id"))
  156. .materialise_async();
  157. }
  158. /**
  159. * The primary + linked project ids of `goal` (stringified —
  160. * the Invercargill collections cannot hash value types).
  161. */
  162. public async Invercargill.DataStructures.HashSet<string> involved_project_ids(Goal goal) throws Error {
  163. var ids = new Invercargill.DataStructures.HashSet<string>();
  164. ids.add(goal.project_id.to_string());
  165. foreach (var link in yield list_links(goal.id)) {
  166. ids.add(link.project_id.to_string());
  167. }
  168. return ids;
  169. }
  170. /** Adds a project link to the goal; duplicates are refused. */
  171. public async void add_link(int64 goal_id, int64 project_id,
  172. string relationship) throws Error {
  173. if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) {
  174. throw new Error(Quark.from_string("clanker-saddle"), 65,
  175. @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'");
  176. }
  177. var goal = yield get_goal(goal_id);
  178. if (goal == null) {
  179. throw new Error(Quark.from_string("clanker-saddle"), 66,
  180. @"No goal exists with id $(goal_id)");
  181. }
  182. if (project_id == ((!)goal).project_id) {
  183. throw new Error(Quark.from_string("clanker-saddle"), 67,
  184. "The primary project is implicitly linked");
  185. }
  186. var existing = yield db.query<GoalLink>()
  187. .where(expr("goal_id == $0 && project_id == $1",
  188. elem<int64?>(goal_id), elem<int64?>(project_id)))
  189. .materialise_async();
  190. if (existing.length > 0) {
  191. throw new Error(Quark.from_string("clanker-saddle"), 68,
  192. "That project is already linked to the goal");
  193. }
  194. yield db.insert_async<GoalLink>(new GoalLink() {
  195. goal_id = goal_id,
  196. project_id = project_id,
  197. relationship = relationship
  198. });
  199. yield pulse.bump(0, "goal link added");
  200. }
  201. /** Removes the goal link identified by `id`. */
  202. public async void remove_link(int64 link_id) throws Error {
  203. var link = yield db.query<GoalLink>()
  204. .where(expr("id == $0", elem<int64?>(link_id)))
  205. .first_async();
  206. if (link != null) {
  207. yield db.delete_async<GoalLink>((!)link);
  208. yield pulse.bump(0, "goal link removed");
  209. }
  210. }
  211. /** The goal's links, newest first (the list-page consistency
  212. * sweep). The RULE/effective-context consumers only iterate,
  213. * so the flip is invisible to them. */
  214. public async ImmutableLot<GoalLink> list_links(int64 goal_id) throws Error {
  215. return yield db.query<GoalLink>()
  216. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  217. .order_by_desc(expr("id"))
  218. .materialise_async();
  219. }
  220. // ------------------------------------------------------------------
  221. // Lifecycle
  222. // ------------------------------------------------------------------
  223. /**
  224. * The goal's goal-run internal task (kind 'goal_run'), or null
  225. * before the goal starts.
  226. */
  227. public async TaskEntity? goal_run_task(int64 goal_id) throws Error {
  228. var rows = yield db.query<TaskEntity>()
  229. .where(expr("goal_id == $0 && kind == $1",
  230. elem<int64?>(goal_id), elem(TASK_KIND_GOAL_RUN)))
  231. .order_by(expr("id"))
  232. .materialise_async();
  233. TaskEntity? first = null;
  234. foreach (var row in rows) {
  235. first = row;
  236. break;
  237. }
  238. return first;
  239. }
  240. /**
  241. * Starts a draft goal: the brief locks, the goal moves to
  242. * 'planning' and the goal agent's internal run task is created
  243. * (kind 'goal_run', mode 'orchestrate', priority 3, creator
  244. * 'goal') and queued — the dispatcher hands it to a saddle and
  245. * the agent begins investigating.
  246. */
  247. public async Goal start_goal(int64 goal_id) throws Error {
  248. var goal = yield require_goal(goal_id);
  249. if (((!)goal).status != GOAL_DRAFT) {
  250. throw new Error(Quark.from_string("clanker-saddle"), 69,
  251. "Only draft goals can be started");
  252. }
  253. ((!)goal).status = GOAL_PLANNING;
  254. ((!)goal).started_at = new DateTime.now_local();
  255. yield db.update_async<Goal>((!)goal);
  256. var run = yield tasks.create_task(((!)goal).project_id,
  257. @"Goal agent — $(((!)goal).title)",
  258. ((!)goal).brief, GOAL_RUN_PRIORITY,
  259. null, ((!)goal).base_branch, STATUS_QUEUED,
  260. TASK_KIND_GOAL_RUN, MODE_ORCHESTRATE, CREATOR_GOAL,
  261. ((!)goal).id, 0);
  262. yield pulse.bump(run.id, "goal started");
  263. return goal;
  264. }
  265. /**
  266. * Edits a DRAFT goal's title, brief and priority (the brief-card
  267. * editor, plan QoL task 12): a draft is the only phase with an
  268. * edit surface — starting locks the brief (the run task's
  269. * mandate is seeded from it) and every later phase reads it as
  270. * history. Each argument is optional (null = leave unchanged),
  271. * mirroring {@link TaskService.edit_task}.
  272. */
  273. public async Goal edit_draft_goal(int64 goal_id, string? title, string? brief,
  274. int64? priority) throws Error {
  275. var goal = yield require_goal(goal_id);
  276. if (((!)goal).status != GOAL_DRAFT) {
  277. throw new Error(Quark.from_string("clanker-saddle"), 72,
  278. @"Only draft goals can be edited — $(((!)goal).status) goals have a locked brief");
  279. }
  280. if (title != null && ((!)title).strip().length == 0) {
  281. throw new Error(Quark.from_string("clanker-saddle"), 73,
  282. "A goal title is required");
  283. }
  284. if (brief != null && ((!)brief).strip().length == 0) {
  285. throw new Error(Quark.from_string("clanker-saddle"), 74,
  286. "A goal brief is required");
  287. }
  288. if (title != null) {
  289. ((!)goal).title = ((!)title).strip();
  290. }
  291. if (brief != null) {
  292. ((!)goal).brief = (!)brief;
  293. }
  294. if (priority != null) {
  295. ((!)goal).priority = ((!)priority).clamp(1, 9);
  296. }
  297. yield db.update_async<Goal>((!)goal);
  298. return goal;
  299. }
  300. /**
  301. * Pauses an active goal: supervision freezes — no goal-agent
  302. * resumes are scheduled while paused; task events keep
  303. * queueing and coalesce into the flush on resume.
  304. */
  305. public async Goal pause_goal(int64 goal_id) throws Error {
  306. var goal = yield require_goal(goal_id);
  307. if (((!)goal).status != GOAL_ACTIVE) {
  308. throw new Error(Quark.from_string("clanker-saddle"), 70,
  309. "Only active goals can be paused");
  310. }
  311. ((!)goal).status = GOAL_PAUSED;
  312. ((!)goal).paused_at = new DateTime.now_local();
  313. yield db.update_async<Goal>((!)goal);
  314. yield pulse.bump(0, "goal paused");
  315. return goal;
  316. }
  317. /**
  318. * Resumes a paused goal: supervision restarts and any events
  319. * that queued while paused coalesce into the next resume (the
  320. * oldest pending event usually already satisfies the window).
  321. */
  322. public async Goal resume_goal(int64 goal_id) throws Error {
  323. var goal = yield require_goal(goal_id);
  324. if (((!)goal).status != GOAL_PAUSED) {
  325. throw new Error(Quark.from_string("clanker-saddle"), 71,
  326. "Only paused goals can be resumed");
  327. }
  328. ((!)goal).status = GOAL_ACTIVE;
  329. ((!)goal).paused_at = null;
  330. yield db.update_async<Goal>((!)goal);
  331. yield pulse.bump(0, "goal resumed");
  332. return goal;
  333. }
  334. /**
  335. * Goal 4 task 1 (Force Wake): the human-triggered resume —
  336. * sets {@link Goal.force_wake} so the parked run becomes
  337. * offerable immediately (resume_due skips the coalescing
  338. * window and the terminal-transition requirement) and the
  339. * resume notice leads with the explicit wake line. One wake
  340. * per press: take_resume_notice clears the flag.
  341. */
  342. public async Goal force_wake_goal(int64 goal_id) throws Error {
  343. var goal = yield require_goal(goal_id);
  344. if (((!)goal).status != GOAL_ACTIVE) {
  345. throw new Error(Quark.from_string("clanker-saddle"), 72,
  346. "Only active goals can be force-woke (paused goals freeze supervision — resume the goal instead)");
  347. }
  348. var run = yield goal_run_task(goal_id);
  349. if (run == null || ((!)run).status != STATUS_SUSPENDED) {
  350. throw new Error(Quark.from_string("clanker-saddle"), 73,
  351. "The goal agent is not parked — there is nothing to force-woke");
  352. }
  353. ((!)goal).force_wake = true;
  354. yield db.update_async<Goal>((!)goal);
  355. yield pulse.bump(0, "goal force-woke");
  356. return goal;
  357. }
  358. /**
  359. * Cancels a goal: the cascade closes every one of its tasks —
  360. * non-terminal ones are cancelled (the running goal agent
  361. * included), draft ones deleted (they never ran). Terminal
  362. * goals refuse the action. Live daemons are NOT aborted here —
  363. * {@link LifecycleService.cancel_goal} (which reaches
  364. * SaddleService without a dependency cycle) aborts them
  365. * first.
  366. */
  367. public async void cancel_goal(int64 goal_id) throws Error {
  368. var goal = yield require_goal(goal_id);
  369. string status = ((!)goal).status;
  370. if (status == GOAL_COMPLETE || status == GOAL_FAILED
  371. || status == GOAL_CANCELLED) {
  372. throw new Error(Quark.from_string("clanker-saddle"), 72,
  373. "The goal is already finished");
  374. }
  375. ((!)goal).status = GOAL_CANCELLED;
  376. ((!)goal).completed_at = new DateTime.now_local();
  377. yield db.update_async<Goal>((!)goal);
  378. yield cascade_goal_tasks(goal_id);
  379. yield pulse.bump(0, "goal cancelled");
  380. }
  381. /**
  382. * Revives a failed goal for another supervision attempt (plan
  383. * 14 B1; plan 16 R2 moved the RetryGoalRunAction's direct
  384. * Goal writes here): status back to 'active', the terminal
  385. * stamp withdrawn. The caller ({@link
  386. * LifecycleService.retry_goal_run}) has already requeued the
  387. * run task and aborts its daemon first; the action guards the
  388. * failed-goal/failed-run preconditions upstream. Returns the
  389. * revived row.
  390. */
  391. public async Goal retry_goal(int64 goal_id) throws Error {
  392. var goal = yield require_goal(goal_id);
  393. ((!)goal).status = GOAL_ACTIVE;
  394. ((!)goal).completed_at = null;
  395. yield db.update_async<Goal>((!)goal);
  396. return goal;
  397. }
  398. /**
  399. * The cancel/fail cascade the terminal goal paths share: every
  400. * non-terminal task under the goal closes — drafts are deleted
  401. * (they never ran; plan 13 B3 — no orphan stays dispatchable),
  402. * everything else is cancelled (the running goal agent
  403. * included). Terminal rows keep their first outcome.
  404. */
  405. private async void cascade_goal_tasks(int64 goal_id) throws Error {
  406. foreach (var task in yield db.query<TaskEntity>()
  407. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  408. .order_by(expr("id"))
  409. .materialise_async()) {
  410. if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
  411. continue;
  412. }
  413. if (task.status == STATUS_DRAFT) {
  414. yield tasks.delete_task_quietly(task.id);
  415. } else {
  416. yield tasks.cancel_task(task.id);
  417. }
  418. }
  419. }
  420. // ------------------------------------------------------------------
  421. // The goal agent's pushes: plan, completion, failure
  422. // ------------------------------------------------------------------
  423. /**
  424. * The goal agent submitted (or revised) its plan: ONE
  425. * review-kind block of type 'plan' on the goal-run task
  426. * carries the markdown, the goal moves to 'plan_review' and
  427. * the run parks on it (the verdict resume rides the answered
  428. * block). The caller has already parked the task via the
  429. * results state. Plan 15 C1: the block lives on the run task
  430. * — there is no second row to keep in sync.
  431. */
  432. public async TaskBlock? submit_plan(int64 goal_id, string markdown) throws Error {
  433. var goal = yield require_goal(goal_id);
  434. // Idempotent on the pending submission: a redelivered push
  435. // (outbox retry, the entry + state pair of one `saddle
  436. // plan`) must not stack duplicate revisions — a NEW row
  437. // only appears once the previous one was answered
  438. // (rejected → the agent revises).
  439. TaskBlock? raised = yield raise_run_block(((!)goal), "Plan submitted for review",
  440. BLOCK_PLAN, null, markdown, true);
  441. if (raised == null || ((!)raised).answer != null) {
  442. // No pending submission: a fresh revision supersedes
  443. // the answered history.
  444. raised = yield raise_run_block(((!)goal), "Plan submitted for review",
  445. BLOCK_PLAN, null, markdown, false);
  446. }
  447. if (raised == null) {
  448. return null;
  449. }
  450. ((!)goal).status = GOAL_PLAN_REVIEW;
  451. yield db.update_async<Goal>(((!)goal));
  452. return raised;
  453. }
  454. /**
  455. * Records a goal-agent question/RFC as ONE block on the
  456. * goal-run task and parks it there. Idempotent on the pending
  457. * question: a redelivered blocked push (outbox retry, the
  458. * park-unwinding round-end push) must not stack duplicates.
  459. */
  460. public async TaskBlock? raise_agent_block(int64 goal_id, string question,
  461. string block_type, string[]? options = null, string? body = null) throws Error {
  462. var goal = yield require_goal(goal_id);
  463. TaskBlock? raised = yield raise_run_block(((!)goal), question,
  464. block_type.length > 0 ? block_type : BLOCK_QUESTION,
  465. options, body, false);
  466. if (raised == null) {
  467. return null;
  468. }
  469. return raised;
  470. }
  471. /**
  472. * The goal agent submitted its completion report (`saddle
  473. * done` in goal context): the goal moves to
  474. * 'completion_review' and the report lives ONLY in the pending
  475. * completion-kind block on the goal-run task (canonical-on-
  476. * accept, plan 16 S1: `goals.completion_report` is written
  477. * ONLY by the accept verdict — reject/revise leaves the goal
  478. * row clean). The run parks on the block. The daemon enforces
  479. * the branch-push rule before letting `done` through.
  480. * Idempotent on terminal goals (plan 13 B2): a redelivered
  481. * done push must not resurrect a finished goal.
  482. */
  483. public async TaskBlock? submit_completion(int64 goal_id, string report) throws Error {
  484. var goal = yield require_goal(goal_id);
  485. if (((!)goal).status == GOAL_COMPLETE || ((!)goal).status == GOAL_FAILED
  486. || ((!)goal).status == GOAL_CANCELLED) {
  487. // Terminal stands: the pending-report idempotency below
  488. // cannot apply (the verdict already landed).
  489. throw new Error(Quark.from_string("clanker-saddle"), 85,
  490. @"The goal is already $(((!)goal).status) — its completion review is closed");
  491. }
  492. // Idempotent on the pending report (see submit_plan): the
  493. // raise adopts an unanswered review-family row (the daemon's
  494. // done-park "review" block IS this review) — one row,
  495. // whichever path landed first.
  496. TaskBlock? raised = yield raise_run_block(((!)goal), "Goal completion report",
  497. BLOCK_COMPLETION, null, report, true);
  498. if (raised == null) {
  499. return null;
  500. }
  501. ((!)goal).status = GOAL_COMPLETION_REVIEW;
  502. yield db.update_async<Goal>(((!)goal));
  503. return raised;
  504. }
  505. /**
  506. * Fails the goal (`saddle failure` in goal context): status
  507. * 'failed', the report stored, the goal-run task failed (its
  508. * session closes and saddle frees), every non-terminal task
  509. * cascades exactly like a cancellation (plan 13 B3: drafts
  510. * are deleted — no orphan stays dispatchable — queued/working/
  511. * blocked tasks are cancelled) and the narrative records the
  512. * cascade. Every user is notified. Idempotent on terminal
  513. * goals (plan 13 B2): a replayed failure push — redelivered
  514. * OR a fresh entry id from the honest burst — re-runs no
  515. * bookkeeping, so exactly one goal-failed event, one cascade,
  516. * one notification, ever.
  517. */
  518. public async void fail_goal(int64 goal_id, string? report) throws Error {
  519. var goal = yield require_goal(goal_id);
  520. if (((!)goal).status == GOAL_COMPLETE || ((!)goal).status == GOAL_FAILED
  521. || ((!)goal).status == GOAL_CANCELLED) {
  522. return;
  523. }
  524. ((!)goal).status = GOAL_FAILED;
  525. ((!)goal).completed_at = new DateTime.now_local();
  526. ((!)goal).completion_report = report ?? ((!)goal).completion_report;
  527. yield db.update_async<Goal>(((!)goal));
  528. var run = yield goal_run_task(goal_id);
  529. if (run != null && ((!)run).status != STATUS_DONE
  530. && ((!)run).status != STATUS_FAILED) {
  531. yield tasks.fail_task(((!)run).id,
  532. report != null && report.length > 0 ? report : "The goal agent failed the goal");
  533. }
  534. // Plan 13 B3: the failure cascade — the cancel-cascade
  535. // semantics (the run task just failed above, so the loop's
  536. // terminal skip covers it).
  537. yield cascade_goal_tasks(goal_id);
  538. push.notify_global(PushService.goal_terminal_notification(
  539. ((!)goal).title, goal_id, false));
  540. yield pulse.bump(0, "goal failed");
  541. }
  542. /**
  543. * Answers a goal's block (plan 15 C1: THE single answer path
  544. * every surface calls — the goal page's verdict forms, the
  545. * task-page deep link on a goal-run block, the dashboard's
  546. * link-through destination). The block is the goal-run task's
  547. * OWN row: answering it in place settles everything an
  548. * observer can read before the first pulse fires, so the
  549. * completion-review-never-leaves-the-dashboard bug class is
  550. * structurally impossible — no dual rows exist to fall out of
  551. * sync. The parked-resume machinery then wakes the agent with
  552. * the verdict as its resume_notice. Plan and completion
  553. * blocks apply their goal-state transitions:
  554. *
  555. * - plan + {@link NOTICE_PLAN_APPROVED} → the submission
  556. * becomes goal.plan, status 'active';
  557. * - plan + feedback → status 'planning' (the agent revises);
  558. * - completion + {@link NOTICE_REVIEW_APPROVED} → the goal
  559. * completes, the winning block's body becomes
  560. * goal.completion_report (plan 16 S1: the ONLY writer) and
  561. * the goal-run task closes done;
  562. * - completion + feedback → status 'active' (the agent keeps
  563. * working; the goal row keeps no report).
  564. */
  565. public async void answer_block(int64 block_id, string answer,
  566. string? answered_by = null) throws Error {
  567. var block = yield db.query<TaskBlock>()
  568. .where(expr("id == $0", elem<int64?>(block_id)))
  569. .first_async();
  570. if (block == null) {
  571. throw new Error(Quark.from_string("clanker-saddle"), 73,
  572. @"No goal block exists with id $(block_id)");
  573. }
  574. if (((!)block).answer != null) {
  575. return;
  576. }
  577. var task = yield db.query<TaskEntity>()
  578. .where(expr("id == $0", elem<int64?>(((!)block).task_id)))
  579. .first_async();
  580. if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN
  581. || ((!)task).goal_id <= 0) {
  582. // Not a goal-run block: the plain task answer path.
  583. yield tasks.answer_block(block_id, answer, answered_by);
  584. return;
  585. }
  586. var goal = yield get_goal(((!)task).goal_id);
  587. if (goal == null) {
  588. return;
  589. }
  590. // The verdict's events record FIRST, then the row settles
  591. // (below) before the first observer pulse fires — an
  592. // event-less resume is a lost context_update, and an
  593. // unanswered row past the pulse is exactly the stale-pile
  594. // shape plan 14 B3 defended against with mirror sweeps.
  595. // With one row there is nothing left to sweep.
  596. if (((!)block).block_type == BLOCK_PLAN) {
  597. if (answer == NOTICE_PLAN_APPROVED) {
  598. if (((!)block).body != null && ((!)block).body.length > 0) {
  599. ((!)goal).plan = ((!)block).body;
  600. }
  601. ((!)goal).status = GOAL_ACTIVE;
  602. } else {
  603. ((!)goal).status = GOAL_PLANNING;
  604. }
  605. } else if (is_completion_row(((!)block))) {
  606. if (answer == NOTICE_REVIEW_APPROVED) {
  607. // Plan 15 C4: the operator-confirmed hard gate —
  608. // refuse while origin is behind (live flags only).
  609. yield refuse_unpushed_origin(((!)goal), (!)task);
  610. // Plan 16 S1 (canonical-on-accept): the report
  611. // lands on the goal ONLY here — the winning
  612. // block's body becomes completion_report, so the
  613. // terminal card reads a field that provably exists
  614. // only post-acceptance.
  615. if (((!)block).body != null && ((!)block).body.length > 0) {
  616. ((!)goal).completion_report = ((!)block).body;
  617. }
  618. ((!)goal).status = GOAL_COMPLETE;
  619. ((!)goal).completed_at = new DateTime.now_local();
  620. } else {
  621. // Reject/revise: the agent keeps working and the
  622. // goal row stays clean (the report died with the
  623. // rejected block — submit_completion never wrote
  624. // it there).
  625. ((!)goal).status = GOAL_ACTIVE;
  626. }
  627. }
  628. yield db.update_async<Goal>(((!)goal));
  629. yield tasks.answer_block(block_id, answer, answered_by);
  630. if (is_completion_row(((!)block))
  631. && answer == NOTICE_REVIEW_APPROVED) {
  632. // Approving the completion closes the goal agent's
  633. // own task: the run is finished, its saddle frees.
  634. yield tasks.complete_task(((!)task).id,
  635. ((!)goal).completion_report, OUTCOME_DONE);
  636. push.notify_global(PushService.goal_terminal_notification(
  637. ((!)goal).title, ((!)goal).id, true));
  638. }
  639. yield pulse.bump(0, "goal block answered");
  640. }
  641. /**
  642. * Raises the goal's ONE block on the goal-run task (plan 15
  643. * C1): the parked-resume machinery (answered blocks ride the
  644. * resuming allocation) works untouched, and the goal-run task
  645. * parks as 'blocked' waiting for the human verdict. The raise
  646. * is QUIET on the task side — the goal-level notification
  647. * (goal title, /goals/<id> URL) fires here, once, and must
  648. * not double-notify with the run task's title.
  649. *
  650. * Deduplication (`dedupe_on_type`): a pending row of the same
  651. * question — or, for the review kinds whose question text is
  652. * fixed ("Plan submitted for review", "Goal completion
  653. * report"), of the same TYPE — means the push is a
  654. * redelivery and the existing row is returned unwritten. A
  655. * missing or terminal run task raises nothing (null).
  656. */
  657. private async TaskBlock? raise_run_block(Goal goal, string question,
  658. string block_type, string[]? options, string? body,
  659. bool dedupe_on_type) throws Error {
  660. var run = yield goal_run_task(goal.id);
  661. if (run == null || ((!)run).status == STATUS_DONE
  662. || ((!)run).status == STATUS_FAILED) {
  663. warning("[ClankerSaddle] Goal %s block '%s' raised without a live run task",
  664. goal.id.to_string(), question);
  665. return null;
  666. }
  667. // Review-family cross-dedupe: the completion review is raised
  668. // from two paths (the web's completion kind, the daemon's
  669. // done-park "review" kind, different question strings) — an
  670. // unanswered row of EITHER kind IS the pending review; adopt
  671. // it rather than raising a twin.
  672. bool review_family = block_type == BLOCK_COMPLETION
  673. || block_type == BLOCK_REVIEW;
  674. foreach (var existing in yield tasks.list_blocks(((!)run).id)) {
  675. if (review_family && existing.answer == null
  676. && (existing.block_type == BLOCK_COMPLETION
  677. || existing.block_type == BLOCK_REVIEW)) {
  678. return existing;
  679. }
  680. bool same = dedupe_on_type
  681. ? existing.block_type == block_type : existing.question == question;
  682. if (same && existing.answer == null) {
  683. return existing;
  684. }
  685. }
  686. var raised = yield tasks.add_block(((!)run).id, question, block_type,
  687. options, body, true);
  688. yield pulse.bump(0, "goal block raised");
  689. push.notify_global(PushService.goal_block_notification(
  690. goal.title, goal.id, block_type, question));
  691. return raised;
  692. }
  693. // ------------------------------------------------------------------
  694. // Comments and events
  695. // ------------------------------------------------------------------
  696. /**
  697. * Appends a HUMAN comment to the goal's thread (plan 15 C2):
  698. * the goal-run task's own `task_comments` row — injection
  699. * rides the existing delivery paths (the pending-comment
  700. * turn, the resuming assignment's embedded comments) straight
  701. * into the agent's next round. The goal agent's own comments
  702. * land on the same thread via its entry pushes. Refused
  703. * before the run exists (a draft goal has no thread).
  704. */
  705. public async TaskComment? add_human_comment(int64 goal_id, string author,
  706. string body, string? quote = null) throws Error {
  707. var run = yield goal_run_task(goal_id);
  708. if (run == null) {
  709. throw new Error(Quark.from_string("clanker-saddle"), 87,
  710. "The goal agent has not started yet — comments open with its run");
  711. }
  712. return yield tasks.add_comment(((!)run).id, author, body, quote);
  713. }
  714. /**
  715. * The goal's comments (plan 15 C2): the goal-run task's
  716. * `task_comments` rows, newest first — the ONE thread.
  717. * Empty while the goal has no run task.
  718. */
  719. public async ImmutableLot<TaskComment> list_comments(int64 goal_id) throws Error {
  720. var run = yield goal_run_task(goal_id);
  721. if (run == null) {
  722. return new Vector<TaskComment>().to_immutable_buffer();
  723. }
  724. return yield tasks.list_comments(((!)run).id);
  725. }
  726. /**
  727. * A goal's blocks (plan 15 C1): the goal-run task's
  728. * `task_blocks` rows, newest first — the run task is the
  729. * single home of the goal agent's questions, plans and
  730. * completion reports. Empty while the goal has no run task.
  731. */
  732. public async ImmutableLot<TaskBlock> list_blocks(int64 goal_id) throws Error {
  733. var run = yield goal_run_task(goal_id);
  734. if (run == null) {
  735. return new Vector<TaskBlock>().to_immutable_buffer();
  736. }
  737. return yield tasks.list_blocks(((!)run).id);
  738. }
  739. /**
  740. * One block row by id (the verdict actions' kind lookup for
  741. * the server-owned wording, plan 16 S2/R4), or null.
  742. */
  743. public async TaskBlock? get_block(int64 block_id) throws Error {
  744. return yield db.query<TaskBlock>()
  745. .where(expr("id == $0", elem<int64?>(block_id)))
  746. .first_async();
  747. }
  748. /**
  749. * One derived supervision-narrative row (plan 15 C3): the
  750. * read-time projection of a timestamped fact — a lifecycle
  751. * stamp, a run-task block or comment, a worker task's
  752. * created/terminal transition. `kind` keeps the old
  753. * goal_events vocabulary; system rows (kind "system") come
  754. * from the run thread's harness-authored comments.
  755. */
  756. public class NarrativeRow : Object {
  757. public string kind { get; set; default = ""; }
  758. public string text { get; set; default = ""; }
  759. public int64 task_id { get; set; }
  760. public DateTime? at { get; set; default = null; }
  761. }
  762. /**
  763. * The supervision narrative (plan 15 C3): a DERIVED read-time
  764. * view — nothing is written on ingest any more. The rows come
  765. * from the goal's lifecycle stamps (created/started/paused/
  766. * terminal), the goal-run task's blocks (plan submissions and
  767. * verdicts, questions/RFCs, the completion review) and its
  768. * comment thread (agent/user rows as footnote comments,
  769. * harness-authored rows as system events), and the goal's
  770. * tasks' created and terminal transitions. Timestamp-less
  771. * bookkeeping (task-enqueued, task-deleted, resumed) never
  772. * returns; the goal-failure CASCADE derives from the
  773. * cancelled tasks surviving under a failed goal. Newest
  774. * first.
  775. */
  776. public async ImmutableLot<NarrativeRow> narrative(int64 goal_id) throws Error {
  777. var goal = yield get_goal(goal_id);
  778. if (goal == null) {
  779. return new Vector<NarrativeRow>().to_immutable_buffer();
  780. }
  781. var rows = new Vector<NarrativeRow>();
  782. // Lifecycle stamps.
  783. if (((!)goal).created_at != null) {
  784. rows.add(new NarrativeRow() {
  785. kind = "created", at = ((!)goal).created_at,
  786. text = @"Goal created (branch $(((!)goal).branch_name))" });
  787. }
  788. if (((!)goal).started_at != null) {
  789. rows.add(new NarrativeRow() {
  790. kind = "started", at = ((!)goal).started_at,
  791. text = "Goal started — the goal agent is planning" });
  792. }
  793. if (((!)goal).paused_at != null && ((!)goal).status == GOAL_PAUSED) {
  794. rows.add(new NarrativeRow() {
  795. kind = "paused", at = ((!)goal).paused_at,
  796. text = "Goal paused — supervision frozen (terminal transitions queue)" });
  797. }
  798. if (((!)goal).completed_at != null) {
  799. if (((!)goal).status == GOAL_CANCELLED) {
  800. rows.add(new NarrativeRow() {
  801. kind = "cancelled", at = ((!)goal).completed_at,
  802. text = "Goal cancelled — its tasks were cancelled with it" });
  803. } else if (((!)goal).status == GOAL_FAILED) {
  804. rows.add(new NarrativeRow() {
  805. kind = "goal-failed", at = ((!)goal).completed_at,
  806. text = (((!)goal).completion_report ?? "").length > 0
  807. ? (!)(((!)goal).completion_report)
  808. : "The goal agent failed the goal" });
  809. }
  810. }
  811. // The run task's blocks: the plan/completion reviews with
  812. // their verdicts, the questions/RFCs.
  813. var run = yield goal_run_task(goal_id);
  814. if (run != null) {
  815. foreach (var block in yield tasks.list_blocks(((!)run).id)) {
  816. if (block.created_at != null) {
  817. if (block.block_type == BLOCK_PLAN) {
  818. rows.add(new NarrativeRow() {
  819. kind = "plan-submitted", at = block.created_at,
  820. text = @"Plan revision #$(block.id) submitted for review" });
  821. } else if (is_completion_row(block)) {
  822. rows.add(new NarrativeRow() {
  823. kind = "completion-submitted", at = block.created_at,
  824. text = "Completion report submitted for review" });
  825. } else {
  826. rows.add(new NarrativeRow() {
  827. kind = "block-raised", at = block.created_at,
  828. text = @"$(block.block_type) raised: $(block.question)" });
  829. }
  830. }
  831. if (block.answered_at != null) {
  832. if (block.block_type == BLOCK_PLAN) {
  833. bool approved = block.answer == NOTICE_PLAN_APPROVED;
  834. rows.add(new NarrativeRow() {
  835. kind = approved ? "plan-approved" : "plan-rejected",
  836. at = block.answered_at,
  837. text = approved
  838. ? @"Plan revision #$(block.id) approved — the goal is active"
  839. : @"Plan revision #$(block.id) rejected with feedback — the agent revises" });
  840. } else if (is_completion_row(block)) {
  841. bool approved = block.answer == NOTICE_REVIEW_APPROVED;
  842. rows.add(new NarrativeRow() {
  843. kind = approved ? "goal-approved" : "completion-rejected",
  844. at = block.answered_at,
  845. text = approved
  846. ? "Completion report approved — the goal is complete"
  847. : "Completion report rejected with feedback — the agent continues" });
  848. } else {
  849. rows.add(new NarrativeRow() {
  850. kind = "block-answered", at = block.answered_at,
  851. text = @"$(block.block_type) answered" });
  852. }
  853. }
  854. }
  855. // The ONE comment thread: agent/user rows as footnote
  856. // comments, harness-authored rows as system events.
  857. // Plan 16 S5 (U2): mechanical rows (restores, park
  858. // notices) never reach the user-facing narrative.
  859. foreach (var comment in yield tasks.list_comments(((!)run).id)) {
  860. if (comment.created_at == null) {
  861. continue;
  862. }
  863. if (comment.author == COMMENT_AUTHOR_SYSTEM
  864. && comment_is_mechanical(comment.body)) {
  865. continue;
  866. }
  867. rows.add(new NarrativeRow() {
  868. kind = comment.author == COMMENT_AUTHOR_SYSTEM
  869. ? "system" : "comment",
  870. at = comment.created_at,
  871. text = comment.body });
  872. }
  873. }
  874. // The goal's tasks: created + terminal transitions. The
  875. // failure cascade derives from the cancelled tasks
  876. // surviving under a failed goal (deleted drafts left no
  877. // rows; the count is the surviving record).
  878. int64 cascaded = 0;
  879. foreach (var task in yield db.query<TaskEntity>()
  880. .where(expr("goal_id == $0 && kind == $1",
  881. elem<int64?>(goal_id), elem(TASK_KIND_TASK)))
  882. .order_by(expr("id"))
  883. .materialise_async()) {
  884. if (task.created_at != null) {
  885. rows.add(new NarrativeRow() {
  886. kind = "task-created", at = task.created_at, task_id = task.id,
  887. text = @"Task #$(task.id) $(task.title) created by the goal agent ($(task.mode))" });
  888. }
  889. if (task.completed_at != null) {
  890. if (task.status == STATUS_DONE) {
  891. rows.add(new NarrativeRow() {
  892. kind = "task-done", at = task.completed_at, task_id = task.id,
  893. text = @"Task #$(task.id) $(task.title) — completed$(summary_lead(task))" });
  894. } else if (task.status == STATUS_FAILED) {
  895. rows.add(new NarrativeRow() {
  896. kind = "task-failed", at = task.completed_at, task_id = task.id,
  897. text = @"Task #$(task.id) $(task.title) — failed$(summary_lead(task))" });
  898. if (((!)goal).status == GOAL_FAILED
  899. && (task.summary ?? "") == "Cancelled by user") {
  900. cascaded++;
  901. }
  902. }
  903. }
  904. }
  905. if (((!)goal).status == GOAL_FAILED && cascaded > 0) {
  906. rows.add(new NarrativeRow() {
  907. kind = "cascade", at = ((!)goal).completed_at,
  908. text = @"Goal failure cancelled $(cascaded) outstanding task(s)" });
  909. }
  910. // Newest first, stable for equal timestamps (insertion
  911. // sort — the narratives are small).
  912. var array = rows.to_array();
  913. for (var i = 1; i < array.length; i++) {
  914. var key = array[i];
  915. var j = i - 1;
  916. while (j >= 0 && narrative_after(key, array[j])) {
  917. array[j + 1] = array[j];
  918. j--;
  919. }
  920. array[j + 1] = key;
  921. }
  922. var newest_first = new Vector<NarrativeRow>();
  923. foreach (var row in array) {
  924. newest_first.add(row);
  925. }
  926. return newest_first.to_immutable_buffer();
  927. }
  928. /** True when row `a` sorts after (newer than) row `b`. */
  929. private static bool narrative_after(NarrativeRow a, NarrativeRow b) {
  930. if (a.at == null) {
  931. return false;
  932. }
  933. if (b.at == null) {
  934. return true;
  935. }
  936. return ((!)a.at).compare((!)b.at) > 0;
  937. }
  938. /** The task's summary as a narrative lead-in (": <first line>"). */
  939. private static string summary_lead(TaskEntity task) {
  940. if (task.summary == null || ((!)task.summary).length == 0) {
  941. return "";
  942. }
  943. string summary = (!)task.summary;
  944. int cut = summary.index_of("\n");
  945. string lead = cut >= 0 ? summary.substring(0, cut) : summary;
  946. return @": $(lead)";
  947. }
  948. // ------------------------------------------------------------------
  949. // Event-driven resume (the parked goal agent)
  950. // ------------------------------------------------------------------
  951. /**
  952. * True when the goal agent should be resumed: its run task is
  953. * parked (suspended), the goal is active (paused goals freeze
  954. * supervision — transitions newer than the watermark simply
  955. * QUEUE until the goal resumes; review states park on blocks
  956. * instead), and at least one owned task's TERMINAL transition
  957. * (completed_at newer than {@link Goal.last_terminal_watermark})
  958. * has waited out the coalescing window — the OLDEST such
  959. * transition ages the window, so bursts collapse into one
  960. * resume. Plan 15 C3: the watermark replaced the goal_events
  961. * id cursor; the transitions read straight off the tasks.
  962. */
  963. public async bool resume_due(TaskEntity run_task) throws Error {
  964. if (run_task.kind != TASK_KIND_GOAL_RUN || run_task.status != STATUS_SUSPENDED) {
  965. return false;
  966. }
  967. var goal = yield get_goal(run_task.goal_id);
  968. if (goal == null || ((!)goal).status != GOAL_ACTIVE) {
  969. return false;
  970. }
  971. // Goal 4 task 1 (Force Wake): the human pressed Force
  972. // wake — offerable immediately, skipping BOTH the
  973. // coalescing window and the terminal-transition
  974. // requirement (a parked agent with no pending wake —
  975. // the goal-2 stall class — still resumes).
  976. if (((!)goal).force_wake) {
  977. return true;
  978. }
  979. DateTime? watermark = ((!)goal).last_terminal_watermark;
  980. DateTime? oldest = null;
  981. // ONLY owned-task terminal transitions resume the parked
  982. // agent — the agent's own task-created narrative (and
  983. // human comments) must never wake it.
  984. foreach (var task in yield db.query<TaskEntity>()
  985. .where(expr("goal_id == $0 && kind == $1",
  986. elem<int64?>(run_task.goal_id), elem(TASK_KIND_TASK)))
  987. .order_by(expr("id"))
  988. .materialise_async()) {
  989. if (task.completed_at == null) {
  990. continue;
  991. }
  992. if (watermark != null
  993. && ((!)task.completed_at).compare((!)watermark) <= 0) {
  994. continue;
  995. }
  996. if (oldest == null || ((!)task.completed_at).compare((!)oldest) < 0) {
  997. oldest = task.completed_at;
  998. }
  999. }
  1000. if (oldest == null) {
  1001. return false;
  1002. }
  1003. double age = new DateTime.now_local().difference((!)oldest) / 1000000.0;
  1004. return age >= goal_resume_coalesce_seconds();
  1005. }
  1006. /**
  1007. * The batched markdown report riding a goal-agent resume (the
  1008. * assignment's `resume_notice`): the current status of ALL of
  1009. * the goal's tasks as ONE-LINE entries (status + the first
  1010. * line of the terminal summary) plus the FULL completion
  1011. * reports of the tasks that finished since the agent parked —
  1012. * each new completion's body exactly once (plan 16 S3: no
  1013. * statuses-section/reports-section double embedding; the
  1014. * notice size is constant per event, not cumulative). Consumes
  1015. * the pending transitions — the terminal watermark advances
  1016. * past every transition visible now (and past the clock, so a
  1017. * same-second park/consume pair cannot re-fire) — so the
  1018. * window starts fresh for the next park. Plan 15 C3: the
  1019. * watermark replaced the goal_events id cursor.
  1020. */
  1021. public async string take_resume_notice(int64 goal_id) throws Error {
  1022. var goal = yield require_goal(goal_id);
  1023. DateTime? since = null;
  1024. var run = yield goal_run_task(goal_id);
  1025. if (run != null) {
  1026. since = ((!)run).suspended_at;
  1027. }
  1028. // Goal 4 task 1 (Force Wake): a human-pressed wake
  1029. // leads with the explicit line, then the standard batched
  1030. // report. The flag is CONSUMED by this notice (one wake
  1031. // per press) — cleared below with the watermark write.
  1032. bool woken = goal.force_wake;
  1033. var report = new StringBuilder();
  1034. if (woken) {
  1035. report.append("The user explicitly force-woke this goal's supervision — you were parked with no pending wake.\n\n");
  1036. }
  1037. report.append("Goal update — resuming your parked session:\n\n");
  1038. report.append("## Task statuses\n");
  1039. var finished = new Vector<TaskEntity>();
  1040. foreach (var task in yield db.query<TaskEntity>()
  1041. .where(expr("goal_id == $0 && kind == $1",
  1042. elem<int64?>(goal_id), elem(TASK_KIND_TASK)))
  1043. .order_by(expr("id"))
  1044. .materialise_async()) {
  1045. if (task.status == STATUS_DONE) {
  1046. report.append_printf("- #%s %s — done: %s\n", task.id.to_string(),
  1047. task.title, notice_lead(task));
  1048. if (since == null || task.completed_at == null
  1049. || ((!)task.completed_at).compare((!)since) > 0) {
  1050. finished.add(task);
  1051. }
  1052. } else if (task.status == STATUS_FAILED) {
  1053. report.append_printf("- #%s %s — failed: %s\n", task.id.to_string(),
  1054. task.title, notice_lead(task));
  1055. } else if (task.status == STATUS_DRAFT) {
  1056. // Goal 4 task 1: the goal-2 stall class — a draft
  1057. // that was never enqueued produces no transitions,
  1058. // so a force-woke agent must see it named as such.
  1059. report.append_printf("- #%s %s — draft (NOT enqueued — run `saddle task enqueue %s` or delete it)\n",
  1060. task.id.to_string(), task.title, task.id.to_string());
  1061. } else {
  1062. report.append_printf("- #%s %s — %s\n", task.id.to_string(), task.title, task.status);
  1063. }
  1064. }
  1065. if (finished.length > 0) {
  1066. report.append("\n## New completion reports\n");
  1067. foreach (var task in finished) {
  1068. report.append_printf("\n### #%s %s\n%s\n", task.id.to_string(), task.title,
  1069. task.summary ?? "no summary reported");
  1070. }
  1071. }
  1072. report.append("\nSupervise your tasks: merge completed work into the goal branch,")
  1073. .append(" prune worktrees, create or enqueue follow-ups as needed, then")
  1074. .append(" `saddle suspend` again when you are parked.\n");
  1075. DateTime consumed = new DateTime.now_local();
  1076. foreach (var task in yield db.query<TaskEntity>()
  1077. .where(expr("goal_id == $0", elem<int64?>(goal_id)))
  1078. .order_by(expr("id"))
  1079. .materialise_async()) {
  1080. if (task.completed_at != null
  1081. && ((!)task.completed_at).compare(consumed) > 0) {
  1082. consumed = (!)task.completed_at;
  1083. }
  1084. }
  1085. ((!)goal).last_terminal_watermark = consumed;
  1086. ((!)goal).force_wake = false;
  1087. yield db.update_async<Goal>(((!)goal));
  1088. return report.str;
  1089. }
  1090. /**
  1091. * A task's terminal summary as a ONE-LINE notice entry (the
  1092. * first line only — plan 16 S3); "no summary reported" when
  1093. * the summary holds nothing.
  1094. */
  1095. private static string notice_lead(TaskEntity task) {
  1096. string summary = (task.summary ?? "").strip();
  1097. if (summary.length == 0) {
  1098. return "no summary reported";
  1099. }
  1100. int cut = summary.index_of("\n");
  1101. string lead = cut >= 0 ? summary.substring(0, cut) : summary;
  1102. return lead.strip();
  1103. }
  1104. // ------------------------------------------------------------------
  1105. // Completion gate (plan 15 C4)
  1106. // ------------------------------------------------------------------
  1107. /**
  1108. * The hard human gate on completion acceptance (plan 15 C4,
  1109. * F9): the accept REFUSES while any daemon-reported goal
  1110. * branch is unpushed — push-after-each-merge is enforced at
  1111. * the operator's confirmation, read from the LIVE per-saddle
  1112. * probe flags (never a stored snapshot) through the ONE
  1113. * {@link parse_goal_branches} parser (the same rows the goal
  1114. * page's chips render). Unknown flags (an older daemon that
  1115. * never reported goal_branches) leave the daemon-side push
  1116. * rule as the enforcing layer.
  1117. */
  1118. private async void refuse_unpushed_origin(Goal goal, TaskEntity run) throws Error {
  1119. int64 saddle_id = 0;
  1120. foreach (var session in yield db.query<Session>()
  1121. .where(expr("task_id == $0", elem<int64?>(run.id)))
  1122. .order_by_desc(expr("id"))
  1123. .materialise_async()) {
  1124. if (session.ended_at == null) {
  1125. saddle_id = session.saddle_id;
  1126. break;
  1127. }
  1128. if (saddle_id == 0) {
  1129. saddle_id = session.saddle_id;
  1130. }
  1131. }
  1132. if (saddle_id == 0) {
  1133. return;
  1134. }
  1135. string raw = polls.goal_branches_for(saddle_id);
  1136. if (raw.length == 0) {
  1137. return;
  1138. }
  1139. var unpushed = new Vector<string>();
  1140. foreach (var row in parse_goal_branches(raw)) {
  1141. if (!row.pushed) {
  1142. unpushed.add(row.label());
  1143. }
  1144. }
  1145. if (unpushed.length > 0) {
  1146. var labels = new StringBuilder();
  1147. foreach (var label in unpushed.to_array()) {
  1148. if (labels.len > 0) {
  1149. labels.append(", ");
  1150. }
  1151. labels.append(label);
  1152. }
  1153. throw new Error(Quark.from_string("clanker-saddle"), 88,
  1154. @"Origin is behind — push the goal branch(es) before accepting ($(labels.str)). The accept refuses until the daemon reports them pushed.");
  1155. }
  1156. }
  1157. // ------------------------------------------------------------------
  1158. // Helpers
  1159. // ------------------------------------------------------------------
  1160. /**
  1161. * One daemon-reported goal-branch push flag: a row of the
  1162. * /status `goal_branches` array (the ONLY shape the daemon
  1163. * emits — one `{repo, branch, pushed}` row per involved
  1164. * repo).
  1165. */
  1166. public class GoalBranchPush : Object {
  1167. public string repo { get; set; default = ""; }
  1168. public string branch { get; set; default = ""; }
  1169. public bool pushed { get; set; }
  1170. /** The display/gate label: "repo: branch" when both are known. */
  1171. public string label() {
  1172. return repo.length > 0 && branch.length > 0
  1173. ? @"$(repo): $(branch)" : (branch.length > 0 ? branch : repo);
  1174. }
  1175. }
  1176. /**
  1177. * The ONE parser of the daemon's goal_branches report (plan
  1178. * 15 C4): an array of `{repo, branch, pushed}` rows, read by
  1179. * BOTH consumers — the completion-accept gate
  1180. * ({@link refuse_unpushed_origin}) and the goal page's push
  1181. * chips — so the two can never diverge on the same payload
  1182. * again. `pushed` is the daemon's boolean (an integer counts
  1183. * as non-zero; anything else reads unpushed). A row missing
  1184. * its branch labels by its repo alone; label-less rows drop.
  1185. * Unparseable input parses as no rows.
  1186. */
  1187. public static Vector<GoalBranchPush> parse_goal_branches(string raw) {
  1188. var rows = new Vector<GoalBranchPush>();
  1189. try {
  1190. var node = Json.from_string(raw);
  1191. if (node.get_node_type() != Json.NodeType.ARRAY) {
  1192. return rows;
  1193. }
  1194. foreach (var element in node.get_array().get_elements()) {
  1195. if (element.get_node_type() != Json.NodeType.OBJECT) {
  1196. continue;
  1197. }
  1198. var obj = element.get_object();
  1199. var row = new GoalBranchPush();
  1200. row.repo = obj.has_member("repo")
  1201. ? (obj.get_string_member("repo") ?? "") : "";
  1202. if (obj.has_member("branch")) {
  1203. row.branch = obj.get_string_member("branch") ?? "";
  1204. } else if (row.repo.length > 0) {
  1205. row.branch = row.repo;
  1206. row.repo = "";
  1207. }
  1208. if (obj.has_member("pushed")) {
  1209. var pushed = obj.get_member("pushed");
  1210. if (pushed.get_node_type() == Json.NodeType.VALUE) {
  1211. var value = pushed.get_value();
  1212. if (value.type() == typeof(bool)) {
  1213. row.pushed = ((!)value).get_boolean();
  1214. } else if (value.type() == typeof(int64)) {
  1215. row.pushed = ((!)value).get_int64() != 0;
  1216. }
  1217. }
  1218. }
  1219. if (row.label().length > 0) {
  1220. rows.add(row);
  1221. }
  1222. }
  1223. } catch (Error e) {
  1224. // Unparseable push state reads as no rows.
  1225. }
  1226. return rows;
  1227. }
  1228. private async Goal require_goal(int64 goal_id) throws Error {
  1229. var goal = yield get_goal(goal_id);
  1230. if (goal == null) {
  1231. throw new Error(Quark.from_string("clanker-saddle"), 74,
  1232. @"No goal exists with id $(goal_id)");
  1233. }
  1234. return (!)goal;
  1235. }
  1236. }
  1237. }