using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using InvercargillSql; using InvercargillSql.Orm; using Inversion; using ClankerSaddle.Data; namespace ClankerSaddle.Services { /** Valid goal statuses (the `goals.status` values). */ public const string GOAL_DRAFT = "draft"; public const string GOAL_PLANNING = "planning"; public const string GOAL_PLAN_REVIEW = "plan_review"; public const string GOAL_ACTIVE = "active"; public const string GOAL_PAUSED = "paused"; public const string GOAL_COMPLETION_REVIEW = "completion_review"; public const string GOAL_COMPLETE = "complete"; public const string GOAL_FAILED = "failed"; public const string GOAL_CANCELLED = "cancelled"; /** * Whether a run-task block row is THE completion review. The review is * raised from two paths — the web's completion raise (kind * {@link BLOCK_COMPLETION}) and the daemon's done-park (kind * {@link BLOCK_REVIEW}) — which adopt each other so only one row * ever exists; every completion-verdict/narrative surface matches * both kinds. */ private static bool is_completion_row(TaskBlock block) { return block.block_type == BLOCK_COMPLETION || block.block_type == BLOCK_REVIEW; } /** Task kinds (the `tasks.kind` wire values). */ public const string TASK_KIND_TASK = "task"; public const string TASK_KIND_GOAL_RUN = "goal_run"; /** Task creators: enqueueing is creator-only. */ public const string CREATOR_HUMAN = "human"; public const string CREATOR_GOAL = "goal"; /** * The goal-run internal task's mode: the goal agent orchestrates * (prompt-level mode shaping is the daemon's job). */ public const string MODE_ORCHESTRATE = "orchestrate"; /** Valid work modes for human/goal-created tasks. */ public const string[] TASK_MODES = { "implement", "investigate", "review", "test" }; /** Whether `mode` is one of {@link TASK_MODES} (the create/edit validation). */ public bool is_valid_mode(string mode) { foreach (string candidate in TASK_MODES) { if (mode == candidate) { return true; } } return false; } /** The priority every goal-run internal task dispatches at. */ public const int64 GOAL_RUN_PRIORITY = 3; /** * Goal storage and lifecycle (plan 12; blocks consolidated plan * 15 C1): creation with branch-name uniqueness validation, the * draft → planning → plan review → active → completion review → * complete/failed/cancelled flow (with paused freezing * supervision in between), the goal agent's run task, plan/ * completion review flows with revisions, goal comments, the * cancel cascade and the event-driven resume machinery (task- * terminal events coalesce ~10 s into one goal-agent resume * carrying the batched status report). The goal agent's BLOCKS * ride the run task's own `task_blocks` rows — one row per * question, answered in place through {@link answer_block}. */ public class GoalService : Object { private OrmSession db = inject(); private StatePulse pulse = inject(); private PushService push = inject(); private TaskService tasks = inject(); private SaddlePollState polls = inject(); /** * SADDLE_GOAL_RESUME_S (default 10): the coalescing window — * a parked goal agent resumes once the OLDEST unconsumed task * event is at least this old, so bursts collapse into one * resume. */ public static uint goal_resume_coalesce_seconds() { var raw = Environment.get_variable("SADDLE_GOAL_RESUME_S") ?? ""; int64 parsed = int64.parse(raw); return parsed > 0 ? (uint) parsed : 10; } // ------------------------------------------------------------------ // Creation and links // ------------------------------------------------------------------ /** * Creates a goal in draft. The branch name is required and * uniqueness-validated: no other live (non-cancelled, * non-complete, non-failed) goal may use the same branch name * while sharing any involved project (primary or link) — one * branch of that name per involved repo. `base_branch` defaults * to the project's default branch. */ public async Goal create_goal(int64 project_id, string title, string brief, string branch_name, string? base_branch, int64 priority, string creator = CREATOR_HUMAN) throws Error { var project = yield db.query() .where(expr("id == $0", elem(project_id))) .first_async(); if (project == null) { throw new Error(Quark.from_string("clanker-saddle"), 60, @"No project exists with id $(project_id)"); } string trimmed_branch = branch_name.strip(); if (title.strip().length == 0) { throw new Error(Quark.from_string("clanker-saddle"), 61, "A goal title is required"); } if (brief.strip().length == 0) { throw new Error(Quark.from_string("clanker-saddle"), 62, "An initial brief is required"); } if (trimmed_branch.length == 0) { throw new Error(Quark.from_string("clanker-saddle"), 63, "A goal branch name is required"); } foreach (var other in yield db.query().materialise_async()) { if (other.branch_name != trimmed_branch || other.status == GOAL_CANCELLED || other.status == GOAL_COMPLETE || other.status == GOAL_FAILED) { continue; } if ((yield involved_project_ids(other)).contains(project_id.to_string())) { throw new Error(Quark.from_string("clanker-saddle"), 64, @"Branch name '$(trimmed_branch)' is already used by a live goal of an involved project (#$(other.id) $(other.title)) — pick another name"); } } var goal = new Goal() { project_id = project_id, title = title.strip(), brief = brief, branch_name = trimmed_branch, base_branch = base_branch != null && base_branch.strip().length > 0 ? base_branch.strip() : ((!)project).default_branch, priority = priority.clamp(1, 9), creator = creator, created_at = new DateTime.now_local() }; yield db.insert_async(goal); yield pulse.bump(0, "goal created"); return goal; } /** Loads the goal identified by `id`, or null. */ public async Goal? get_goal(int64 id) throws Error { return yield db.query() .where(expr("id == $0", elem(id))) .first_async(); } /** Every goal, newest first. */ public async ImmutableLot list_goals() throws Error { return yield db.query() .order_by_desc(expr("id")) .materialise_async(); } /** * The primary + linked project ids of `goal` (stringified — * the Invercargill collections cannot hash value types). */ public async Invercargill.DataStructures.HashSet involved_project_ids(Goal goal) throws Error { var ids = new Invercargill.DataStructures.HashSet(); ids.add(goal.project_id.to_string()); foreach (var link in yield list_links(goal.id)) { ids.add(link.project_id.to_string()); } return ids; } /** Adds a project link to the goal; duplicates are refused. */ public async void add_link(int64 goal_id, int64 project_id, string relationship) throws Error { if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) { throw new Error(Quark.from_string("clanker-saddle"), 65, @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'"); } var goal = yield get_goal(goal_id); if (goal == null) { throw new Error(Quark.from_string("clanker-saddle"), 66, @"No goal exists with id $(goal_id)"); } if (project_id == ((!)goal).project_id) { throw new Error(Quark.from_string("clanker-saddle"), 67, "The primary project is implicitly linked"); } var existing = yield db.query() .where(expr("goal_id == $0 && project_id == $1", elem(goal_id), elem(project_id))) .materialise_async(); if (existing.length > 0) { throw new Error(Quark.from_string("clanker-saddle"), 68, "That project is already linked to the goal"); } yield db.insert_async(new GoalLink() { goal_id = goal_id, project_id = project_id, relationship = relationship }); yield pulse.bump(0, "goal link added"); } /** Removes the goal link identified by `id`. */ public async void remove_link(int64 link_id) throws Error { var link = yield db.query() .where(expr("id == $0", elem(link_id))) .first_async(); if (link != null) { yield db.delete_async((!)link); yield pulse.bump(0, "goal link removed"); } } /** The goal's links, newest first (the list-page consistency * sweep). The RULE/effective-context consumers only iterate, * so the flip is invisible to them. */ public async ImmutableLot list_links(int64 goal_id) throws Error { return yield db.query() .where(expr("goal_id == $0", elem(goal_id))) .order_by_desc(expr("id")) .materialise_async(); } // ------------------------------------------------------------------ // Lifecycle // ------------------------------------------------------------------ /** * The goal's goal-run internal task (kind 'goal_run'), or null * before the goal starts. */ public async TaskEntity? goal_run_task(int64 goal_id) throws Error { var rows = yield db.query() .where(expr("goal_id == $0 && kind == $1", elem(goal_id), elem(TASK_KIND_GOAL_RUN))) .order_by(expr("id")) .materialise_async(); TaskEntity? first = null; foreach (var row in rows) { first = row; break; } return first; } /** * Starts a draft goal: the brief locks, the goal moves to * 'planning' and the goal agent's internal run task is created * (kind 'goal_run', mode 'orchestrate', priority 3, creator * 'goal') and queued — the dispatcher hands it to a saddle and * the agent begins investigating. */ public async Goal start_goal(int64 goal_id) throws Error { var goal = yield require_goal(goal_id); if (((!)goal).status != GOAL_DRAFT) { throw new Error(Quark.from_string("clanker-saddle"), 69, "Only draft goals can be started"); } ((!)goal).status = GOAL_PLANNING; ((!)goal).started_at = new DateTime.now_local(); yield db.update_async((!)goal); var run = yield tasks.create_task(((!)goal).project_id, @"Goal agent — $(((!)goal).title)", ((!)goal).brief, GOAL_RUN_PRIORITY, null, ((!)goal).base_branch, STATUS_QUEUED, TASK_KIND_GOAL_RUN, MODE_ORCHESTRATE, CREATOR_GOAL, ((!)goal).id, 0); yield pulse.bump(run.id, "goal started"); return goal; } /** * Edits a DRAFT goal's title, brief and priority (the brief-card * editor, plan QoL task 12): a draft is the only phase with an * edit surface — starting locks the brief (the run task's * mandate is seeded from it) and every later phase reads it as * history. Each argument is optional (null = leave unchanged), * mirroring {@link TaskService.edit_task}. */ public async Goal edit_draft_goal(int64 goal_id, string? title, string? brief, int64? priority) throws Error { var goal = yield require_goal(goal_id); if (((!)goal).status != GOAL_DRAFT) { throw new Error(Quark.from_string("clanker-saddle"), 72, @"Only draft goals can be edited — $(((!)goal).status) goals have a locked brief"); } if (title != null && ((!)title).strip().length == 0) { throw new Error(Quark.from_string("clanker-saddle"), 73, "A goal title is required"); } if (brief != null && ((!)brief).strip().length == 0) { throw new Error(Quark.from_string("clanker-saddle"), 74, "A goal brief is required"); } if (title != null) { ((!)goal).title = ((!)title).strip(); } if (brief != null) { ((!)goal).brief = (!)brief; } if (priority != null) { ((!)goal).priority = ((!)priority).clamp(1, 9); } yield db.update_async((!)goal); return goal; } /** * Pauses an active goal: supervision freezes — no goal-agent * resumes are scheduled while paused; task events keep * queueing and coalesce into the flush on resume. */ public async Goal pause_goal(int64 goal_id) throws Error { var goal = yield require_goal(goal_id); if (((!)goal).status != GOAL_ACTIVE) { throw new Error(Quark.from_string("clanker-saddle"), 70, "Only active goals can be paused"); } ((!)goal).status = GOAL_PAUSED; ((!)goal).paused_at = new DateTime.now_local(); yield db.update_async((!)goal); yield pulse.bump(0, "goal paused"); return goal; } /** * Resumes a paused goal: supervision restarts and any events * that queued while paused coalesce into the next resume (the * oldest pending event usually already satisfies the window). */ public async Goal resume_goal(int64 goal_id) throws Error { var goal = yield require_goal(goal_id); if (((!)goal).status != GOAL_PAUSED) { throw new Error(Quark.from_string("clanker-saddle"), 71, "Only paused goals can be resumed"); } ((!)goal).status = GOAL_ACTIVE; ((!)goal).paused_at = null; yield db.update_async((!)goal); yield pulse.bump(0, "goal resumed"); return goal; } /** * Goal 4 task 1 (Force Wake): the human-triggered resume — * sets {@link Goal.force_wake} so the parked run becomes * offerable immediately (resume_due skips the coalescing * window and the terminal-transition requirement) and the * resume notice leads with the explicit wake line. One wake * per press: take_resume_notice clears the flag. */ public async Goal force_wake_goal(int64 goal_id) throws Error { var goal = yield require_goal(goal_id); if (((!)goal).status != GOAL_ACTIVE) { throw new Error(Quark.from_string("clanker-saddle"), 72, "Only active goals can be force-woke (paused goals freeze supervision — resume the goal instead)"); } var run = yield goal_run_task(goal_id); if (run == null || ((!)run).status != STATUS_SUSPENDED) { throw new Error(Quark.from_string("clanker-saddle"), 73, "The goal agent is not parked — there is nothing to force-woke"); } ((!)goal).force_wake = true; yield db.update_async((!)goal); yield pulse.bump(0, "goal force-woke"); return goal; } /** * Cancels a goal: the cascade closes every one of its tasks — * non-terminal ones are cancelled (the running goal agent * included), draft ones deleted (they never ran). Terminal * goals refuse the action. Live daemons are NOT aborted here — * {@link LifecycleService.cancel_goal} (which reaches * SaddleService without a dependency cycle) aborts them * first. */ public async void cancel_goal(int64 goal_id) throws Error { var goal = yield require_goal(goal_id); string status = ((!)goal).status; if (status == GOAL_COMPLETE || status == GOAL_FAILED || status == GOAL_CANCELLED) { throw new Error(Quark.from_string("clanker-saddle"), 72, "The goal is already finished"); } ((!)goal).status = GOAL_CANCELLED; ((!)goal).completed_at = new DateTime.now_local(); yield db.update_async((!)goal); yield cascade_goal_tasks(goal_id); yield pulse.bump(0, "goal cancelled"); } /** * Revives a failed goal for another supervision attempt (plan * 14 B1; plan 16 R2 moved the RetryGoalRunAction's direct * Goal writes here): status back to 'active', the terminal * stamp withdrawn. The caller ({@link * LifecycleService.retry_goal_run}) has already requeued the * run task and aborts its daemon first; the action guards the * failed-goal/failed-run preconditions upstream. Returns the * revived row. */ public async Goal retry_goal(int64 goal_id) throws Error { var goal = yield require_goal(goal_id); ((!)goal).status = GOAL_ACTIVE; ((!)goal).completed_at = null; yield db.update_async((!)goal); return goal; } /** * The cancel/fail cascade the terminal goal paths share: every * non-terminal task under the goal closes — drafts are deleted * (they never ran; plan 13 B3 — no orphan stays dispatchable), * everything else is cancelled (the running goal agent * included). Terminal rows keep their first outcome. */ private async void cascade_goal_tasks(int64 goal_id) throws Error { foreach (var task in yield db.query() .where(expr("goal_id == $0", elem(goal_id))) .order_by(expr("id")) .materialise_async()) { if (task.status == STATUS_DONE || task.status == STATUS_FAILED) { continue; } if (task.status == STATUS_DRAFT) { yield tasks.delete_task_quietly(task.id); } else { yield tasks.cancel_task(task.id); } } } // ------------------------------------------------------------------ // The goal agent's pushes: plan, completion, failure // ------------------------------------------------------------------ /** * The goal agent submitted (or revised) its plan: ONE * review-kind block of type 'plan' on the goal-run task * carries the markdown, the goal moves to 'plan_review' and * the run parks on it (the verdict resume rides the answered * block). The caller has already parked the task via the * results state. Plan 15 C1: the block lives on the run task * — there is no second row to keep in sync. */ public async TaskBlock? submit_plan(int64 goal_id, string markdown) throws Error { var goal = yield require_goal(goal_id); // Idempotent on the pending submission: a redelivered push // (outbox retry, the entry + state pair of one `saddle // plan`) must not stack duplicate revisions — a NEW row // only appears once the previous one was answered // (rejected → the agent revises). TaskBlock? raised = yield raise_run_block(((!)goal), "Plan submitted for review", BLOCK_PLAN, null, markdown, true); if (raised == null || ((!)raised).answer != null) { // No pending submission: a fresh revision supersedes // the answered history. raised = yield raise_run_block(((!)goal), "Plan submitted for review", BLOCK_PLAN, null, markdown, false); } if (raised == null) { return null; } ((!)goal).status = GOAL_PLAN_REVIEW; yield db.update_async(((!)goal)); return raised; } /** * Records a goal-agent question/RFC as ONE block on the * goal-run task and parks it there. Idempotent on the pending * question: a redelivered blocked push (outbox retry, the * park-unwinding round-end push) must not stack duplicates. */ public async TaskBlock? raise_agent_block(int64 goal_id, string question, string block_type, string[]? options = null, string? body = null) throws Error { var goal = yield require_goal(goal_id); TaskBlock? raised = yield raise_run_block(((!)goal), question, block_type.length > 0 ? block_type : BLOCK_QUESTION, options, body, false); if (raised == null) { return null; } return raised; } /** * The goal agent submitted its completion report (`saddle * done` in goal context): the goal moves to * 'completion_review' and the report lives ONLY in the pending * completion-kind block on the goal-run task (canonical-on- * accept, plan 16 S1: `goals.completion_report` is written * ONLY by the accept verdict — reject/revise leaves the goal * row clean). The run parks on the block. The daemon enforces * the branch-push rule before letting `done` through. * Idempotent on terminal goals (plan 13 B2): a redelivered * done push must not resurrect a finished goal. */ public async TaskBlock? submit_completion(int64 goal_id, string report) throws Error { var goal = yield require_goal(goal_id); if (((!)goal).status == GOAL_COMPLETE || ((!)goal).status == GOAL_FAILED || ((!)goal).status == GOAL_CANCELLED) { // Terminal stands: the pending-report idempotency below // cannot apply (the verdict already landed). throw new Error(Quark.from_string("clanker-saddle"), 85, @"The goal is already $(((!)goal).status) — its completion review is closed"); } // Idempotent on the pending report (see submit_plan): the // raise adopts an unanswered review-family row (the daemon's // done-park "review" block IS this review) — one row, // whichever path landed first. TaskBlock? raised = yield raise_run_block(((!)goal), "Goal completion report", BLOCK_COMPLETION, null, report, true); if (raised == null) { return null; } ((!)goal).status = GOAL_COMPLETION_REVIEW; yield db.update_async(((!)goal)); return raised; } /** * Fails the goal (`saddle failure` in goal context): status * 'failed', the report stored, the goal-run task failed (its * session closes and saddle frees), every non-terminal task * cascades exactly like a cancellation (plan 13 B3: drafts * are deleted — no orphan stays dispatchable — queued/working/ * blocked tasks are cancelled) and the narrative records the * cascade. Every user is notified. Idempotent on terminal * goals (plan 13 B2): a replayed failure push — redelivered * OR a fresh entry id from the honest burst — re-runs no * bookkeeping, so exactly one goal-failed event, one cascade, * one notification, ever. */ public async void fail_goal(int64 goal_id, string? report) throws Error { var goal = yield require_goal(goal_id); if (((!)goal).status == GOAL_COMPLETE || ((!)goal).status == GOAL_FAILED || ((!)goal).status == GOAL_CANCELLED) { return; } ((!)goal).status = GOAL_FAILED; ((!)goal).completed_at = new DateTime.now_local(); ((!)goal).completion_report = report ?? ((!)goal).completion_report; yield db.update_async(((!)goal)); var run = yield goal_run_task(goal_id); if (run != null && ((!)run).status != STATUS_DONE && ((!)run).status != STATUS_FAILED) { yield tasks.fail_task(((!)run).id, report != null && report.length > 0 ? report : "The goal agent failed the goal"); } // Plan 13 B3: the failure cascade — the cancel-cascade // semantics (the run task just failed above, so the loop's // terminal skip covers it). yield cascade_goal_tasks(goal_id); push.notify_global(PushService.goal_terminal_notification( ((!)goal).title, goal_id, false)); yield pulse.bump(0, "goal failed"); } /** * Answers a goal's block (plan 15 C1: THE single answer path * every surface calls — the goal page's verdict forms, the * task-page deep link on a goal-run block, the dashboard's * link-through destination). The block is the goal-run task's * OWN row: answering it in place settles everything an * observer can read before the first pulse fires, so the * completion-review-never-leaves-the-dashboard bug class is * structurally impossible — no dual rows exist to fall out of * sync. The parked-resume machinery then wakes the agent with * the verdict as its resume_notice. Plan and completion * blocks apply their goal-state transitions: * * - plan + {@link NOTICE_PLAN_APPROVED} → the submission * becomes goal.plan, status 'active'; * - plan + feedback → status 'planning' (the agent revises); * - completion + {@link NOTICE_REVIEW_APPROVED} → the goal * completes, the winning block's body becomes * goal.completion_report (plan 16 S1: the ONLY writer) and * the goal-run task closes done; * - completion + feedback → status 'active' (the agent keeps * working; the goal row keeps no report). */ public async void answer_block(int64 block_id, string answer, string? answered_by = null) throws Error { var block = yield db.query() .where(expr("id == $0", elem(block_id))) .first_async(); if (block == null) { throw new Error(Quark.from_string("clanker-saddle"), 73, @"No goal block exists with id $(block_id)"); } if (((!)block).answer != null) { return; } var task = yield db.query() .where(expr("id == $0", elem(((!)block).task_id))) .first_async(); if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN || ((!)task).goal_id <= 0) { // Not a goal-run block: the plain task answer path. yield tasks.answer_block(block_id, answer, answered_by); return; } var goal = yield get_goal(((!)task).goal_id); if (goal == null) { return; } // The verdict's events record FIRST, then the row settles // (below) before the first observer pulse fires — an // event-less resume is a lost context_update, and an // unanswered row past the pulse is exactly the stale-pile // shape plan 14 B3 defended against with mirror sweeps. // With one row there is nothing left to sweep. if (((!)block).block_type == BLOCK_PLAN) { if (answer == NOTICE_PLAN_APPROVED) { if (((!)block).body != null && ((!)block).body.length > 0) { ((!)goal).plan = ((!)block).body; } ((!)goal).status = GOAL_ACTIVE; } else { ((!)goal).status = GOAL_PLANNING; } } else if (is_completion_row(((!)block))) { if (answer == NOTICE_REVIEW_APPROVED) { // Plan 15 C4: the operator-confirmed hard gate — // refuse while origin is behind (live flags only). yield refuse_unpushed_origin(((!)goal), (!)task); // Plan 16 S1 (canonical-on-accept): the report // lands on the goal ONLY here — the winning // block's body becomes completion_report, so the // terminal card reads a field that provably exists // only post-acceptance. if (((!)block).body != null && ((!)block).body.length > 0) { ((!)goal).completion_report = ((!)block).body; } ((!)goal).status = GOAL_COMPLETE; ((!)goal).completed_at = new DateTime.now_local(); } else { // Reject/revise: the agent keeps working and the // goal row stays clean (the report died with the // rejected block — submit_completion never wrote // it there). ((!)goal).status = GOAL_ACTIVE; } } yield db.update_async(((!)goal)); yield tasks.answer_block(block_id, answer, answered_by); if (is_completion_row(((!)block)) && answer == NOTICE_REVIEW_APPROVED) { // Approving the completion closes the goal agent's // own task: the run is finished, its saddle frees. yield tasks.complete_task(((!)task).id, ((!)goal).completion_report, OUTCOME_DONE); push.notify_global(PushService.goal_terminal_notification( ((!)goal).title, ((!)goal).id, true)); } yield pulse.bump(0, "goal block answered"); } /** * Raises the goal's ONE block on the goal-run task (plan 15 * C1): the parked-resume machinery (answered blocks ride the * resuming allocation) works untouched, and the goal-run task * parks as 'blocked' waiting for the human verdict. The raise * is QUIET on the task side — the goal-level notification * (goal title, /goals/ URL) fires here, once, and must * not double-notify with the run task's title. * * Deduplication (`dedupe_on_type`): a pending row of the same * question — or, for the review kinds whose question text is * fixed ("Plan submitted for review", "Goal completion * report"), of the same TYPE — means the push is a * redelivery and the existing row is returned unwritten. A * missing or terminal run task raises nothing (null). */ private async TaskBlock? raise_run_block(Goal goal, string question, string block_type, string[]? options, string? body, bool dedupe_on_type) throws Error { var run = yield goal_run_task(goal.id); if (run == null || ((!)run).status == STATUS_DONE || ((!)run).status == STATUS_FAILED) { warning("[ClankerSaddle] Goal %s block '%s' raised without a live run task", goal.id.to_string(), question); return null; } // Review-family cross-dedupe: the completion review is raised // from two paths (the web's completion kind, the daemon's // done-park "review" kind, different question strings) — an // unanswered row of EITHER kind IS the pending review; adopt // it rather than raising a twin. bool review_family = block_type == BLOCK_COMPLETION || block_type == BLOCK_REVIEW; foreach (var existing in yield tasks.list_blocks(((!)run).id)) { if (review_family && existing.answer == null && (existing.block_type == BLOCK_COMPLETION || existing.block_type == BLOCK_REVIEW)) { return existing; } bool same = dedupe_on_type ? existing.block_type == block_type : existing.question == question; if (same && existing.answer == null) { return existing; } } var raised = yield tasks.add_block(((!)run).id, question, block_type, options, body, true); yield pulse.bump(0, "goal block raised"); push.notify_global(PushService.goal_block_notification( goal.title, goal.id, block_type, question)); return raised; } // ------------------------------------------------------------------ // Comments and events // ------------------------------------------------------------------ /** * Appends a HUMAN comment to the goal's thread (plan 15 C2): * the goal-run task's own `task_comments` row — injection * rides the existing delivery paths (the pending-comment * turn, the resuming assignment's embedded comments) straight * into the agent's next round. The goal agent's own comments * land on the same thread via its entry pushes. Refused * before the run exists (a draft goal has no thread). */ public async TaskComment? add_human_comment(int64 goal_id, string author, string body, string? quote = null) throws Error { var run = yield goal_run_task(goal_id); if (run == null) { throw new Error(Quark.from_string("clanker-saddle"), 87, "The goal agent has not started yet — comments open with its run"); } return yield tasks.add_comment(((!)run).id, author, body, quote); } /** * The goal's comments (plan 15 C2): the goal-run task's * `task_comments` rows, newest first — the ONE thread. * Empty while the goal has no run task. */ public async ImmutableLot list_comments(int64 goal_id) throws Error { var run = yield goal_run_task(goal_id); if (run == null) { return new Vector().to_immutable_buffer(); } return yield tasks.list_comments(((!)run).id); } /** * A goal's blocks (plan 15 C1): the goal-run task's * `task_blocks` rows, newest first — the run task is the * single home of the goal agent's questions, plans and * completion reports. Empty while the goal has no run task. */ public async ImmutableLot list_blocks(int64 goal_id) throws Error { var run = yield goal_run_task(goal_id); if (run == null) { return new Vector().to_immutable_buffer(); } return yield tasks.list_blocks(((!)run).id); } /** * One block row by id (the verdict actions' kind lookup for * the server-owned wording, plan 16 S2/R4), or null. */ public async TaskBlock? get_block(int64 block_id) throws Error { return yield db.query() .where(expr("id == $0", elem(block_id))) .first_async(); } /** * One derived supervision-narrative row (plan 15 C3): the * read-time projection of a timestamped fact — a lifecycle * stamp, a run-task block or comment, a worker task's * created/terminal transition. `kind` keeps the old * goal_events vocabulary; system rows (kind "system") come * from the run thread's harness-authored comments. */ public class NarrativeRow : Object { public string kind { get; set; default = ""; } public string text { get; set; default = ""; } public int64 task_id { get; set; } public DateTime? at { get; set; default = null; } } /** * The supervision narrative (plan 15 C3): a DERIVED read-time * view — nothing is written on ingest any more. The rows come * from the goal's lifecycle stamps (created/started/paused/ * terminal), the goal-run task's blocks (plan submissions and * verdicts, questions/RFCs, the completion review) and its * comment thread (agent/user rows as footnote comments, * harness-authored rows as system events), and the goal's * tasks' created and terminal transitions. Timestamp-less * bookkeeping (task-enqueued, task-deleted, resumed) never * returns; the goal-failure CASCADE derives from the * cancelled tasks surviving under a failed goal. Newest * first. */ public async ImmutableLot narrative(int64 goal_id) throws Error { var goal = yield get_goal(goal_id); if (goal == null) { return new Vector().to_immutable_buffer(); } var rows = new Vector(); // Lifecycle stamps. if (((!)goal).created_at != null) { rows.add(new NarrativeRow() { kind = "created", at = ((!)goal).created_at, text = @"Goal created (branch $(((!)goal).branch_name))" }); } if (((!)goal).started_at != null) { rows.add(new NarrativeRow() { kind = "started", at = ((!)goal).started_at, text = "Goal started — the goal agent is planning" }); } if (((!)goal).paused_at != null && ((!)goal).status == GOAL_PAUSED) { rows.add(new NarrativeRow() { kind = "paused", at = ((!)goal).paused_at, text = "Goal paused — supervision frozen (terminal transitions queue)" }); } if (((!)goal).completed_at != null) { if (((!)goal).status == GOAL_CANCELLED) { rows.add(new NarrativeRow() { kind = "cancelled", at = ((!)goal).completed_at, text = "Goal cancelled — its tasks were cancelled with it" }); } else if (((!)goal).status == GOAL_FAILED) { rows.add(new NarrativeRow() { kind = "goal-failed", at = ((!)goal).completed_at, text = (((!)goal).completion_report ?? "").length > 0 ? (!)(((!)goal).completion_report) : "The goal agent failed the goal" }); } } // The run task's blocks: the plan/completion reviews with // their verdicts, the questions/RFCs. var run = yield goal_run_task(goal_id); if (run != null) { foreach (var block in yield tasks.list_blocks(((!)run).id)) { if (block.created_at != null) { if (block.block_type == BLOCK_PLAN) { rows.add(new NarrativeRow() { kind = "plan-submitted", at = block.created_at, text = @"Plan revision #$(block.id) submitted for review" }); } else if (is_completion_row(block)) { rows.add(new NarrativeRow() { kind = "completion-submitted", at = block.created_at, text = "Completion report submitted for review" }); } else { rows.add(new NarrativeRow() { kind = "block-raised", at = block.created_at, text = @"$(block.block_type) raised: $(block.question)" }); } } if (block.answered_at != null) { if (block.block_type == BLOCK_PLAN) { bool approved = block.answer == NOTICE_PLAN_APPROVED; rows.add(new NarrativeRow() { kind = approved ? "plan-approved" : "plan-rejected", at = block.answered_at, text = approved ? @"Plan revision #$(block.id) approved — the goal is active" : @"Plan revision #$(block.id) rejected with feedback — the agent revises" }); } else if (is_completion_row(block)) { bool approved = block.answer == NOTICE_REVIEW_APPROVED; rows.add(new NarrativeRow() { kind = approved ? "goal-approved" : "completion-rejected", at = block.answered_at, text = approved ? "Completion report approved — the goal is complete" : "Completion report rejected with feedback — the agent continues" }); } else { rows.add(new NarrativeRow() { kind = "block-answered", at = block.answered_at, text = @"$(block.block_type) answered" }); } } } // The ONE comment thread: agent/user rows as footnote // comments, harness-authored rows as system events. // Plan 16 S5 (U2): mechanical rows (restores, park // notices) never reach the user-facing narrative. foreach (var comment in yield tasks.list_comments(((!)run).id)) { if (comment.created_at == null) { continue; } if (comment.author == COMMENT_AUTHOR_SYSTEM && comment_is_mechanical(comment.body)) { continue; } rows.add(new NarrativeRow() { kind = comment.author == COMMENT_AUTHOR_SYSTEM ? "system" : "comment", at = comment.created_at, text = comment.body }); } } // The goal's tasks: created + terminal transitions. The // failure cascade derives from the cancelled tasks // surviving under a failed goal (deleted drafts left no // rows; the count is the surviving record). int64 cascaded = 0; foreach (var task in yield db.query() .where(expr("goal_id == $0 && kind == $1", elem(goal_id), elem(TASK_KIND_TASK))) .order_by(expr("id")) .materialise_async()) { if (task.created_at != null) { rows.add(new NarrativeRow() { kind = "task-created", at = task.created_at, task_id = task.id, text = @"Task #$(task.id) $(task.title) created by the goal agent ($(task.mode))" }); } if (task.completed_at != null) { if (task.status == STATUS_DONE) { rows.add(new NarrativeRow() { kind = "task-done", at = task.completed_at, task_id = task.id, text = @"Task #$(task.id) $(task.title) — completed$(summary_lead(task))" }); } else if (task.status == STATUS_FAILED) { rows.add(new NarrativeRow() { kind = "task-failed", at = task.completed_at, task_id = task.id, text = @"Task #$(task.id) $(task.title) — failed$(summary_lead(task))" }); if (((!)goal).status == GOAL_FAILED && (task.summary ?? "") == "Cancelled by user") { cascaded++; } } } } if (((!)goal).status == GOAL_FAILED && cascaded > 0) { rows.add(new NarrativeRow() { kind = "cascade", at = ((!)goal).completed_at, text = @"Goal failure cancelled $(cascaded) outstanding task(s)" }); } // Newest first, stable for equal timestamps (insertion // sort — the narratives are small). var array = rows.to_array(); for (var i = 1; i < array.length; i++) { var key = array[i]; var j = i - 1; while (j >= 0 && narrative_after(key, array[j])) { array[j + 1] = array[j]; j--; } array[j + 1] = key; } var newest_first = new Vector(); foreach (var row in array) { newest_first.add(row); } return newest_first.to_immutable_buffer(); } /** True when row `a` sorts after (newer than) row `b`. */ private static bool narrative_after(NarrativeRow a, NarrativeRow b) { if (a.at == null) { return false; } if (b.at == null) { return true; } return ((!)a.at).compare((!)b.at) > 0; } /** The task's summary as a narrative lead-in (": "). */ private static string summary_lead(TaskEntity task) { if (task.summary == null || ((!)task.summary).length == 0) { return ""; } string summary = (!)task.summary; int cut = summary.index_of("\n"); string lead = cut >= 0 ? summary.substring(0, cut) : summary; return @": $(lead)"; } // ------------------------------------------------------------------ // Event-driven resume (the parked goal agent) // ------------------------------------------------------------------ /** * True when the goal agent should be resumed: its run task is * parked (suspended), the goal is active (paused goals freeze * supervision — transitions newer than the watermark simply * QUEUE until the goal resumes; review states park on blocks * instead), and at least one owned task's TERMINAL transition * (completed_at newer than {@link Goal.last_terminal_watermark}) * has waited out the coalescing window — the OLDEST such * transition ages the window, so bursts collapse into one * resume. Plan 15 C3: the watermark replaced the goal_events * id cursor; the transitions read straight off the tasks. */ public async bool resume_due(TaskEntity run_task) throws Error { if (run_task.kind != TASK_KIND_GOAL_RUN || run_task.status != STATUS_SUSPENDED) { return false; } var goal = yield get_goal(run_task.goal_id); if (goal == null || ((!)goal).status != GOAL_ACTIVE) { return false; } // Goal 4 task 1 (Force Wake): the human pressed Force // wake — offerable immediately, skipping BOTH the // coalescing window and the terminal-transition // requirement (a parked agent with no pending wake — // the goal-2 stall class — still resumes). if (((!)goal).force_wake) { return true; } DateTime? watermark = ((!)goal).last_terminal_watermark; DateTime? oldest = null; // ONLY owned-task terminal transitions resume the parked // agent — the agent's own task-created narrative (and // human comments) must never wake it. foreach (var task in yield db.query() .where(expr("goal_id == $0 && kind == $1", elem(run_task.goal_id), elem(TASK_KIND_TASK))) .order_by(expr("id")) .materialise_async()) { if (task.completed_at == null) { continue; } if (watermark != null && ((!)task.completed_at).compare((!)watermark) <= 0) { continue; } if (oldest == null || ((!)task.completed_at).compare((!)oldest) < 0) { oldest = task.completed_at; } } if (oldest == null) { return false; } double age = new DateTime.now_local().difference((!)oldest) / 1000000.0; return age >= goal_resume_coalesce_seconds(); } /** * The batched markdown report riding a goal-agent resume (the * assignment's `resume_notice`): the current status of ALL of * the goal's tasks as ONE-LINE entries (status + the first * line of the terminal summary) plus the FULL completion * reports of the tasks that finished since the agent parked — * each new completion's body exactly once (plan 16 S3: no * statuses-section/reports-section double embedding; the * notice size is constant per event, not cumulative). Consumes * the pending transitions — the terminal watermark advances * past every transition visible now (and past the clock, so a * same-second park/consume pair cannot re-fire) — so the * window starts fresh for the next park. Plan 15 C3: the * watermark replaced the goal_events id cursor. */ public async string take_resume_notice(int64 goal_id) throws Error { var goal = yield require_goal(goal_id); DateTime? since = null; var run = yield goal_run_task(goal_id); if (run != null) { since = ((!)run).suspended_at; } // Goal 4 task 1 (Force Wake): a human-pressed wake // leads with the explicit line, then the standard batched // report. The flag is CONSUMED by this notice (one wake // per press) — cleared below with the watermark write. bool woken = goal.force_wake; var report = new StringBuilder(); if (woken) { report.append("The user explicitly force-woke this goal's supervision — you were parked with no pending wake.\n\n"); } report.append("Goal update — resuming your parked session:\n\n"); report.append("## Task statuses\n"); var finished = new Vector(); foreach (var task in yield db.query() .where(expr("goal_id == $0 && kind == $1", elem(goal_id), elem(TASK_KIND_TASK))) .order_by(expr("id")) .materialise_async()) { if (task.status == STATUS_DONE) { report.append_printf("- #%s %s — done: %s\n", task.id.to_string(), task.title, notice_lead(task)); if (since == null || task.completed_at == null || ((!)task.completed_at).compare((!)since) > 0) { finished.add(task); } } else if (task.status == STATUS_FAILED) { report.append_printf("- #%s %s — failed: %s\n", task.id.to_string(), task.title, notice_lead(task)); } else if (task.status == STATUS_DRAFT) { // Goal 4 task 1: the goal-2 stall class — a draft // that was never enqueued produces no transitions, // so a force-woke agent must see it named as such. report.append_printf("- #%s %s — draft (NOT enqueued — run `saddle task enqueue %s` or delete it)\n", task.id.to_string(), task.title, task.id.to_string()); } else { report.append_printf("- #%s %s — %s\n", task.id.to_string(), task.title, task.status); } } if (finished.length > 0) { report.append("\n## New completion reports\n"); foreach (var task in finished) { report.append_printf("\n### #%s %s\n%s\n", task.id.to_string(), task.title, task.summary ?? "no summary reported"); } } report.append("\nSupervise your tasks: merge completed work into the goal branch,") .append(" prune worktrees, create or enqueue follow-ups as needed, then") .append(" `saddle suspend` again when you are parked.\n"); DateTime consumed = new DateTime.now_local(); foreach (var task in yield db.query() .where(expr("goal_id == $0", elem(goal_id))) .order_by(expr("id")) .materialise_async()) { if (task.completed_at != null && ((!)task.completed_at).compare(consumed) > 0) { consumed = (!)task.completed_at; } } ((!)goal).last_terminal_watermark = consumed; ((!)goal).force_wake = false; yield db.update_async(((!)goal)); return report.str; } /** * A task's terminal summary as a ONE-LINE notice entry (the * first line only — plan 16 S3); "no summary reported" when * the summary holds nothing. */ private static string notice_lead(TaskEntity task) { string summary = (task.summary ?? "").strip(); if (summary.length == 0) { return "no summary reported"; } int cut = summary.index_of("\n"); string lead = cut >= 0 ? summary.substring(0, cut) : summary; return lead.strip(); } // ------------------------------------------------------------------ // Completion gate (plan 15 C4) // ------------------------------------------------------------------ /** * The hard human gate on completion acceptance (plan 15 C4, * F9): the accept REFUSES while any daemon-reported goal * branch is unpushed — push-after-each-merge is enforced at * the operator's confirmation, read from the LIVE per-saddle * probe flags (never a stored snapshot) through the ONE * {@link parse_goal_branches} parser (the same rows the goal * page's chips render). Unknown flags (an older daemon that * never reported goal_branches) leave the daemon-side push * rule as the enforcing layer. */ private async void refuse_unpushed_origin(Goal goal, TaskEntity run) throws Error { int64 saddle_id = 0; foreach (var session in yield db.query() .where(expr("task_id == $0", elem(run.id))) .order_by_desc(expr("id")) .materialise_async()) { if (session.ended_at == null) { saddle_id = session.saddle_id; break; } if (saddle_id == 0) { saddle_id = session.saddle_id; } } if (saddle_id == 0) { return; } string raw = polls.goal_branches_for(saddle_id); if (raw.length == 0) { return; } var unpushed = new Vector(); foreach (var row in parse_goal_branches(raw)) { if (!row.pushed) { unpushed.add(row.label()); } } if (unpushed.length > 0) { var labels = new StringBuilder(); foreach (var label in unpushed.to_array()) { if (labels.len > 0) { labels.append(", "); } labels.append(label); } throw new Error(Quark.from_string("clanker-saddle"), 88, @"Origin is behind — push the goal branch(es) before accepting ($(labels.str)). The accept refuses until the daemon reports them pushed."); } } // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ /** * One daemon-reported goal-branch push flag: a row of the * /status `goal_branches` array (the ONLY shape the daemon * emits — one `{repo, branch, pushed}` row per involved * repo). */ public class GoalBranchPush : Object { public string repo { get; set; default = ""; } public string branch { get; set; default = ""; } public bool pushed { get; set; } /** The display/gate label: "repo: branch" when both are known. */ public string label() { return repo.length > 0 && branch.length > 0 ? @"$(repo): $(branch)" : (branch.length > 0 ? branch : repo); } } /** * The ONE parser of the daemon's goal_branches report (plan * 15 C4): an array of `{repo, branch, pushed}` rows, read by * BOTH consumers — the completion-accept gate * ({@link refuse_unpushed_origin}) and the goal page's push * chips — so the two can never diverge on the same payload * again. `pushed` is the daemon's boolean (an integer counts * as non-zero; anything else reads unpushed). A row missing * its branch labels by its repo alone; label-less rows drop. * Unparseable input parses as no rows. */ public static Vector parse_goal_branches(string raw) { var rows = new Vector(); try { var node = Json.from_string(raw); if (node.get_node_type() != Json.NodeType.ARRAY) { return rows; } foreach (var element in node.get_array().get_elements()) { if (element.get_node_type() != Json.NodeType.OBJECT) { continue; } var obj = element.get_object(); var row = new GoalBranchPush(); row.repo = obj.has_member("repo") ? (obj.get_string_member("repo") ?? "") : ""; if (obj.has_member("branch")) { row.branch = obj.get_string_member("branch") ?? ""; } else if (row.repo.length > 0) { row.branch = row.repo; row.repo = ""; } if (obj.has_member("pushed")) { var pushed = obj.get_member("pushed"); if (pushed.get_node_type() == Json.NodeType.VALUE) { var value = pushed.get_value(); if (value.type() == typeof(bool)) { row.pushed = ((!)value).get_boolean(); } else if (value.type() == typeof(int64)) { row.pushed = ((!)value).get_int64() != 0; } } } if (row.label().length > 0) { rows.add(row); } } } catch (Error e) { // Unparseable push state reads as no rows. } return rows; } private async Goal require_goal(int64 goal_id) throws Error { var goal = yield get_goal(goal_id); if (goal == null) { throw new Error(Quark.from_string("clanker-saddle"), 74, @"No goal exists with id $(goal_id)"); } return (!)goal; } } }