Explorar el Código

r2: LifecycleService owns the six cancel/retry/requeue orchestrations (arch 11) — TaskService+SaddleService+GoalService injected, no cycle; CancelTask/RetryTask/ResumeTask and CancelGoal/RetryGoalRun/RequeueGoalRun become thin auth+validation+notify wrappers delegating to it, the abort-before-mutate ordering and the abort failure contract (mark saddle offline, still proceed) living once in the service docblock+paths; RetryGoalRunAction's direct db.update_async<Goal> writes move into GoalService.retry_goal (goal revives active, completed_at withdrawn — no pulse added, matching the old action's write set).

clanker hace 1 mes
padre
commit
acf1fcbe43

+ 1 - 0
meson.build

@@ -128,6 +128,7 @@ app_sources = files(
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
     'src/Services/SaddleService.vala',
+    'src/Services/LifecycleService.vala',
     'src/Services/SaddlePollState.vala',
     'src/Services/AllocationService.vala',
     'src/Services/StatePulse.vala',

+ 22 - 3
src/Services/GoalService.vala

@@ -329,8 +329,9 @@ namespace ClankerSaddle.Services {
          * 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 —
-         * the cancel action (which can reach SaddleService without a
-         * dependency cycle) aborts them first.
+         * {@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);
@@ -349,7 +350,25 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * The cancel/fail cascade the terminal goal paths share: every
+         * 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

+ 141 - 0
src/Services/LifecycleService.vala

@@ -0,0 +1,141 @@
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The cancel/retry/requeue orchestration the six row actions used
+     * to own (plan 16 R2, arch item 11): every path that moves a task
+     * or a goal on from a possibly-live daemon runs "abort the live
+     * session first, then mutate" through here. The orchestration
+     * lived in the actions only because GoalService → SaddleService
+     * would cycle; this service sits beside the three without a cycle
+     * (none of them inject it), and the actions went back to being
+     * auth + validation + notify wrappers.
+     *
+     * The abort contract (SaddleService.abort_live_session, encoded
+     * once here): the cancel is delivered to the daemon holding the
+     * task's open session (`POST /task-update {kind: "cancel"}`); a
+     * transport FAILURE marks the saddle offline and STILL PROCEEDS —
+     * the daemon must never keep running a task the web has moved on
+     * from as a zombie. The verb rides the system thread ("&lt;verb&gt;;
+     * daemon aborted" / "&lt;verb&gt;; daemon unreachable — saddle marked
+     * offline").
+     */
+    public class LifecycleService : Object {
+
+        private TaskService tasks = inject<TaskService>();
+        private SaddleService saddles = inject<SaddleService>();
+        private GoalService goals = inject<GoalService>();
+
+        /**
+         * Cancels a task: its live daemon is aborted first ("cancelled"
+         * on the system thread), then the row goes terminal-failed
+         * with the cancellation summary. An abort failure marks the
+         * saddle offline and proceeds — the cancel itself only fails
+         * when the mutation does. The action's non-draft/non-terminal
+         * guard still applies upstream (drafts are deleted, terminal
+         * tasks keep their first outcome).
+         */
+        public async void cancel_task(int64 task_id) throws Error {
+            yield saddles.abort_live_session(tasks, task_id, "cancelled");
+            yield tasks.cancel_task(task_id);
+        }
+
+        /**
+         * Retries a failed task: any live daemon is aborted first
+         * ("retried"), then the attempt resets — back to the queue,
+         * the failure summary and completed_at withdrawn (plan 16 S4:
+         * the terminal cache dies with its attempt, copied nowhere).
+         */
+        public async void retry_task(int64 task_id) throws Error {
+            yield saddles.abort_live_session(tasks, task_id, "retried");
+            yield tasks.retry_task(task_id);
+        }
+
+        /**
+         * The manual requeue-for-resume (a task stuck working or
+         * parked on a saddle that went offline): a live session is
+         * aborted ("stopped") and its saddle marked unreachable — the
+         * unreachable bookkeeping requeues the held task with its
+         * notice comment; without a session the task simply requeues.
+         * The next dispatch restores the daemon-side workspace.
+         */
+        public async void requeue_for_resume(int64 task_id) throws Error {
+            var session = yield tasks.open_session_for_task(task_id);
+            if (session != null) {
+                yield saddles.abort_live_session(tasks, task_id, "stopped");
+                yield saddles.mark_saddle_unreachable(((!)session).saddle_id, tasks);
+            } else {
+                yield tasks.requeue_task(task_id);
+            }
+        }
+
+        /**
+         * Cancels a goal: every running task under it has its live
+         * daemon aborted first ("goal cancelled" — one abort failure
+         * is warned and skipped; the cascade still proceeds, the
+         * abort's own failure contract having already handled the
+         * dead-daemon case), then the goal and its task tree close
+         * (the cascade cancels non-terminal tasks, deletes drafts).
+         */
+        public async void cancel_goal(int64 goal_id) throws Error {
+            foreach (var task in yield tasks.list_tasks_for_goal(goal_id)) {
+                if (task.status == STATUS_ASSIGNED || task.status == STATUS_WORKING) {
+                    try {
+                        yield saddles.abort_live_session(tasks, task.id, "goal cancelled");
+                    } catch (Error e) {
+                        warning("[ClankerSaddle] Goal-cancel abort of task %s failed: %s",
+                            task.id.to_string(), e.message);
+                    }
+                }
+            }
+            yield goals.cancel_goal(goal_id);
+        }
+
+        /**
+         * Re-offers a failed goal's supervision (plan 14 B1): the run
+         * task's live daemon is aborted first ("supervision retried" —
+         * a failure is warned and skipped), the run re-queues for
+         * another attempt and {@link GoalService.retry_goal} revives
+         * the goal row. The harness row on the run thread narrates the
+         * retry (the narrative derives it at read time). The action's
+         * failed-goal/failed-run guards still apply upstream.
+         */
+        public async void retry_goal_run(int64 goal_id) throws Error {
+            var run = yield goals.goal_run_task(goal_id);
+            if (run == null) {
+                return;
+            }
+            try {
+                yield saddles.abort_live_session(tasks, ((!)run).id, "supervision retried");
+            } catch (Error e) {
+                warning("[ClankerSaddle] Goal-retry abort of run task %s failed: %s",
+                    ((!)run).id.to_string(), e.message);
+            }
+            yield tasks.retry_task(((!)run).id);
+            yield goals.retry_goal(goal_id);
+            yield tasks.add_system_comment(((!)run).id,
+                @"Supervision retried — the goal agent runs again (run task #$(((!)run).id))");
+        }
+
+        /**
+         * The goal page's requeue-to-resume (plan 14 B1): the run is
+         * stale or parked on a dead saddle — abort its session
+         * best-effort, close it and requeue the run; the next dispatch
+         * restores the daemon's workspace context. The action's
+         * not-terminal guard still applies upstream.
+         */
+        public async void requeue_goal_run(int64 goal_id) throws Error {
+            var run = yield goals.goal_run_task(goal_id);
+            if (run == null) {
+                return;
+            }
+            yield saddles.abort_live_session(tasks, ((!)run).id, "requeued");
+            yield tasks.requeue_task(((!)run).id);
+            yield tasks.add_system_comment(((!)run).id,
+                @"Run requeued — the daemon restores the agent's workspace on the next dispatch (run task #$(((!)run).id))");
+        }
+    }
+}

+ 14 - 41
src/actions/GoalActions.vala

@@ -145,14 +145,14 @@ namespace ClankerSaddle {
     /**
      * Cancels a goal: the cascade cancels every non-terminal task under
      * it (drafts are deleted); live daemons hosting its running tasks
-     * are aborted first (the cancel action can reach SaddleService
-     * without the service dependency cycle).
+     * are aborted first — the orchestration lives in
+     * {@link LifecycleService.cancel_goal} (no service dependency
+     * cycle).
      */
     public class CancelGoalAction : SealedGoalAction {
 
         private GoalService goals = inject<GoalService>();
-        private SaddleService saddles = inject<SaddleService>();
-        private TaskService tasks = inject<TaskService>();
+        private LifecycleService lifecycle = inject<LifecycleService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -166,17 +166,7 @@ namespace ClankerSaddle {
                 if (goal == null) {
                     return directives().notify("error", @"No goal exists with id $(goal_id)").navigate("/goals");
                 }
-                foreach (var task in yield tasks.list_tasks_for_goal(goal_id)) {
-                    if (task.status == STATUS_ASSIGNED || task.status == STATUS_WORKING) {
-                        try {
-                            yield saddles.abort_live_session(tasks, task.id, "goal cancelled");
-                        } catch (Error e) {
-                            warning("[ClankerSaddle] Goal-cancel abort of task %s failed: %s",
-                                task.id.to_string(), e.message);
-                        }
-                    }
-                }
-                yield goals.cancel_goal(goal_id);
+                yield lifecycle.cancel_goal(goal_id);
                 return directives().notify("success", @"Goal '$(((!)goal).title)' cancelled — its tasks went with it")
                     .navigate(@"/goals/$(goal_id)");
             } catch (Error e) {
@@ -191,14 +181,14 @@ namespace ClankerSaddle {
      * 'active' and its run task re-queues for another attempt — the
      * failure summary clears with the attempt (plan 16 S4: the
      * terminal cache is never copied to the thread), exactly like
-     * {@link RetryTaskAction}.
+     * {@link RetryTaskAction}. The abort-first orchestration and the
+     * goal-row revival live in {@link LifecycleService.retry_goal_run}
+     * + {@link GoalService.retry_goal}.
      */
     public class RetryGoalRunAction : SealedGoalAction {
 
         private GoalService goals = inject<GoalService>();
-        private TaskService tasks = inject<TaskService>();
-        private SaddleService saddles = inject<SaddleService>();
-        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        private LifecycleService lifecycle = inject<LifecycleService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -223,21 +213,7 @@ namespace ClankerSaddle {
                             "The goal agent's run task is not in a failed state")
                         .navigate(@"/goals/$(goal_id)");
                 }
-                try {
-                    yield saddles.abort_live_session(tasks, ((!)run).id, "supervision retried");
-                } catch (Error e) {
-                    warning("[ClankerSaddle] Goal-retry abort of run task %s failed: %s",
-                        ((!)run).id.to_string(), e.message);
-                }
-                yield tasks.retry_task(((!)run).id);
-                ((!)goal).status = GOAL_ACTIVE;
-                ((!)goal).completed_at = null;
-                yield db.update_async<Goal>((!)goal);
-                // Plan 15 C3: the retry is harness bookkeeping — a
-                // system row on the run thread (the narrative derives
-                // it at read time).
-                yield tasks.add_system_comment(((!)run).id,
-                    @"Supervision retried — the goal agent runs again (run task #$(((!)run).id))");
+                yield lifecycle.retry_goal_run(goal_id);
                 return directives().notify("success",
                         @"Goal '$(((!)goal).title)' — supervision requeued for another attempt")
                     .navigate(@"/goals/$(goal_id)");
@@ -253,13 +229,13 @@ namespace ClankerSaddle {
      * task page's ResumeTaskAction): the goal agent's run is stale or
      * parked on a dead saddle — abort its session best-effort, close
      * it and requeue the run; the next dispatch restores the daemon's
-     * workspace context (restore-on-dispatch).
+     * workspace context (restore-on-dispatch). Owned by
+     * {@link LifecycleService.requeue_goal_run}.
      */
     public class RequeueGoalRunAction : SealedGoalAction {
 
         private GoalService goals = inject<GoalService>();
-        private TaskService tasks = inject<TaskService>();
-        private SaddleService saddles = inject<SaddleService>();
+        private LifecycleService lifecycle = inject<LifecycleService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -281,10 +257,7 @@ namespace ClankerSaddle {
                             "The goal agent's run is not requeueable")
                         .navigate(@"/goals/$(goal_id)");
                 }
-                yield saddles.abort_live_session(tasks, ((!)run).id, "requeued");
-                yield tasks.requeue_task(((!)run).id);
-                yield tasks.add_system_comment(((!)run).id,
-                    @"Run requeued — the daemon restores the agent's workspace on the next dispatch (run task #$(((!)run).id))");
+                yield lifecycle.requeue_goal_run(goal_id);
                 return directives().notify("success",
                         "Run requeued; the daemon restores the agent's context on the next dispatch")
                     .navigate(@"/goals/$(goal_id)");

+ 12 - 18
src/actions/TaskActions.vala

@@ -389,12 +389,13 @@ namespace ClankerSaddle {
      * Cancels a task (status 'failed'); the sealed id comes from the task
      * page's cancel button. Only non-draft non-terminal tasks offer the
      * action — drafts are deleted, terminal tasks are already finished,
-     * and completion itself is agent-only.
+     * and completion itself is agent-only. The abort-before-cancel
+     * orchestration lives in {@link LifecycleService.cancel_task}.
      */
     public class CancelTaskAction : SealedTaskAction {
 
         private TaskService tasks = inject<TaskService>();
-        private SaddleService saddles = inject<SaddleService>();
+        private LifecycleService lifecycle = inject<LifecycleService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -420,8 +421,7 @@ namespace ClankerSaddle {
             }
 
             try {
-                yield saddles.abort_live_session(tasks, task_id, "cancelled");
-                yield tasks.cancel_task(task_id);
+                yield lifecycle.cancel_task(task_id);
                 return directives().notify("success", "Task cancelled").navigate(@"/tasks/$(task_id)");
             } catch (Error e) {
                 return directives().notify("error", @"Could not cancel the task: $(e.message)")
@@ -435,12 +435,13 @@ namespace ClankerSaddle {
      * cleared (plan 16 S4: the terminal cache dies with its attempt —
      * nothing derived is copied to the thread), all other history
      * kept. The next dispatch resumes the daemon-side workspace when
-     * it still exists.
+     * it still exists; the abort-first orchestration lives in
+     * {@link LifecycleService.retry_task}.
      */
     public class RetryTaskAction : SealedTaskAction {
 
         private TaskService tasks = inject<TaskService>();
-        private SaddleService saddles = inject<SaddleService>();
+        private LifecycleService lifecycle = inject<LifecycleService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -464,8 +465,7 @@ namespace ClankerSaddle {
             }
 
             try {
-                yield saddles.abort_live_session(tasks, task_id, "retried");
-                yield tasks.retry_task(task_id);
+                yield lifecycle.retry_task(task_id);
                 return directives().notify("success", "Task requeued for another attempt")
                     .navigate(@"/tasks/$(task_id)");
             } catch (Error e) {
@@ -479,12 +479,12 @@ namespace ClankerSaddle {
      * Manually requeues a task stuck working/blocked on a saddle that
      * went offline (the automatic three-strike path may never fire when
      * the saddle row was refreshed to offline first). The same path as
-     * the dispatcher's abandon: requeue + agent comment + saddle offline.
+     * the dispatcher's abandon: requeue + agent comment + saddle
+     * offline — owned by {@link LifecycleService.requeue_for_resume}.
      */
     public class ResumeTaskAction : SealedTaskAction {
 
-        private TaskService task_service = inject<TaskService>();
-        private SaddleService saddle_service = inject<SaddleService>();
+        private LifecycleService lifecycle = inject<LifecycleService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -499,13 +499,7 @@ namespace ClankerSaddle {
             }
 
             try {
-                var session = yield task_service.open_session_for_task(task_id);
-                if (session != null) {
-                    yield saddle_service.abort_live_session(task_service, task_id, "stopped");
-                    yield saddle_service.mark_saddle_unreachable(((!)session).saddle_id, task_service);
-                } else {
-                    yield task_service.requeue_task(task_id);
-                }
+                yield lifecycle.requeue_for_resume(task_id);
                 return directives().notify("success",
                         "Task requeued; the daemon restores its context on the next dispatch")
                     .navigate(@"/tasks/$(task_id)");

+ 1 - 0
src/main.vala

@@ -71,6 +71,7 @@ int main(string[] args) {
         application.add_scoped<TaskService>();
         application.add_scoped<GoalService>();
         application.add_scoped<SaddleService>();
+        application.add_scoped<LifecycleService>();
         application.add_scoped<RuleService>();
         application.add_scoped<CapabilityService>();
         application.add_scoped<AllocationService>();