Browse Source

Force Wake: human-triggered resume of parked tasks and goals

Goal 4 task 1: the goal-2 stall class (a parked goal agent with no
pending wake - a draft task never enqueued produces no terminal
transition) needed a human recovery lever.

- Migration M0002_ForceWake: goals.force_wake and tasks.force_wake.
- GoalService.force_wake_goal with guards (active goal, suspended
  run); resume_due force-wake arm (immediate, no coalesce wait, no
  transition requirement); take_resume_notice leads with the
  force-woke line, names drafts NOT-enqueued, clears the flag.
- AllocationService: plain-task force-wake offerable arm + the
  plain-task wake notice with flag consumption.
- Actions registered; entrypoints author them only under the
  suspended pair; pages carry the buttons.
- SaddleApi enqueue/delete: actionable tails (pinned substring kept).
- Tests: goals/allocation/goalui extended; a dedicated forcewake
  bundle follows (the goals/allocation shards die environmentally
  before reaching the new sections).
clanker 1 month ago
parent
commit
4a528f3975

+ 1 - 0
meson.build

@@ -159,6 +159,7 @@ app_sources = files(
     'src/Format.vala',
     'src/Data/Entities.vala',
     'src/Data/Migrations/M0001_Initial.vala',
+    'src/Data/Migrations/M0002_ForceWake.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',

+ 2 - 2
src/Api/SaddleApi.vala

@@ -477,7 +477,7 @@ namespace ClankerSaddle.Api {
                 }
                 if (((!)detail).task.goal_id != goal_id) {
                     return SaddleWire.json_error(400,
-                        @"Task $(task_id) does not belong to your goal");
+                        @"Task $(task_id) does not belong to your goal — nothing was deleted; `saddle tasks` lists this goal's tasks");
                 }
                 if (((!)detail).task.status != STATUS_DRAFT) {
                     return SaddleWire.json_error(400,
@@ -520,7 +520,7 @@ namespace ClankerSaddle.Api {
                 }
                 if (((!)detail).task.goal_id != goal_id) {
                     return SaddleWire.json_error(400,
-                        @"Task $(task_id) does not belong to your goal");
+                        @"Task $(task_id) does not belong to your goal — nothing was enqueued; `saddle tasks` lists this goal's tasks");
                 }
                 yield tasks.enqueue_task(task_id, CREATOR_GOAL);
                 return SaddleWire.json_ok(new Json.Object());

+ 17 - 1
src/Data/Entities.vala

@@ -74,6 +74,13 @@ namespace ClankerSaddle.Data {
         public DateTime? started_at { get; set; default = null; }
         public DateTime? completed_at { get; set; default = null; }
         public DateTime? suspended_at { get; set; default = null; }
+        /**
+         * Goal 4 task 1 (Force Wake): the human pressed Force wake on
+         * the task page — this suspended plain task becomes
+         * parked-offerable again with the explicit wake notice.
+         * Consumed (cleared) by the allocation that resumes it.
+         */
+        public bool force_wake { get; set; default = false; }
         /** The goal this task belongs to (0 = standalone). */
         public int64 goal_id { get; set; default = 0; }
         /** "task" (a worker) or "goal_run" (the goal agent's own run). */
@@ -100,6 +107,7 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
                 .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
                 .column<DateTime?>("suspended_at", o => o.suspended_at, (o, v) => o.suspended_at = v)
+                .column<bool>("force_wake", o => o.force_wake, (o, v) => o.force_wake = v)
                 .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v ?? 0)
                 .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
                 .column<string>("mode", o => o.mode, (o, v) => o.mode = v)
@@ -538,6 +546,13 @@ namespace ClankerSaddle.Data {
         public DateTime? started_at { get; set; default = null; }
         public DateTime? completed_at { get; set; default = null; }
         public DateTime? paused_at { get; set; default = null; }
+        /**
+         * Goal 4 task 1 (Force Wake): the human pressed Force wake on
+         * the goal page — the parked run becomes offerable immediately
+         * and the resume notice leads with the explicit wake line.
+         * Consumed (cleared) by the resume that carries it.
+         */
+        public bool force_wake { get; set; default = false; }
 
         public static void entity_mapping(EntityMapperBuilder<Goal> cfg) {
             cfg.table("goals")
@@ -556,7 +571,8 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
                 .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
                 .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
-                .column<DateTime?>("paused_at", o => o.paused_at, (o, v) => o.paused_at = v);
+                .column<DateTime?>("paused_at", o => o.paused_at, (o, v) => o.paused_at = v)
+                .column<bool>("force_wake", o => o.force_wake, (o, v) => o.force_wake = v);
         }
     }
 

+ 40 - 0
src/Data/Migrations/M0002_ForceWake.vala

@@ -0,0 +1,40 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 4 task 1 (Force Wake): the human-triggered resume flags.
+     * `goals.force_wake` — set by the goal page's Force wake button,
+     * consumed by GoalService.resume_due/take_resume_notice (one wake
+     * per press). `tasks.force_wake` — the plain-task twin: a
+     * suspended worker task flagged by the task page's Force wake,
+     * consumed by AllocationService.offerable_tasks when it becomes
+     * parked-offerable again. Both default false.
+     */
+    public class M0002_ForceWake : Migration {
+
+        public override string name { get { return "Force-wake flags on goals and tasks"; } }
+        public override uint64 serial { get { return 2; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.alter_table("goals", (t) => {
+                t.add_column<bool>("force_wake")
+                    .default_value<bool>(false);
+            });
+            b.alter_table("tasks", (t) => {
+                t.add_column<bool>("force_wake")
+                    .default_value<bool>(false);
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.alter_table("goals", (t) => {
+                t.drop_column("force_wake");
+            });
+            b.alter_table("tasks", (t) => {
+                t.drop_column("force_wake");
+            });
+        }
+    }
+}

+ 25 - 1
src/Services/AllocationService.vala

@@ -143,6 +143,16 @@ namespace ClankerSaddle.Services {
                         rows.add((!)parked);
                     }
                 }
+                // Goal 4 task 1 (Force Wake): a human pressed Force
+                // wake on a suspended PLAIN task — it becomes offerable
+                // on its affine saddle immediately, with the explicit
+                // wake notice riding the resuming assignment.
+                if (run.kind == TASK_KIND_TASK && run.force_wake) {
+                    var parked = yield parked_offerable(run, true);
+                    if (parked != null) {
+                        rows.add((!)parked);
+                    }
+                }
             }
             foreach (var task in yield tasks.get_ready_tasks()) {
                 rows.add(new OfferableTask() {
@@ -384,7 +394,7 @@ namespace ClankerSaddle.Services {
                 // round (plan approval, rules, notes) rides the
                 // resume as a turn — null when nothing changed.
                 context_update = yield context_update_for(task, claim.session_id, projects);
-                if (claim.suspended_resume) {
+                if (claim.suspended_resume && task.kind == TASK_KIND_GOAL_RUN) {
                     // Plan 12: the goal agent's batched report — every
                     // plan task's status plus the completion reports
                     // that landed while it was parked. Consumes the
@@ -392,6 +402,20 @@ namespace ClankerSaddle.Services {
                     // and ends the parking window.
                     resume_notice = yield goals.take_resume_notice(task.goal_id);
                     yield tasks.clear_suspension(task.id);
+                } else if (claim.suspended_resume) {
+                    // Goal 4 task 1 (Force Wake): a force-woke PLAIN
+                    // task's resume carries the explicit human-wake
+                    // line and consumes the flag (one wake per press).
+                    // Without the flag this arm is unreachable —
+                    // offerable_tasks only offers plain suspended
+                    // tasks under force_wake.
+                    resume_notice = "The user explicitly force-woke this parked task — resume where you left off.";
+                    var woke_detail = yield tasks.get_task(task.id);
+                    if (woke_detail != null) {
+                        ((!)woke_detail).task.force_wake = false;
+                        yield db.update_async<TaskEntity>(((!)woke_detail).task);
+                    }
+                    yield tasks.clear_suspension(task.id);
                 } else {
                     // Answering = allocating (plan 11): the verdict rides
                     // the resuming assignment's resume_notice, injected as

+ 49 - 0
src/Services/GoalService.vala

@@ -363,6 +363,32 @@ namespace ClankerSaddle.Services {
             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
@@ -1005,6 +1031,14 @@ namespace ClankerSaddle.Services {
             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
@@ -1056,7 +1090,15 @@ namespace ClankerSaddle.Services {
                 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>();
@@ -1075,6 +1117,12 @@ namespace ClankerSaddle.Services {
                 } 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);
                 }
@@ -1101,6 +1149,7 @@ namespace ClankerSaddle.Services {
                 }
             }
             ((!)goal).last_terminal_watermark = consumed;
+            ((!)goal).force_wake = false;
             yield db.update_async<Goal>(((!)goal));
             return report.str;
         }

+ 25 - 0
src/actions/GoalActions.vala

@@ -217,6 +217,31 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Goal 4 task 1 (Force Wake): the human recovery lever for a
+     * parked goal agent — sets the goal force-wake flag so the run
+     * becomes offerable immediately with the explicit wake notice.
+     * Guards live in {@link GoalService.force_wake_goal} (refuses
+     * non-active goals and non-parked runs); the page only authors
+     * the button when they hold.
+     */
+    public class GoalForceWakeAction : GoalDetailRefreshingAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+            try {
+                var goal = yield goal_service.force_wake_goal(sealed_goal_id());
+                return directives().notify("success", @"Goal '$(goal.title)' force-woke — the parked run resumes on its saddle")
+                    .set(yield goal_detail_slot(goal.id));
+            } catch (Error e) {
+                return directives().notify("error", @"Could not force-woke the goal: $(e.message)").navigate("/goals");
+            }
+        }
+    }
+
     /**
      * Cancels a goal: the cascade cancels every non-terminal task under
      * it (drafts are deleted); live daemons hosting its running tasks

+ 54 - 0
src/actions/TaskActions.vala

@@ -3,6 +3,7 @@ using Spry;
 using Spry.Authentication;
 using Statum;
 using ClankerSaddle.Services;
+using ClankerSaddle.Data;
 
 namespace ClankerSaddle {
 
@@ -509,6 +510,59 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Goal 4 task 1 (Force Wake): the human recovery lever for a
+     * parked PLAIN task — sets the task force-wake flag so the
+     * allocation offers it again on its affine saddle with the
+     * explicit wake notice. A suspended GOAL RUN task routes to the
+     * SAME goal force-wake (GoalForceWakeAction) — one path, no
+     * duplicate semantics; the page authors that variant's button.
+     */
+    public class TaskForceWakeAction : TaskDetailRefreshingAction {
+
+        private GoalService goal_service = inject<GoalService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            if (task_id <= 0) {
+                return directives().navigate("/tasks");
+            }
+
+            try {
+                var detail = yield task_service.get_task(task_id);
+                if (detail == null) {
+                    return directives().notify("error", @"No task exists with id $(task_id)").navigate("/tasks");
+                }
+                var task = ((!)detail).task;
+                if (task.kind == TASK_KIND_GOAL_RUN) {
+                    // One path: the run's wake is the GOAL's wake.
+                    yield goal_service.force_wake_goal(task.goal_id);
+                    return directives().notify("success",
+                            "Goal run force-woke — the parked agent resumes on its saddle")
+                        .navigate(@"/goals/$(task.goal_id)");
+                }
+                if (task.status != STATUS_SUSPENDED) {
+                    return directives().notify("error",
+                            "Only parked tasks can be force-woke — nothing was woken")
+                        .navigate(@"/tasks/$(task_id)");
+                }
+                task.force_wake = true;
+                yield db.update_async<TaskEntity>(task);
+                return directives().notify("success",
+                        "Task force-woke — the parked run resumes on its saddle")
+                    .set(yield task_detail_slot(task_id));
+            } catch (Error e) {
+                return directives().notify("error", @"Could not force-woke the task: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
     /**
      * Manually requeues a task stuck working/blocked on a saddle that
      * went offline (the automatic staleness probe may never fire when

+ 17 - 0
src/entrypoints/GoalDetailEntrypoint.vala

@@ -205,6 +205,13 @@ namespace ClankerSaddle {
          * — requeue it; the next dispatch restores the workspace.
          */
         public Statum.Model.ActionDto? requeue_run { get; set; }
+        /**
+         * Goal 4 task 1 (Force Wake): the human recovery lever —
+         * authored only while the goal is active and its run task is
+         * parked (suspended); pressing it sets the goal force-wake
+         * flag (GoalService.force_wake_goal holds the guards).
+         */
+        public Statum.Model.ActionDto? force_wake { 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). */
@@ -720,6 +727,16 @@ namespace ClankerSaddle {
                         || run_status == STATUS_BLOCKED
                         || run_status == STATUS_REVIEW_REQUESTED
                         || run_status == STATUS_SUSPENDED;
+                    // Goal 4 task 1: Force wake — the run is parked
+                    // on a LIVE saddle under an ACTIVE goal (the
+                    // paused-goal recovery is Resume, the dead-saddle
+                    // one Requeue-to-resume; Force wake covers the
+                    // parked-with-no-pending-wake stall, goal 2).
+                    if (goal.status == GOAL_ACTIVE && run_status == STATUS_SUSPENDED) {
+                        pub.force_wake = ((!)ctx.actions)
+                            .author_private<GoalForceWakeAction, GoalActionPrivate>(
+                                new GoalActionPrivate() { goal_id = goal.id });
+                    }
                     if (!pub.terminal && stale_or_parked) {
                         // Plan 16 D4: a parked round's session is
                         // closed 'parked' — the newest record (open or

+ 13 - 0
src/entrypoints/TaskDetailEntrypoint.vala

@@ -110,6 +110,13 @@ namespace ClankerSaddle {
         public string review_summary { get; set; default = ""; }
         public string review_diff_url { get; set; default = ""; }
         public bool resume_available { get; set; }
+        /**
+         * Goal 4 task 1 (Force Wake): the human recovery lever for a
+         * parked task — authored only while the task is suspended
+         * (any kind; a goal_run routes to the goal's wake). Pressing
+         * it sets the task (or goal) force-wake flag.
+         */
+        public Statum.Model.ActionDto? force_wake { get; set; }
         public string saddle_health { get; set; default = ""; }
         public string display { get; set; default = ""; }
         public bool terminal { get; set; }
@@ -257,6 +264,12 @@ namespace ClankerSaddle {
             pub.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
             pub.retry = actions.author_private<RetryTaskAction, TaskActionPrivate>(sealed);
             pub.resume = actions.author_private<ResumeTaskAction, TaskActionPrivate>(sealed);
+            // Goal 4 task 1: Force wake — only a PARKED task offers
+            // it; a suspended goal run's wake IS the goal's wake
+            // (TaskForceWakeAction routes it — one path).
+            if (task.status == STATUS_SUSPENDED) {
+                pub.force_wake = actions.author_private<TaskForceWakeAction, TaskActionPrivate>(sealed);
+            }
             pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
             pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
             pub.attach_capability = actions.author_private<AttachTaskCapabilityAction, TaskActionPrivate>(sealed);

+ 3 - 0
src/main.vala

@@ -42,6 +42,7 @@ int main(string[] args) {
             print(@"[ClankerSaddle] sqlite pragmas failed: $(pragma_error.message)\n");
         }
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0002_ForceWake>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -123,12 +124,14 @@ int main(string[] args) {
         statum.action<CancelTaskAction>();
         statum.action<RetryTaskAction>();
         statum.action<ResumeTaskAction>();
+        statum.action<TaskForceWakeAction>();
         statum.action<RefreshTasksDataAction>();
         statum.action<CreateGoalAction>();
         statum.action<StartGoalAction>();
         statum.action<UpdateDraftGoalAction>();
         statum.action<PauseGoalAction>();
         statum.action<ResumeGoalAction>();
+        statum.action<GoalForceWakeAction>();
         statum.action<CancelGoalAction>();
         statum.action<RetryGoalRunAction>();
         statum.action<RequeueGoalRunAction>();

+ 5 - 0
src/pages/goal_detail.html

@@ -169,6 +169,11 @@
                     <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>
+                    <!-- Goal 4 task 1: Force wake — the parked-agent
+                         recovery for the no-pending-wake stall. -->
+                    <button type="button" stm-if="goal.force_wake != null"
+                            stm-action="goal.force_wake"
+                            stm-confirm="Force-woke this goal's parked agent? It resumes immediately on its saddle — one wake per press.">Force wake</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>

+ 6 - 0
src/pages/task_detail.html

@@ -132,6 +132,12 @@
                         <button type="button" stm-if="task.resume_available"
                                 stm-action="task.resume"
                                 stm-confirm="Requeue this task? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
+                        <!-- Goal 4 task 1: Force wake — the parked-task
+                             recovery (a goal run routes to the goal's
+                             wake). -->
+                        <button type="button" stm-if="task.force_wake != null"
+                                stm-action="task.force_wake"
+                                stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle — one wake per press.">Force wake</button>
                         <button type="button" stm-if="!task.draft && !task.terminal"
                                 stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
                     </form>

+ 51 - 0
test/bundles/allocation.py

@@ -643,3 +643,54 @@ def run(ctx):
           "the slow asker never went offline (guard did not fire)")
     fake2.ask_delay = 0.0
     fake2.ask_on_wake = False  # stays out of the pool below
+
+    print("== goal 4 t1: plain-task force wake — offerable on its affine saddle ==")
+    # A suspended PLAIN task stays parked until the human force-wakes
+    # it; the resuming claim carries the explicit wake notice and
+    # consumes the flag (one wake per press).
+    fake2.ask_on_wake = False
+    fake2.ask_on_idle_status = False
+    fake2.settle_asks()
+    conn = db()
+    conn.execute("insert into tasks (project_id, kind, title, status, priority, mode,"
+                 " creator, created_at, suspended_at) values (1, 'task',"
+                 " 'Park me', 'suspended', 5, 'implement', 'human',"
+                 " strftime('%s','now'), strftime('%s','now'))")
+    plain_parked = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.commit()
+    conn.close()
+    task_page = http_get_page(auth, "/tasks/%d" % plain_parked)
+    check('stm-action="task.force_wake"' in task_page
+          and 'stm-if="task.force_wake != null"' in task_page,
+          "the task page authors the Force wake button for a suspended plain task")
+    # No press, no wake: frozen-ask racer-two gets 204.
+    status, _ = fake2.ask_now()
+    check(status == 204,
+          "without a press the parked plain task stays parked (204)")
+    detail = task_state(auth, plain_parked)
+    check(detail.get("force_wake") is not None,
+          "the task state exposes the offered force-wake action")
+    act(auth, detail["force_wake"], {})
+    status, resume_payload = fake2.ask_now()
+    check(status == 200 and int(resume_payload["task"]["id"]) == plain_parked,
+          "the force-woke plain task is claimable again")
+    woke_notice = resume_payload.get("resume_notice") or ""
+    check("force-woke this parked task" in woke_notice
+          and "resume where you left off" in woke_notice,
+          "the plain-task resume carries the explicit wake notice")
+    conn = db()
+    flag_row = conn.execute("select force_wake from tasks where id = ?",
+                            (plain_parked,)).fetchone()
+    conn.close()
+    check(flag_row is not None and flag_row[0] in (0, False),
+          "the claim consumed the task force-wake flag")
+    # The woken run re-parks (fake daemon finishes then suspends? no —
+    # fake.start_payloads script: the plain task runs to done via the
+    # daemon's own finish path in later suites; here just let it be
+    # requeued by completing it directly to keep the fixture short).
+    check(wait_task_done(auth, plain_parked, timeout=60) is not None,
+          "the woken plain task completes (daemon finish path)")
+    # Guard: a non-suspended task offers no wake action (the template
+    # ships the gated markup statically; absence is a STATE property).
+    check(task_state(auth, plain_parked).get("force_wake") is None,
+          "a done task offers no force-wake action")

+ 85 - 0
test/bundles/goals.py

@@ -1014,3 +1014,88 @@ def run(ctx):
           "standalone resumes carry no plan section")
     check(wait_task_done(auth, probe_id, timeout=60) is not None,
           "the probe task completes after its resume")
+
+    print("== goal 4 t1: force wake — the human resume for a parked agent ==")
+    # The goal-2 stall class: the parked run has NO pending wake (a
+    # draft task never enqueued → no terminal transition → parked
+    # forever). Force wake is the human recovery lever.
+    fake.goal_script = [("suspend",)]
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="agent parks for force wake") is not None,
+          "the goal agent parks before the force-wake scenario")
+    # The stall fixture: an agent-created draft that was never enqueued
+    # (typo'd enqueue) — statuses must name it as NOT enqueued.
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session, "title": "QA sweep",
+        "mandate": "Run the QA sweep.", "mode": "test"})
+    check(status == 200 and body.get("ok") is True,
+          "the stall fixture draft task creates (%s)" % body.get("error"))
+    stall_draft = int(body["task_id"])
+    goal_page = http_get_page(auth, "/goals/%d" % goal1)
+    check('stm-action="goal.force_wake"' in goal_page,
+          "the goal page authors the Force wake button while the run is parked")
+    check('stm-if="goal.force_wake != null"' in goal_page,
+          "the Force wake button is gated on the action being offered")
+    # No wake without the press: parked stays parked.
+    parked_runs = len(fake.goal_assignments)
+    check(quiet_window(quiet_seconds(),
+                       lambda: len(fake.goal_assignments) == parked_runs,
+                       "no press, no wake"),
+          "without a press the parked agent never wakes")
+    state = goal_state(goal1)
+    check(state.get("force_wake") is not None,
+          "the goal state exposes the offered force-wake action")
+    act(auth, state["force_wake"], {})
+    check(wait_for(lambda: len(fake.goal_assignments) > parked_runs or False,
+                   timeout=30, what="force-woke agent resumes") is not None,
+          "the force-woke agent resumes immediately (no coalesce wait)")
+    woke_notice = fake.goal_assignments[-1].get("resume_notice") or ""
+    check("force-woke this goal's supervision" in woke_notice
+          and "no pending wake" in woke_notice,
+          "the force-wake resume leads with the explicit wake line")
+    check(("draft (NOT enqueued" in woke_notice)
+          and ("saddle task enqueue %d" % stall_draft) in woke_notice,
+          "the statuses list names the never-enqueued draft with the enqueue fix")
+    check("Task statuses" in woke_notice,
+          "the force-wake notice still carries the standard batched report")
+    # The flag is consumed: re-park (script), no second wake fires.
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="woken agent re-parks") is not None,
+          "the woken agent parks again (script)")
+    woke_runs = len(fake.goal_assignments)
+    check(quiet_window(quiet_seconds(),
+                       lambda: len(fake.goal_assignments) == woke_runs,
+                       "consumed flag never re-fires"),
+          "the consumed flag does not re-wake (one wake per press)")
+    # Guards: a paused goal refuses the wake (Resume is its lever).
+    state = goal_state(goal1)
+    act(auth, state["pause"], {})
+    conn = db()
+    paused_flag = conn.execute("select force_wake from goals where id = ?",
+                               (goal1,)).fetchone()
+    conn.close()
+    check(paused_flag is not None and paused_flag[0] in (0, False),
+          "pausing does not set the flag")
+    state = goal_state(goal1)
+    check(state.get("force_wake") is None,
+          "a paused goal does not author the Force wake action")
+    act(auth, state["resume"], {})
+    check(wait_for(lambda: goal_state(goal1)["status"] == "active" or False,
+                   timeout=30, what="goal reactivates") is not None,
+          "the goal reactivates for the guard checks")
+    # Non-suspended run refuses: run is parked again (script parked it
+    # above), wake it, let it park, then a second press while parked is
+    # fine — the 400 path needs a NON-suspended run. Start there: wake
+    # once more and press while it is working.
+    state = goal_state(goal1)
+    act(auth, state["force_wake"], {})
+    check(wait_for(lambda: len(fake.goal_assignments) > woke_runs or False,
+                   timeout=30, what="second wake fires") is not None,
+          "a second press wakes again (re-press is allowed)")
+    # While it is mid-script (not yet parked), the action is hidden.
+    check(goal_state(goal1).get("force_wake") is None
+          or task_state(auth, run_task)["status"] != "suspended",
+          "the wake action hides while the run is not parked")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="run parks for cleanup") is not None,
+          "the run parks again")

+ 21 - 0
test/bundles/goalui.py

@@ -783,3 +783,24 @@ def run(ctx):
           "the draft-only guard refuses an edit replay against a started goal")
     check(goal_state(edit_goal)["brief"] == "The edited brief.",
           "the refused replay left the brief unchanged")
+
+    print("== goal 4 t1: force-wake action stays unoffered unless parked ==")
+    # The page template ships the gated button markup statically (the
+    # stm-if hides it client-side), so absence is asserted on the SLOT
+    # STATE: the action is not AUTHORED. The offered pair (parked run)
+    # is covered by the goals bundle, which owns a parked run.
+    check(goal_state(edit_goal).get("force_wake") is None,
+          "a non-parked (planning) goal state offers no force-wake action")
+    conn = db()
+    any_goal = conn.execute(
+        "select id from goals where status = 'draft' order by id limit 1").fetchone()
+    plain = conn.execute(
+        "select id from tasks where status != 'suspended'"
+        " and kind = 'task' order by id limit 1").fetchone()
+    conn.close()
+    if any_goal:
+        check(goal_state(int(any_goal[0])).get("force_wake") is None,
+              "a draft goal state offers no force-wake action")
+    if plain:
+        check(task_state(auth, int(plain[0])).get("force_wake") is None,
+              "a non-suspended task state offers no force-wake action")