| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311 |
- 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<OrmSession>();
- private StatePulse pulse = inject<StatePulse>();
- private PushService push = inject<PushService>();
- private TaskService tasks = inject<TaskService>();
- private SaddlePollState polls = inject<SaddlePollState>();
- /**
- * 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<Project>()
- .where(expr("id == $0", elem<int64?>(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<Goal>().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>(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<Goal>()
- .where(expr("id == $0", elem<int64?>(id)))
- .first_async();
- }
- /** Every goal, newest first. */
- public async ImmutableLot<Goal> list_goals() throws Error {
- return yield db.query<Goal>()
- .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<string> involved_project_ids(Goal goal) throws Error {
- var ids = new Invercargill.DataStructures.HashSet<string>();
- 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<GoalLink>()
- .where(expr("goal_id == $0 && project_id == $1",
- elem<int64?>(goal_id), elem<int64?>(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<GoalLink>(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<GoalLink>()
- .where(expr("id == $0", elem<int64?>(link_id)))
- .first_async();
- if (link != null) {
- yield db.delete_async<GoalLink>((!)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<GoalLink> list_links(int64 goal_id) throws Error {
- return yield db.query<GoalLink>()
- .where(expr("goal_id == $0", elem<int64?>(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<TaskEntity>()
- .where(expr("goal_id == $0 && kind == $1",
- elem<int64?>(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>((!)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>((!)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>((!)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>((!)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>((!)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>((!)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>((!)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<TaskEntity>()
- .where(expr("goal_id == $0", elem<int64?>(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>(((!)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>(((!)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>(((!)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<TaskBlock>()
- .where(expr("id == $0", elem<int64?>(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<TaskEntity>()
- .where(expr("id == $0", elem<int64?>(((!)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>(((!)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/<id> 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<TaskComment> list_comments(int64 goal_id) throws Error {
- var run = yield goal_run_task(goal_id);
- if (run == null) {
- return new Vector<TaskComment>().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<TaskBlock> list_blocks(int64 goal_id) throws Error {
- var run = yield goal_run_task(goal_id);
- if (run == null) {
- return new Vector<TaskBlock>().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<TaskBlock>()
- .where(expr("id == $0", elem<int64?>(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<NarrativeRow> narrative(int64 goal_id) throws Error {
- var goal = yield get_goal(goal_id);
- if (goal == null) {
- return new Vector<NarrativeRow>().to_immutable_buffer();
- }
- var rows = new Vector<NarrativeRow>();
- // 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<TaskEntity>()
- .where(expr("goal_id == $0 && kind == $1",
- elem<int64?>(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<NarrativeRow>();
- 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 (": <first line>"). */
- 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<TaskEntity>()
- .where(expr("goal_id == $0 && kind == $1",
- elem<int64?>(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<TaskEntity>();
- foreach (var task in yield db.query<TaskEntity>()
- .where(expr("goal_id == $0 && kind == $1",
- elem<int64?>(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<TaskEntity>()
- .where(expr("goal_id == $0", elem<int64?>(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>(((!)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<Session>()
- .where(expr("task_id == $0", elem<int64?>(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<string>();
- 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<GoalBranchPush> parse_goal_branches(string raw) {
- var rows = new Vector<GoalBranchPush>();
- 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;
- }
- }
- }
|