浏览代码

B1: goal-run control parity — Retry supervision, Requeue-to-resume, /tasks redirect

The goal status head gains the task page's run controls: Retry
supervision (a failed goal revives to active, its run task re-queues —
the failure summary moves into the run's history like RetryTaskAction)
and Requeue-to-resume (the run stale/parked on a dead saddle: abort the
session best-effort, requeue with restore-on-dispatch — requeue_task now
also flips suspended runs and clears the suspension stamp), both with
the task page's confirm-dialog semantics. Cancel was already present;
there is NO notify-me toggle — goals notify unconditionally by design.
Yeehaw stays rows-only (the goal-head corner badge goes; task rows keep
theirs). The status field set is verified complete: branch pair with
push-state chips, priority, timestamps, assigned/parking saddle.
/tasks/<goal-run-id> now redirects to /goals/<id> (Statum navigate
directive after the state hydrates, so sealed refreshes and API readers
keep their slot); worker tasks render normally.
clanker 1 月之前
父节点
当前提交
e9b8fc1064

+ 9 - 2
src/Services/TaskService.vala

@@ -1338,7 +1338,10 @@ namespace ClankerSaddle.Services {
         /**
          * Returns a non-terminal task to the queue: closes its open
          * sessions (outcome 'stopped') and frees the saddle that was
-         * running it.
+         * running it. Plan 14 B1: a parked (suspended) task requeues
+         * too — the goal page's requeue-to-resume reaches a goal
+         * agent parked on a dead saddle; the suspension stamp clears
+         * so the next dispatch starts a fresh parking window.
          */
         public async void requeue_task(int64 task_id) throws Error {
             var task = yield db.query<TaskEntity>()
@@ -1346,8 +1349,12 @@ namespace ClankerSaddle.Services {
                 .first_async();
             if (task != null && (((!)task).status == STATUS_ASSIGNED
                     || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
-                    || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
+                    || ((!)task).status == STATUS_REVIEW_REQUESTED
+                    || ((!)task).status == STATUS_SUSPENDED)) {
                 ((!)task).status = STATUS_QUEUED;
+                if (((!)task).suspended_at != null) {
+                    ((!)task).suspended_at = null;
+                }
                 yield db.update_async<TaskEntity>((!)task);
             }
             yield close_open_sessions(task_id, "stopped");

+ 109 - 0
src/actions/GoalActions.vala

@@ -2,6 +2,7 @@ using Inversion;
 using Spry;
 using Spry.Authentication;
 using Statum;
+using ClankerSaddle.Data;
 using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
@@ -184,6 +185,114 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Re-offers a failed goal's supervision (plan 14 B1, the goal
+     * page's parity with the task page's Retry): the goal revives to
+     * 'active' and its run task re-queues for another attempt — the
+     * failure summary moves into the run's comment history exactly
+     * like {@link RetryTaskAction}.
+     */
+    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 UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+            int64 goal_id = sealed_goal_id();
+            try {
+                var goal = yield goals.get_goal(goal_id);
+                if (goal == null) {
+                    return directives().notify("error", @"No goal exists with id $(goal_id)")
+                        .navigate("/goals");
+                }
+                if (((!)goal).status != GOAL_FAILED) {
+                    return directives().notify("error", "Only failed goals can retry supervision")
+                        .navigate(@"/goals/$(goal_id)");
+                }
+                var run = yield goals.goal_run_task(goal_id);
+                if (run == null || ((!)run).status != STATUS_FAILED) {
+                    return directives().notify("error",
+                            "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);
+                yield goals.record_event(goal_id, "retry",
+                    @"Supervision retried — the goal agent runs again (run task #$(((!)run).id))",
+                    ((!)run).id);
+                return directives().notify("success",
+                        @"Goal '$(((!)goal).title)' — supervision requeued for another attempt")
+                    .navigate(@"/goals/$(goal_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not retry supervision: $(e.message)")
+                    .navigate(@"/goals/$(goal_id)");
+            }
+        }
+    }
+
+    /**
+     * The goal page's requeue-to-resume (plan 14 B1, parity with the
+     * 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).
+     */
+    public class RequeueGoalRunAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        private TaskService tasks = inject<TaskService>();
+        private SaddleService saddles = inject<SaddleService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+            int64 goal_id = sealed_goal_id();
+            try {
+                var goal = yield goals.get_goal(goal_id);
+                if (goal == null) {
+                    return directives().notify("error", @"No goal exists with id $(goal_id)")
+                        .navigate("/goals");
+                }
+                var run = yield goals.goal_run_task(goal_id);
+                if (run == null || ((!)run).status == STATUS_DONE
+                        || ((!)run).status == STATUS_FAILED) {
+                    return directives().notify("error",
+                            "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 goals.record_event(goal_id, "requeue",
+                    @"Run requeued — the daemon restores the agent's workspace on the next dispatch (run task #$(((!)run).id))",
+                    ((!)run).id);
+                return directives().notify("success",
+                        "Run requeued; the daemon restores the agent's context on the next dispatch")
+                    .navigate(@"/goals/$(goal_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not requeue the run: $(e.message)")
+                    .navigate(@"/goals/$(goal_id)");
+            }
+        }
+    }
+
     /**
      * Posts a comment on a goal (the goal page's thread; a selection
      * quote rides along when the popover opened one).

+ 46 - 0
src/entrypoints/GoalDetailEntrypoint.vala

@@ -152,6 +152,16 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? pause { get; set; }
         public Statum.Model.ActionDto? resume { get; set; }
         public Statum.Model.ActionDto? cancel { get; set; }
+        /**
+         * Plan 14 B1 parity: re-offer a failed goal's supervision (the
+         * task page's Retry, worded for the run).
+         */
+        public Statum.Model.ActionDto? retry_run { get; set; }
+        /**
+         * Plan 14 B1 parity: the run is stale/parked on a dead saddle
+         * — requeue it; the next dispatch restores the workspace.
+         */
+        public Statum.Model.ActionDto? requeue_run { get; set; }
         public Statum.Model.ActionDto? comment { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
         /** The sealed GET refresh action (live pulse rehydration). */
@@ -411,6 +421,42 @@ namespace ClankerSaddle {
                 return;
             }
 
+            // Plan 14 B1: run-control parity on the status head —
+            // "Retry supervision" for a failed goal, requeue-to-resume
+            // for a run stale/parked on a dead saddle (NO notify-me
+            // toggle: goals notify unconditionally by design).
+            if (actions != null && pub.run_task_id > 0) {
+                var run_row = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(pub.run_task_id)))
+                    .first_async();
+                if (run_row != null) {
+                    string run_status = ((!)run_row).status;
+                    if (goal.status == GOAL_FAILED && run_status == STATUS_FAILED) {
+                        pub.retry_run = ((!)actions)
+                            .author_private<RetryGoalRunAction, GoalActionPrivate>(
+                                new GoalActionPrivate() { goal_id = goal.id });
+                    }
+                    bool stale_or_parked = run_status == STATUS_WORKING
+                        || run_status == STATUS_BLOCKED
+                        || run_status == STATUS_REVIEW_REQUESTED
+                        || run_status == STATUS_SUSPENDED;
+                    if (!pub.terminal && stale_or_parked) {
+                        var open_run = yield tasks.open_session_for_task(pub.run_task_id);
+                        if (open_run != null) {
+                            var host = yield db.query<Saddle>()
+                                .where(expr("id == $0",
+                                    elem<int64?>(((!)open_run).saddle_id)))
+                                .first_async();
+                            if (host != null && ((!)host).status == "offline") {
+                                pub.requeue_run = ((!)actions)
+                                    .author_private<RequeueGoalRunAction, GoalActionPrivate>(
+                                        new GoalActionPrivate() { goal_id = goal.id });
+                            }
+                        }
+                    }
+                }
+            }
+
             var event_buffer = new Vector<GoalEventView>();
             foreach (var row in yield goals.list_events(goal.id)) {
                 event_buffer.add(new GoalEventView() {

+ 12 - 0
src/entrypoints/TaskDetailEntrypoint.vala

@@ -546,6 +546,18 @@ namespace ClankerSaddle {
                 project_service, saddle_service, rule_service, capability_service,
                 action_registry, poll_state, push_service, db,
                 me != null ? ((!)me).user_id : 0, task_id);
+            // Plan 14 B1: the goal page fully replaces the goal-run
+            // task's page — deep links (dashboard, notifications,
+            // timeline) redirect to the goal. The task state still
+            // hydrates first (the sealed refresh and API readers keep
+            // their slot); worker tasks render normally.
+            var detail = yield task_service.get_task(task_id);
+            if (detail != null && ((!)detail).task.kind == TASK_KIND_GOAL_RUN
+                    && ((!)detail).task.goal_id > 0) {
+                return directives().set(state)
+                    .navigate(@"/goals/$(((!)detail).task.goal_id)")
+                    .subscribe_global(StatePulse.SLOT_TYPE);
+            }
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 2 - 0
src/main.vala

@@ -115,6 +115,8 @@ int main(string[] args) {
         statum.action<PauseGoalAction>();
         statum.action<ResumeGoalAction>();
         statum.action<CancelGoalAction>();
+        statum.action<RetryGoalRunAction>();
+        statum.action<RequeueGoalRunAction>();
         statum.action<GoalCommentAction>();
         statum.action<AnswerGoalBlockAction>();
         statum.action<AddGoalLinkAction>();

+ 5 - 2
src/pages/goal_detail.html

@@ -83,8 +83,6 @@
             <section class="card goal-head">
                 <div class="card-head">
                     <h3>Status</h3>
-                    <span class="badge badge-yeehaw status-corner"
-                          stm-if="goal.terminal && goal.status == 'complete'">🤠 goal complete</span>
                 </div>
                 <p><span stm-attribute.class="'badge badge-' + goal.status" stm-text="goal.display">status</span>
                     · goal branch <code stm-text="goal.branch_name">branch</code>
@@ -113,6 +111,11 @@
                             stm-action="goal.pause">Pause</button>
                     <button type="button" stm-if="goal.resume != null"
                             stm-action="goal.resume">Resume</button>
+                    <button type="button" class="primary" stm-if="goal.retry_run != null"
+                            stm-action="goal.retry_run">Retry supervision</button>
+                    <button type="button" stm-if="goal.requeue_run != null"
+                            stm-action="goal.requeue_run"
+                            stm-confirm="Requeue the agent's run? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
                     <button type="button" class="danger" stm-if="goal.cancel != null"
                             stm-action="goal.cancel"
                             stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>