Bläddra i källkod

phase 3: capability awareness + dashboard — assignment capabilities context (saddle_tags/fleet_tags), GET /api/saddle/capabilities for the daemon CLI, subtask callback capabilities validated against the operator tag set (explicit-only, unknown tags name the valid list), task page status block pinned to the top with newest-first blocking sections, home dashboard pinning OPEN blocks as newest-first link-through cards above a cross-task activity timeline (TimelineService, last ~50 events), slow-ask lost-wake regression; tracked test bytecode removed

clanker 1 månad sedan
förälder
incheckning
16ea23f919

+ 4 - 0
.gitignore

@@ -4,3 +4,7 @@ web-config.json
 
 # Generated by meson; copied from builddir by spry for the Vala language server
 compile_commands.json
+
+# Python bytecode
+test/__pycache__/
+__pycache__/

+ 19 - 2
README.md

@@ -34,6 +34,17 @@ against a daemon is exceptional only — the staleness probe (a silent
 runner owes one probe after `SADDLE_STALE_S`; a failed probe requeues
 its task and marks the saddle offline) and the manual refresh button.
 A recovered daemon's ask flips an offline row back to idle.
+Capability awareness (phase 3): every assignment carries a
+`capabilities` context — `saddle_tags` (the allocated saddle's own
+tags) and `fleet_tags` (every tag name known across the fleet) — which
+the daemon renders into its system prompt; `GET
+/api/saddle/capabilities` (per-saddle token) feeds its `saddle
+capabilities` CLI, and the subtask callback's `capabilities: [tags]`
+attach explicit, validated requirements (unknown tags reject with the
+valid list). The home dashboard pins OPEN blocks (questions, RFCs,
+reviews) as newest-first link-through cards above a cross-task activity
+timeline (last ~50 events, newest first); the task page leads with its
+status block and orders blocking sections newest-first.
 
 | Component | Lives | Role |
 |---|---|---|
@@ -238,12 +249,18 @@ contract, and asserts the web-stack features: plan-review body hiding,
 the effective `rules` array of assignments, project notes with the
 `/api/saddle/note` callback and the payload's `project_notes`/`callback`
 caps, the ask/allocation flip (concurrent asks race one winner, affinity
-pinning, the lost-wake guard), and the phase-2 telemetry flip (pushed
+pinning, the lost-wake guard), the phase-2 telemetry flip (pushed
 chunks idempotent and ordered into the terminal view, results pushes
 with blocked/suspended/done/failed state transitions, entry-redelivery
 idempotency, the one-probe staleness policy with its dead-daemon
 requeue, and the retired `/output`//`/log`//`/start` daemon routes 404ing
-while the web side never calls them).
+while the web side never calls them), and the phase-3 capability
+awareness + UI (the assignment `capabilities` context,
+`GET /api/saddle/capabilities`, subtask `--capability` validation with
+the valid-tag list in the error, the task page's top status block and
+newest-first blocking sections, the dashboard's pinned link-through
+cards above the activity timeline, and the slow-ask lost-wake
+regression).
 
 ## Realtime channels and the browser's SSE budget
 

+ 1 - 0
meson.build

@@ -126,6 +126,7 @@ app_sources = files(
     'src/Services/RuleService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/SubtaskEventQueue.vala',
+    'src/Services/TimelineService.vala',
     'src/Api/SaddleApi.vala',
     'src/Api/TerminalSse.vala',
     'src/actions/Forms.vala',

+ 122 - 2
src/Api/SaddleApi.vala

@@ -481,6 +481,58 @@ namespace ClankerSaddle.Api {
         }
     }
 
+    /**
+     * `GET /api/saddle/capabilities` (plan 10 phase 3) — the fleet's
+     * tag catalogue behind the daemon's `saddle capabilities` CLI:
+     * per-saddle-token authenticated (plan 10), every operator-managed
+     * tag with the saddles that carry it, tag order by name.
+     *
+     * ```json
+     * {"ok": true, "tags": [{"name": "gpu", "saddles": ["saddle-1"]}]}
+     * ```
+     */
+    public class SaddleCapabilitiesEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private CapabilityService capabilities = inject<CapabilityService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            if (!(yield validate_saddle_bearer(http_context, saddles))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
+            }
+
+            try {
+                // list_saddles is name-ordered, so iterating saddles in
+                // the outer loop yields each tag's carriers by name.
+                var saddle_rows = yield saddles.list_saddles();
+                var carrier_keys = new HashSet<string>();
+                foreach (var link in yield capabilities.all_saddle_attachments()) {
+                    carrier_keys.add(@"$(link.capability_id):$(link.saddle_id)");
+                }
+
+                var tags = new Json.Array();
+                foreach (var capability in yield capabilities.list_capabilities()) {
+                    var element = new Json.Object();
+                    element.set_string_member("name", capability.name);
+                    var saddles_array = new Json.Array();
+                    foreach (var saddle in saddle_rows) {
+                        if (carrier_keys.has(@"$(capability.id):$(saddle.id)")) {
+                            saddles_array.add_string_element(saddle.name);
+                        }
+                    }
+                    element.set_array_member("saddles", saddles_array);
+                    tags.add_object_element(element);
+                }
+                var body = new Json.Object();
+                body.set_array_member("tags", tags);
+                return SaddleUpdateEndpoint.json_ok(body);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+        }
+    }
+
     /**
      * `POST /api/saddle/subtask` — the synchronous subtask-creation
      * callback a daemon's `saddle subtask` CLI posts to (wire contract):
@@ -491,9 +543,16 @@ namespace ClankerSaddle.Api {
      * (parent, title): a retried callback or the later entry-poll
      * fallback returns the existing task's id instead of duplicating.
      *
+     * `capabilities: [tags]` (plan 10 phase 3, `saddle subtask
+     * --capability`) attaches required tags to the new task — explicit
+     * only, never inherited from the parent — and is validated against
+     * the operator tag set first: an unknown tag rejects the call with
+     * an error naming the valid tags (the agent reads it verbatim in
+     * the CLI output).
+     *
      * ```json
      * {"parent_task_id": 1, "title": "…", "description": "…",
-     *  "depends_on": [3, 4], "session_id": 2}
+     *  "depends_on": [3, 4], "capabilities": ["gpu"], "session_id": 2}
      * ```
      *
      * → `{"ok": true, "task_id": 7}`.
@@ -501,8 +560,8 @@ namespace ClankerSaddle.Api {
     public class SaddleSubtaskEndpoint : Object, Endpoint {
 
         private SaddleService saddles = inject<SaddleService>();
-        private ProjectService projects = inject<ProjectService>();
         private TaskService tasks = inject<TaskService>();
+        private CapabilityService capabilities = inject<CapabilityService>();
 
         public virtual async HttpResult handle_request(HttpContext http_context,
                 RouteContext route_context) throws Error {
@@ -531,6 +590,38 @@ namespace ClankerSaddle.Api {
                     @"No task exists with id $(parent_task_id)");
             }
 
+            string[] requested_tags = {};
+            try {
+                requested_tags = capability_tags_of(body);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+            var resolved_capabilities = new Vector<Capability>();
+            if (requested_tags.length > 0) {
+                var unknown = new Vector<string>();
+                foreach (string tag in requested_tags) {
+                    string trimmed = tag.strip();
+                    if (trimmed.length == 0) {
+                        continue;
+                    }
+                    var capability = yield capabilities.find_by_name(trimmed);
+                    if (capability == null) {
+                        unknown.add(trimmed);
+                    } else {
+                        resolved_capabilities.add((!)capability);
+                    }
+                }
+                if (unknown.length > 0) {
+                    var valid = new Vector<string>();
+                    foreach (var capability in yield capabilities.list_capabilities()) {
+                        valid.add(capability.name);
+                    }
+                    return SaddleUpdateEndpoint.json_error(400,
+                        @"Unknown capability tag(s): $(string.joinv(", ", unknown.to_array()))"
+                        + @" — valid tags: $(string.joinv(", ", valid.to_array()))");
+                }
+            }
+
             int64[] depends_on = {};
             if (body.has_member("depends_on")
                     && body.get_member("depends_on").get_node_type() == Json.NodeType.ARRAY) {
@@ -565,6 +656,11 @@ namespace ClankerSaddle.Api {
                     yield tasks.log_session_entry(parent_task_id, "subtask",
                         @"saddle subtask: $(title)", @"created task #$(task.id)");
                 }
+                // Explicit-only capability attachment (never inherited):
+                // idempotent, so a retried callback re-attaches nothing.
+                foreach (var capability in resolved_capabilities) {
+                    yield capabilities.attach_to_task(capability.id, task.id);
+                }
                 var response = new Json.Object();
                 response.set_int_member("task_id", task.id);
                 return SaddleUpdateEndpoint.json_ok(response);
@@ -572,6 +668,30 @@ namespace ClankerSaddle.Api {
                 return SaddleUpdateEndpoint.json_error(400, e.message);
             }
         }
+
+        /**
+         * The `capabilities` member as a tag array: absent → empty; a
+         * native JSON array of strings → its elements; anything else →
+         * an error (the daemon always sends the array form).
+         */
+        private static string[] capability_tags_of(Json.Object body) throws Error {
+            if (!body.has_member("capabilities")) {
+                return new string[0];
+            }
+            var member = body.get_member("capabilities");
+            if (member.get_node_type() != Json.NodeType.ARRAY) {
+                throw new Error(Quark.from_string("clanker-saddle"), 46,
+                    "capabilities must be a JSON array of tag names");
+            }
+            var tags = new Vector<string>();
+            foreach (var element in member.get_array().get_elements()) {
+                if (element.get_node_type() == Json.NodeType.VALUE
+                        && element.get_string() != null) {
+                    tags.add(element.get_string());
+                }
+            }
+            return tags.to_array();
+        }
     }
 
     /**

+ 10 - 0
src/Services/CapabilityService.vala

@@ -49,6 +49,16 @@ namespace ClankerSaddle.Services {
                 .first_async();
         }
 
+        /**
+         * The capability named exactly `name`, or null — the subtask
+         * callback's tag validation lookup (names are unique).
+         */
+        public async Capability? find_by_name(string name) throws Error {
+            return yield db.query<Capability>()
+                .where(expr("name == $0", elem(name)))
+                .first_async();
+        }
+
         /** Every capability ordered by name. */
         public async ImmutableLot<Capability> list_capabilities() throws Error {
             return yield db.query<Capability>()

+ 27 - 1
src/Services/SaddleService.vala

@@ -97,6 +97,7 @@ namespace ClankerSaddle.Services {
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private StatePulse pulse = inject<StatePulse>();
         private RuleService rules = inject<RuleService>();
+        private CapabilityService capability_service = inject<CapabilityService>();
         private TaskService task_service = inject<TaskService>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
@@ -657,7 +658,10 @@ namespace ClankerSaddle.Services {
          * the top-level `rules` array, the capped `project_notes` of
          * the primary and co-developed projects, the note-creation
          * `callback` (base URL + the saddle's own bearer token), the
-         * saddle's `git_identity`, and — for resumed workspaces — the
+         * saddle's `git_identity`, a `capabilities` context (plan 10
+         * phase 3: `saddle_tags` — the allocated saddle's own tags —
+         * and `fleet_tags` — every tag name known across the fleet),
+         * and — for resumed workspaces — the
          * `resume_notice`/`branch_advanced` context. `session_id` (the
          * web-side run row the daemon reports against) rides along so
          * pushed telemetry can key on it. Project checkouts are
@@ -853,6 +857,28 @@ namespace ClankerSaddle.Services {
                 payload.end_object();
             }
 
+            // The wire contract's `capabilities` context (plan 10
+            // phase 3): the allocated saddle's own tags plus every tag
+            // name known across the fleet (the operator-managed
+            // catalogue) — the daemon renders both into its system
+            // prompt with the subtask tagging rule, and the same set
+            // validates `saddle subtask --capability`.
+            payload.set_member_name("capabilities");
+            payload.begin_object();
+            payload.set_member_name("saddle_tags");
+            payload.begin_array();
+            foreach (var carried in yield capability_service.list_for_saddle(saddle.id)) {
+                payload.add_string_value(carried.name);
+            }
+            payload.end_array();
+            payload.set_member_name("fleet_tags");
+            payload.begin_array();
+            foreach (var known in yield capability_service.list_capabilities()) {
+                payload.add_string_value(known.name);
+            }
+            payload.end_array();
+            payload.end_object();
+
             // The wire contract's `resume_notice` addition: on a
             // workspace-adopting start (a suspended parent's resume), the
             // daemon injects it as the next user turn instead of the

+ 190 - 0
src/Services/TimelineService.vala

@@ -0,0 +1,190 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /** One materialised cross-task timeline event (plan 10 phase 3). */
+    public class TimelineEvent : Object {
+        /** created | started | suspended | completed | failed |
+         *  block-raised | block-answered | comment | progress | dispatch */
+        public string kind { get; set; default = ""; }
+        /** The event's epoch second — the newest-first sort key. */
+        public int64 at_epoch { get; set; }
+        public int64 task_id { get; set; }
+        public string task_title { get; set; default = ""; }
+        public string text { get; set; default = ""; }
+        /** Build-order tiebreak for same-second events. */
+        public int64 sequence { get; set; }
+    }
+
+    /**
+     * The home dashboard's cross-task activity timeline (plan 10
+     * phase 3): the last {@link WINDOW} events across every task,
+     * newest first — status changes (started, suspended), blocks
+     * raised and answered, comments (author + snippet), progress
+     * updates, completions/failures and session dispatches —
+     * materialised per request from the existing tables (tasks,
+     * task_blocks, task_comments, task_progress, sessions). Nothing is
+     * archived or aggregated: at this fleet size a bounded newest-cut
+     * per source, merged and re-sorted, is the whole job.
+     */
+    public class TimelineService : Object {
+
+        /** The dashboard timeline window (events shown, newest first). */
+        public const int WINDOW = 50;
+
+        /** Snippet clamp for bodies, questions and summaries. */
+        public const int SNIPPET_MAX = 160;
+
+        private OrmSession db = inject<OrmSession>();
+
+        /**
+         * The recent event window, newest first. Each source
+         * contributes its newest WINDOW rows by id; the merged view is
+         * sorted by event time (epoch desc, then materialisation order
+         * for same-second ties) and trimmed to WINDOW.
+         */
+        public async Vector<TimelineEvent> recent_window() throws Error {
+            var titles = new Dictionary<string, string>();
+            foreach (var task in yield db.query<TaskEntity>().materialise_async()) {
+                titles.set(task.id.to_string(), task.title);
+            }
+            var saddle_names = new Dictionary<string, string>();
+            foreach (var saddle in yield db.query<Saddle>().materialise_async()) {
+                saddle_names.set(saddle.id.to_string(), saddle.name);
+            }
+
+            var events = new Vector<TimelineEvent>();
+            int64 sequence = 0;
+
+            foreach (var task in yield db.query<TaskEntity>()
+                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
+                string title = titles.get_or_default(task.id.to_string()) ?? task.title;
+                if (task.created_at != null) {
+                    events.add(new TimelineEvent() {
+                        kind = "created", at_epoch = ((!)task.created_at).to_unix(),
+                        task_id = task.id, task_title = title, text = "Task created",
+                        sequence = sequence++
+                    });
+                }
+                if (task.started_at != null) {
+                    events.add(new TimelineEvent() {
+                        kind = "started", at_epoch = ((!)task.started_at).to_unix(),
+                        task_id = task.id, task_title = title, text = "Status → working",
+                        sequence = sequence++
+                    });
+                }
+                if (task.suspended_at != null) {
+                    events.add(new TimelineEvent() {
+                        kind = "suspended", at_epoch = ((!)task.suspended_at).to_unix(),
+                        task_id = task.id, task_title = title,
+                        text = "Parked — waiting on subtasks", sequence = sequence++
+                    });
+                }
+                if (task.completed_at != null) {
+                    bool done = task.status == STATUS_DONE;
+                    string text = done ? "Task completed" : "Task failed";
+                    if ((task.summary ?? "").length > 0) {
+                        text += ": " + snippet(task.summary);
+                    }
+                    events.add(new TimelineEvent() {
+                        kind = done ? "completed" : "failed",
+                        at_epoch = ((!)task.completed_at).to_unix(),
+                        task_id = task.id, task_title = title, text = text,
+                        sequence = sequence++
+                    });
+                }
+            }
+
+            foreach (var block in yield db.query<TaskBlock>()
+                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
+                if (block.created_at != null) {
+                    events.add(new TimelineEvent() {
+                        kind = "block-raised", at_epoch = ((!)block.created_at).to_unix(),
+                        task_id = block.task_id,
+                        task_title = titles.get_or_default(block.task_id.to_string()) ?? "",
+                        text = @"$(block.block_type) raised: $(snippet(block.question))",
+                        sequence = sequence++
+                    });
+                }
+                if (block.answered_at != null) {
+                    events.add(new TimelineEvent() {
+                        kind = "block-answered", at_epoch = ((!)block.answered_at).to_unix(),
+                        task_id = block.task_id,
+                        task_title = titles.get_or_default(block.task_id.to_string()) ?? "",
+                        text = @"$(block.block_type) answered: $(snippet(block.answer ?? ""))",
+                        sequence = sequence++
+                    });
+                }
+            }
+
+            foreach (var comment in yield db.query<TaskComment>()
+                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
+                if (comment.created_at == null) {
+                    continue;
+                }
+                events.add(new TimelineEvent() {
+                    kind = "comment", at_epoch = ((!)comment.created_at).to_unix(),
+                    task_id = comment.task_id,
+                    task_title = titles.get_or_default(comment.task_id.to_string()) ?? "",
+                    text = @"$(comment.author): $(snippet(comment.body))",
+                    sequence = sequence++
+                });
+            }
+
+            foreach (var progress in yield db.query<TaskProgress>()
+                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
+                if (progress.created_at == null) {
+                    continue;
+                }
+                events.add(new TimelineEvent() {
+                    kind = "progress", at_epoch = ((!)progress.created_at).to_unix(),
+                    task_id = progress.task_id,
+                    task_title = titles.get_or_default(progress.task_id.to_string()) ?? "",
+                    text = snippet(progress.summary), sequence = sequence++
+                });
+            }
+
+            foreach (var session in yield db.query<Session>()
+                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
+                if (session.started_at == null) {
+                    continue;
+                }
+                string saddle = saddle_names.get_or_default(session.saddle_id.to_string())
+                    ?? @"saddle #$(session.saddle_id)";
+                events.add(new TimelineEvent() {
+                    kind = "dispatch", at_epoch = ((!)session.started_at).to_unix(),
+                    task_id = session.task_id,
+                    task_title = titles.get_or_default(session.task_id.to_string()) ?? "",
+                    text = @"Dispatched to $(saddle)", sequence = sequence++
+                });
+            }
+
+            var sorted = events.sort((a, b) => {
+                if (a.at_epoch != b.at_epoch) {
+                    return a.at_epoch > b.at_epoch ? -1 : 1;
+                }
+                return a.sequence > b.sequence ? -1 : 1;
+            });
+            var trimmed = new Vector<TimelineEvent>();
+            int64 taken = 0;
+            foreach (var item in sorted) {
+                if (taken >= WINDOW) {
+                    break;
+                }
+                trimmed.add(item);
+                taken++;
+            }
+            return trimmed;
+        }
+
+        private static string snippet(string body) {
+            string flat = body.replace("\n", " ");
+            return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat;
+        }
+    }
+}

+ 2 - 1
src/actions/DashboardActions.vala

@@ -15,6 +15,7 @@ namespace ClankerSaddle {
 
         private TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
+        private TimelineService timeline_service = inject<TimelineService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = inject<UserService>();
 
@@ -25,7 +26,7 @@ namespace ClankerSaddle {
             }
 
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, db);
+                saddle_service, timeline_service, db);
             return directives().set(state);
         }
     }

+ 57 - 86
src/entrypoints/HomeEntrypoint.vala

@@ -19,9 +19,10 @@ namespace ClankerSaddle {
         public string current_task_title { get; set; default = ""; }
     }
 
-    /** One entry in the dashboard's recent activity feed. */
+    /** One entry in the dashboard's cross-task activity timeline. */
     public class DashboardActivityView : Object {
         public string kind { get; set; default = ""; }
+        public int64 at_epoch { get; set; }
         public string when_display { get; set; default = ""; }
         public int64 task_id { get; set; }
         public string task_title { get; set; default = ""; }
@@ -38,7 +39,13 @@ namespace ClankerSaddle {
         public string task_branch { get; set; default = ""; }
     }
 
-    /** A block awaiting a human answer. */
+    /**
+     * A pinned OPEN blocking item (plan 10 phase 3): an unanswered
+     * question, RFC or review across any task, rendered as a
+     * link-through card — task title, block kind badge, question
+     * title, and a link to the task page where the answer form lives.
+     * No inline answer forms on the dashboard (settled).
+     */
     public class DashboardBlockView : Object {
         public int64 id { get; set; }
         public int64 task_id { get; set; }
@@ -48,15 +55,6 @@ namespace ClankerSaddle {
         public string created_display { get; set; default = ""; }
     }
 
-    /** A pending review request with its diff link. */
-    public class DashboardReviewView : Object {
-        public int64 id { get; set; }
-        public int64 task_id { get; set; }
-        public string task_title { get; set; default = ""; }
-        public string summary { get; set; default = ""; }
-        public string diff_url { get; set; default = ""; }
-    }
-
     /** Public data of the dashboard's `dashboard` slot. */
     public class DashboardPublic : Object {
         public int64 queued_count { get; set; }
@@ -72,9 +70,11 @@ namespace ClankerSaddle {
     /**
      * Builds the `dashboard` slot state shared by the home entrypoint and
      * the {@link RefreshDashboardAction} pulse refresh, so both hydrate
-     * identical state: queue counters, saddle cards, active tasks with
-     * their latest progress, blocks and reviews awaiting a human, and the
-     * recent activity feed.
+     * identical state: queue counters, saddle cards, the pinned OPEN
+     * blocking items as link-through cards (newest first — plan 10
+     * phase 3), active tasks with their latest progress, and the
+     * cross-task activity timeline ({@link TimelineService}: last ~50
+     * events, newest first).
      */
     public class DashboardState : Object {
 
@@ -83,7 +83,8 @@ namespace ClankerSaddle {
          * `db` session comes from the caller (entrypoint or action scope).
          */
         public static async Statum.Slot build(StateService state_service, TaskService tasks,
-                SaddleService saddles, InvercargillSql.Orm.OrmSession db,
+                SaddleService saddles, TimelineService timeline,
+                InvercargillSql.Orm.OrmSession db,
                 ActionRegistry? actions = null) throws GLib.Error {
             var pub = new DashboardPublic();
             var titles = new Dictionary<string, string>();
@@ -135,8 +136,7 @@ namespace ClankerSaddle {
             }
 
             var active = new Vector<DashboardActiveView>();
-            var open_blocks = new Vector<DashboardBlockView>();
-            var reviews = new Vector<DashboardReviewView>();
+            var pinned = new Vector<DashboardBlockView>();
             foreach (var task in yield tasks.list_tasks(null, null)) {
                 if (task.status == STATUS_ASSIGNED || task.status == STATUS_WORKING
                         || task.status == STATUS_BLOCKED || task.status == STATUS_REVIEW_REQUESTED) {
@@ -154,44 +154,53 @@ namespace ClankerSaddle {
                     active.add(row);
                 }
 
-                var blocks = yield tasks.list_blocks(task.id);
-                foreach (var block in blocks) {
+                // Every unanswered block pins as a link-through card —
+                // questions, RFCs and reviews alike.
+                foreach (var block in yield tasks.list_blocks(task.id)) {
                     if (block.answer != null) {
                         continue;
                     }
-                    if (block.block_type == BLOCK_REVIEW) {
-                        reviews.add(new DashboardReviewView() {
-                            id = block.id,
-                            task_id = task.id,
-                            task_title = task.title,
-                            summary = (block.body ?? block.question).strip(),
-                            diff_url = yield tasks.diff_url_for_task(task.id)
-                        });
-                    } else {
-                        open_blocks.add(new DashboardBlockView() {
-                            id = block.id,
-                            task_id = task.id,
-                            task_title = task.title,
-                            block_type = block.block_type,
-                            question = block.question,
-                            created_display = format_time(block.created_at)
-                        });
-                    }
+                    pinned.add(new DashboardBlockView() {
+                        id = block.id,
+                        task_id = task.id,
+                        task_title = task.title,
+                        block_type = block.block_type,
+                        question = block.question,
+                        created_display = format_time(block.created_at)
+                    });
                 }
             }
+            // Newest first (plan 10 phase 3): the most recent question,
+            // RFC or review leads the pinned list.
+            var pinned_sorted = pinned.sort((a, b) => {
+                if (a.id != b.id) {
+                    return a.id > b.id ? -1 : 1;
+                }
+                return 0;
+            });
 
             if (actions != null) {
                 pub.refresh = ((!)actions).author<RefreshDashboardAction>("GET");
             }
 
-            var activity = yield build_activity(db, titles);
+            var activity = new Vector<DashboardActivityView>();
+            foreach (var item in yield timeline.recent_window()) {
+                activity.add(new DashboardActivityView() {
+                    kind = item.kind,
+                    at_epoch = item.at_epoch,
+                    when_display = new DateTime.from_unix_local(item.at_epoch)
+                        .format("%Y-%m-%d %H:%M"),
+                    task_id = item.task_id,
+                    task_title = item.task_title,
+                    text = item.text
+                });
+            }
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["saddles"] = StateJson.list<DashboardSaddleView>(saddle_rows.to_immutable_buffer());
             props["activity"] = StateJson.list<DashboardActivityView>(activity.to_immutable_buffer());
             props["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
-            props["open_blocks"] = StateJson.list<DashboardBlockView>(open_blocks.to_immutable_buffer());
-            props["reviews"] = StateJson.list<DashboardReviewView>(reviews.to_immutable_buffer());
+            props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.to_immutable_buffer());
 
             return state_service.new_slot(Scope.PAGE, new Statum.State() {
                 type_name = "dashboard",
@@ -200,62 +209,24 @@ namespace ClankerSaddle {
             });
         }
 
-        private static async Vector<DashboardActivityView> build_activity(
-                InvercargillSql.Orm.OrmSession db,
-                Dictionary<string, string> titles) throws GLib.Error {
-            var activity = new Vector<DashboardActivityView>();
-            foreach (var comment in yield db.query<TaskComment>().order_by_desc(expr("id")).limit(10).materialise_async()) {
-                activity.add(new DashboardActivityView() {
-                    kind = "comment",
-                    when_display = format_time(comment.created_at),
-                    task_id = comment.task_id,
-                    task_title = titles.get_or_default(comment.task_id.to_string()) ?? "",
-                    text = @"$(comment.author): $(comment.body)"
-                });
-            }
-            foreach (var task in yield db.query<TaskEntity>().order_by_desc(expr("id")).limit(10).materialise_async()) {
-                activity.add(new DashboardActivityView() {
-                    kind = "created",
-                    when_display = format_time(task.created_at),
-                    task_id = task.id,
-                    task_title = task.title,
-                    text = "Task created"
-                });
-                if (task.completed_at != null) {
-                    activity.add(new DashboardActivityView() {
-                        kind = task.status,
-                        when_display = format_time(task.completed_at),
-                        task_id = task.id,
-                        task_title = task.title,
-                        text = task.status == STATUS_DONE ? "Task completed" : "Task failed"
-                    });
-                }
-            }
-
-            var sorted = activity.sort((a, b) => strcmp(b.when_display, a.when_display));
-            var trimmed = new Vector<DashboardActivityView>();
-            foreach (var item in sorted.take(15)) {
-                trimmed.add(item);
-            }
-            return trimmed;
-        }
-
         private static string format_time(DateTime? time) {
             return time != null ? ((!)time).format("%Y-%m-%d %H:%M") : "";
         }
     }
 
     /**
-     * Entrypoint for /: the dashboard — queue summary, saddle cards,
-     * active tasks with their latest progress, blocks and review requests
-     * awaiting a human, and a recent activity feed. The page subscribes
-     * to the global pulse and re-runs its sealed refresh action whenever
-     * task or saddle state changes anywhere in the app.
+     * Entrypoint for /: the dashboard — queue summary, the pinned OPEN
+     * blocking items as link-through cards (newest first), active tasks
+     * with their latest progress, saddle cards, and the cross-task
+     * activity timeline (last ~50 events, newest first). The page
+     * subscribes to the global pulse and re-runs its sealed refresh
+     * action whenever task or saddle state changes anywhere in the app.
      */
     public class HomeEntrypoint : StatumEntrypoint {
 
         private TaskService task_service = Inversion.inject<TaskService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
+        private TimelineService timeline_service = Inversion.inject<TimelineService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
@@ -266,7 +237,7 @@ namespace ClankerSaddle {
             }
 
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, db, action_registry);
+                saddle_service, timeline_service, db, action_registry);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 9 - 2
src/entrypoints/TaskDetailEntrypoint.vala

@@ -232,7 +232,9 @@ namespace ClankerSaddle {
                 });
             }
 
-            var blocks = new Vector<BlockView>();
+            // Newest-first (plan 10 phase 3): the blocking sections
+            // lead with the most recent question, RFC or review.
+            var blocks_ascending = new Vector<BlockView>();
             foreach (var block in ((!)detail).blocks) {
                 var block_private = new BlockAnswerPrivate() {
                     task_id = task.id,
@@ -264,7 +266,12 @@ namespace ClankerSaddle {
                 // hidden until the block is answered.
                 view.show_body = (!view.is_plan_review && view.block_type != BLOCK_REVIEW)
                     || view.answered;
-                blocks.add(view);
+                blocks_ascending.add(view);
+            }
+            var blocks = new Vector<BlockView>();
+            var block_array = blocks_ascending.to_array();
+            for (var index = block_array.length - 1; index >= 0; index--) {
+                blocks.add(block_array[index]);
             }
 
             var subtasks = new Vector<SubtaskView>();

+ 3 - 0
src/main.vala

@@ -63,6 +63,7 @@ int main(string[] args) {
         application.add_scoped<RuleService>();
         application.add_scoped<CapabilityService>();
         application.add_scoped<AllocationService>();
+        application.add_scoped<TimelineService>();
         application.add_singleton<SubtaskEventQueue>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<StatePulse>();
@@ -150,6 +151,8 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/tasks", Method.GET));
         application.add_endpoint<SaddleTaskDetailEndpoint>(
             new EndpointRoute("/api/saddle/task/{id}", Method.GET));
+        application.add_endpoint<SaddleCapabilitiesEndpoint>(
+            new EndpointRoute("/api/saddle/capabilities", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.TerminalSseEndpoint>(
             new EndpointRoute("/terminal-stream/{session_id}", Method.GET));
 

+ 18 - 0
src/pages/app.css

@@ -126,6 +126,13 @@ form.inline textarea { min-height: 3rem; }
 
 .card h3 { margin: 0 0 var(--grid-1); }
 
+/* The task page's status block — pinned to the very top of the page
+   (plan 10 phase 3), above the cards grid. */
+.status-head {
+    border-left: 4px solid #0969da;
+    margin: var(--grid-2) 0;
+}
+
 table { border-collapse: collapse; width: 100%; background: var(--surface); }
 th, td { border: 1px solid var(--line); padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
 th { background: var(--surface-sunken); }
@@ -219,6 +226,17 @@ summary { cursor: pointer; font-weight: 600; }
 .badge-plan { background: #f3e8ff; color: var(--plan); }
 .badge-question { background: #ddf4ff; color: #0969da; }
 .badge-rfc { background: #f3e8ff; color: var(--plan); }
+
+/* Dashboard timeline event kinds (plan 10 phase 3) */
+.badge-created { background: var(--line-soft); color: var(--ink-muted); }
+.badge-started { background: #ddf4ff; color: #0969da; }
+.badge-suspended { background: #fff8c5; color: var(--warning); }
+.badge-completed { background: #dafbe1; color: var(--success); }
+.badge-failed { background: #ffebe9; color: var(--danger); }
+.badge-block-raised { background: #fff8c5; color: var(--warning); }
+.badge-block-answered { background: #dafbe1; color: var(--success); }
+.badge-progress { background: #dafbe1; color: var(--success); }
+.badge-dispatch { background: #ddf4ff; color: #0969da; }
 .badge-query { background: #ddf4ff; color: #0969da; }
 .badge-shell-reset { background: var(--line-soft); color: var(--ink-muted); }
 

+ 15 - 14
src/pages/home.html

@@ -43,23 +43,21 @@
                 </section>
             </div>
 
-            <h3 stm-if="dashboard.reviews.length > 0">Reviews pending</h3>
-            <div stm-for-review-in="dashboard.reviews" class="review-banner">
-                <h3>Review — <a stm-attribute.href="'/tasks/' + review.task_id" stm-text="review.task_title">task</a></h3>
-                <div class="md" stm-markdown="review.summary">summary</div>
-                <p><a stm-if="review.diff_url.length > 0" stm-attribute.href="review.diff_url"
-                      target="_blank" rel="noopener">compare changes</a></p>
-            </div>
-
-            <h3 stm-if="dashboard.open_blocks.length > 0">Needs an answer</h3>
-            <div class="cards" stm-if="dashboard.open_blocks.length > 0">
-                <section class="card" stm-for-blockview-in="dashboard.open_blocks">
+            <h3 stm-if="dashboard.pinned.length > 0">Needs a human
+                (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
+            <p class="muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and
+                reviews across all tasks, newest first — follow the link to answer on the
+                task page.</p>
+            <div class="cards" stm-if="dashboard.pinned.length > 0">
+                <section class="card" stm-for-blockview-in="dashboard.pinned">
                     <p><span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
                         <span class="muted" stm-text="blockview.created_display">when</span></p>
                     <p><strong stm-text="blockview.question">question</strong></p>
                     <p><a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
                 </section>
             </div>
+            <p stm-if="dashboard.pinned.length == 0" class="muted">Nothing needs a human —
+                no open questions, RFCs or reviews.</p>
 
             <h3 stm-if="dashboard.active_tasks.length > 0">Active tasks</h3>
             <table stm-if="dashboard.active_tasks.length > 0">
@@ -91,14 +89,17 @@
                 </section>
             </div>
 
-            <h3>Recent activity</h3>
+            <h3>Activity</h3>
+            <p class="muted">The last <span stm-text="dashboard.activity.length">0</span> events
+                across all tasks, newest first.</p>
             <table>
-                <thead><tr><th>When</th><th>Task</th><th>Activity</th></tr></thead>
+                <thead><tr><th>When</th><th>Task</th><th>Event</th></tr></thead>
                 <tbody>
                     <tr stm-for-item-in="dashboard.activity">
                         <td stm-text="item.when_display">when</td>
                         <td><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a></td>
-                        <td stm-text="item.text">activity</td>
+                        <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
+                            <span stm-text="item.text">activity</span></td>
                     </tr>
                     <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="muted">Nothing yet.</td></tr>
                 </tbody>

+ 27 - 25
src/pages/task_detail.html

@@ -235,32 +235,34 @@
                 <span stm-if="task.parent_task_id > 0"> · subtask of
                     <a stm-attribute.href="'/tasks/' + task.parent_task_id" stm-text="'#' + task.parent_task_id">#parent</a></span></p>
 
-            <div class="cards">
-                <section class="card">
-                    <h3>Status</h3>
-                    <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
-                        · priority <strong stm-text="task.priority">5</strong>
-                        <span stm-if="task.is_design_task"> · <strong>plan review required</strong></span></p>
-                    <p class="muted">Created <span stm-text="task.created_display">when</span></p>
-                    <p class="muted" stm-if="task.started_display.length > 0">Started <span stm-text="task.started_display">when</span></p>
-                    <p class="muted" stm-if="task.completed_display.length > 0">Completed <span stm-text="task.completed_display">when</span></p>
-                    <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
-                        <span class="muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
-                    <p stm-if="task.working_branch.length > 0" class="muted">Working branch: <span stm-text="task.working_branch">branch</span></p>
-                    <p stm-if="task.status == 'suspended'" class="muted">Parked — waiting on
-                        subtasks (<span stm-text="task.subtask_open">0</span> open). The task
-                        resumes with a status report whenever one finishes.</p>
-                    <form class="inline">
-                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                        <button type="button" stm-if="task.status == 'failed'" class="primary"
-                                stm-action="task.retry">Retry task</button>
-                        <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>
-                        <button type="button" stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
-                    </form>
-                </section>
+            <!-- Status block pinned to the very top (plan 10): the
+                 current state is the first thing on the page. -->
+            <section class="card status-head">
+                <h3>Status</h3>
+                <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
+                    · priority <strong stm-text="task.priority">5</strong>
+                    <span stm-if="task.is_design_task"> · <strong>plan review required</strong></span></p>
+                <p class="muted">Created <span stm-text="task.created_display">when</span></p>
+                <p class="muted" stm-if="task.started_display.length > 0">Started <span stm-text="task.started_display">when</span></p>
+                <p class="muted" stm-if="task.completed_display.length > 0">Completed <span stm-text="task.completed_display">when</span></p>
+                <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
+                    <span class="muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
+                <p stm-if="task.working_branch.length > 0" class="muted">Working branch: <span stm-text="task.working_branch">branch</span></p>
+                <p stm-if="task.status == 'suspended'" class="muted">Parked — waiting on
+                    subtasks (<span stm-text="task.subtask_open">0</span> open). The task
+                    resumes with a status report whenever one finishes.</p>
+                <form class="inline">
+                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                    <button type="button" stm-if="task.status == 'failed'" class="primary"
+                            stm-action="task.retry">Retry task</button>
+                    <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>
+                    <button type="button" stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
+                </form>
+            </section>
 
+            <div class="cards">
                 <section class="card">
                     <h3>Description</h3>
                     <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>

BIN
test/__pycache__/integration_test.cpython-314.pyc


BIN
test/__pycache__/web_stack_test.cpython-314.pyc


+ 8 - 6
test/integration_test.py

@@ -484,8 +484,9 @@ def main():
         check(detail["review_diff_url"].endswith(
             "demo.git/compare/main...task/3-feature-tour"), "review diff link constructed")
         dash = slot_public(auth, "/", "dashboard")
-        check(any(int(r["task_id"]) == 3 for r in dash["reviews"]),
-              "dashboard lists the pending review")
+        check(any(int(r["task_id"]) == 3 and r["block_type"] == "review"
+                  for r in dash["pinned"]),
+              "dashboard pins the pending review")
         review_block = [b for b in detail["blocks"]
                         if b["block_type"] == "review" and not b.get("answered")]
         check(len(review_block) == 1, "review raised as an answerable block")
@@ -699,10 +700,11 @@ def main():
             "demo.git/compare/main...task/9-api-surface"), "review diff link constructed")
 
         dash = slot_public(auth, "/", "dashboard")
-        check(any(int(r["task_id"]) == api_task for r in dash["reviews"]),
-              "dashboard lists the pending review")
-        check(any(b["question"] == "Which database?" for b in dash["open_blocks"]),
-              "dashboard lists blocks needing answers")
+        check(any(int(r["task_id"]) == api_task and r["block_type"] == "review"
+                  for r in dash["pinned"]),
+              "dashboard pins the pending review")
+        check(any(b["question"] == "Which database?" for b in dash["pinned"]),
+              "dashboard pins blocks needing answers")
 
         piped = db.execute("select piped_input from session_commands where session_id = ?"
                            " and type = 'query'", (api_session,)).fetchall()

+ 264 - 1
test/web_stack_test.py

@@ -53,6 +53,24 @@ Plan 10 phase 2 (telemetry flip):
 - the deleted layers are gone: /output, /log and /start 404 on the
   daemon side, and nothing on the web side calls them
 
+Plan 10 phase 3 (capability awareness + UI):
+
+- assignment capability context: {saddle_tags, fleet_tags} on every
+  payload, GET /api/saddle/capabilities (per-saddle token) feeding
+  the daemon's `saddle capabilities` CLI
+- subtask callback capabilities: explicit-only attachment validated
+  against the operator tag set, unknown tags rejected with the valid
+  list in the error
+- task page: status block pinned to the top, blocking sections
+  newest-first (sealed GET state)
+- home dashboard: pinned OPEN blocks as newest-first link-through
+  cards (no inline answer forms) above the cross-task activity
+  timeline (last ~50 events newest-first)
+- lost-wake-guard regression: a saddle that asks SLOWLY after its
+  wake (busy winding down) still claims the task — the wake is never
+  silently dropped, no re-wake storm fires, and the never-asks guard
+  path (offline + re-wake of the next capable) stays green
+
 Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
 """
 import json
@@ -298,6 +316,7 @@ class FakeSaddle:
         self.ask_on_wake = True
         self.ask_on_terminal = True
         self.ask_on_idle_status = True
+        self.ask_delay = 0.0             # seconds; a slow daemon delays its wake-triggered ask
         self.serve_register = True         # False: 404 /register (pre-flip daemon)
         self.last_ask_trigger = 0.0
         self.port = None
@@ -402,10 +421,15 @@ class FakeSaddle:
         return status, payload
 
     def _trigger_ask(self, reason):
-        """Schedules a delayed ask (the daemon woke / went idle-awake)."""
+        """Schedules a delayed ask (the daemon woke / went idle-awake).
+        ask_delay models a SLOW asker — a daemon still winding down its
+        round when the wake lands: it honours the wake, but its ask
+        arrives late (still inside the web's lost-wake guard window)."""
         def run():
             if self.state in ("working", "failing"):
                 return  # a running daemon does not ask
+            if self.ask_delay > 0:
+                time.sleep(self.ask_delay)
             for attempt in range(3):
                 try:
                     status, _ = self.ask_now()
@@ -2286,6 +2310,245 @@ def main():
         fake3.ask_on_wake = True
         fake3.ask_on_idle_status = True
 
+        print("== phase 3: capability context, catalogue, subtask tags ==")
+        # Assignment capability context (plan 10 phase 3): every
+        # assignment carries {saddle_tags: the allocated saddle's tags,
+        # fleet_tags: every tag name known across the fleet} — the
+        # daemon renders it into its system prompt. fake-saddle carries
+        # gpu + lostwake; the catalogue is affinity-tag, gpu, lostwake.
+        fake2.ask_on_register = False
+        fake2.ask_on_wake = False
+        fake2.ask_on_terminal = False
+        fake2.ask_on_idle_status = False
+        time.sleep(1.2)  # any in-flight trigger asks land
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Capabilities context",
+            "priority": "2",
+        })
+        cap_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                        if t["title"] == "Capabilities context"][0])
+        check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == cap_task
+                                   for p in fake.start_payloads) or False,
+                       timeout=60, what="capabilities-context task dispatched") is not None,
+              "the capabilities-context task dispatches")
+        cap_payload = [p for p in fake.start_payloads
+                       if int(p.get("task", {}).get("id") or 0) == cap_task][-1]
+        context = cap_payload.get("capabilities") or {}
+        check(context.get("saddle_tags") == ["gpu", "lostwake"],
+              "assignment saddle_tags = the allocated saddle's tags (%s)"
+              % context.get("saddle_tags"))
+        check(context.get("fleet_tags") == ["affinity-tag", "gpu", "lostwake"],
+              "assignment fleet_tags = every tag name known across the fleet (%s)"
+              % context.get("fleet_tags"))
+        check(wait_task_done(auth, cap_task, timeout=60) is not None,
+              "capabilities-context task completes")
+
+        # GET /api/saddle/capabilities feeds the daemon's `saddle
+        # capabilities` CLI: per-saddle-token auth, tag order by name,
+        # each tag with the saddles carrying it (name order).
+        status, _ = saddle_api("GET", "/api/saddle/capabilities", None)
+        check(status == 401, "capabilities endpoint rejects a missing token")
+        status, body = saddle_api("GET", "/api/saddle/capabilities", "saddle-bogus")
+        check(status == 401, "capabilities endpoint rejects a wrong token")
+        status, body = saddle_api("GET", "/api/saddle/capabilities", fake_token)
+        catalogue = {t["name"]: t["saddles"] for t in body.get("tags", [])}
+        check(status == 200 and body.get("ok") is True
+              and [t["name"] for t in body.get("tags", [])]
+              == ["affinity-tag", "gpu", "lostwake"],
+              "catalogue lists every tag by name")
+        check(catalogue.get("gpu") == ["fake-saddle"]
+              and catalogue.get("affinity-tag") == ["racer-three", "racer-two"]
+              and catalogue.get("lostwake") == ["fake-saddle", "racer-three"],
+              "each tag names its carrying saddles in name order (%s)" % catalogue)
+
+        # Subtask capabilities (`saddle subtask --capability`): explicit
+        # only — never inherited — and validated against the operator
+        # tag set; unknown tags reject with the valid list in the error
+        # (the agent reads it verbatim in the CLI output).
+        conn = db()
+        tasks_before = conn.execute("select count(*) from tasks").fetchone()[0]
+        conn.close()
+        status, body = saddle_api("POST", "/api/saddle/subtask", r4_token, {
+            "parent_task_id": auto_parent, "title": "Bad tag subtask",
+            "capabilities": ["gpu", "warp-drive"]})
+        conn = db()
+        tasks_after = conn.execute("select count(*) from tasks").fetchone()[0]
+        conn.close()
+        check(status == 400 and tasks_after == tasks_before,
+              "an unknown capability tag rejects the subtask call")
+        error_text = body.get("error", "")
+        check("warp-drive" in error_text and "valid tags" in error_text
+              and "gpu" in error_text and "lostwake" in error_text,
+              "the rejection names the unknown tag and the valid list (%s)" % error_text)
+        status, _ = saddle_api("POST", "/api/saddle/subtask", r4_token, {
+            "parent_task_id": auto_parent, "title": "Bad shape subtask",
+            "capabilities": "gpu"})
+        check(status == 400, "a non-array capabilities member rejects")
+
+        # Explicit-only: a parent requiring 'lostwake' (the lost-wake
+        # task) gets a subtask tagged just 'gpu' — nothing inherited.
+        status, body = saddle_api("POST", "/api/saddle/subtask", r4_token, {
+            "parent_task_id": lost_task, "title": "Explicit tag subtask",
+            "capabilities": ["gpu"]})
+        check(status == 200 and body.get("ok") is True,
+              "a valid tag creates the subtask synchronously")
+        tagged_subtask = int(body["task_id"])
+        detail = task_state(auth, tagged_subtask)
+        check([c["name"] for c in detail.get("capabilities", [])] == ["gpu"],
+              "the subtask carries exactly the explicit tag (no inheritance)")
+        check([c["name"] for c in task_state(auth, lost_task).get("capabilities", [])]
+              == ["lostwake"],
+              "the parent keeps its own requirement")
+        conn = db()
+        tag_rows = conn.execute(
+            "select c.name from task_capabilities tc"
+            " join capabilities c on c.id = tc.capability_id"
+            " where tc.task_id = ?", (tagged_subtask,)).fetchall()
+        conn.close()
+        check([r[0] for r in tag_rows] == ["gpu"],
+              "the task_capabilities rows record the explicit tag")
+        # The queued subtask dispatches (gpu-carrier) and settles before
+        # the slow-wake run needs an empty queue.
+        check(wait_task_done(auth, tagged_subtask, timeout=90) is not None,
+              "the tagged subtask dispatches and completes")
+
+        print("== phase 3: task page — status on top, blocks newest-first ==")
+        detail = task_state(auth, plan_task)
+        block_ids = [int(b["id"]) for b in detail.get("blocks", [])]
+        check(len(block_ids) >= 3 and block_ids == sorted(block_ids, reverse=True),
+              "the blocks section orders newest-first via the sealed GET state (%s)"
+              % block_ids)
+        status, page = http("GET", "/tasks/%d" % plan_task, timeout=15)
+        check(status == 200 and "status-head" in page
+              and page.index("status-head") < page.index("<h3>Description</h3>")
+              and page.index("status-head") < page.index("<h3>Plan</h3>"),
+              "the status block sits at the very top of the task page")
+
+        print("== phase 3: dashboard — pinned blocks + activity timeline ==")
+        # Pinned OPEN blocking items as link-through cards, newest
+        # first; no inline answer forms (the card links to the task
+        # page). At this point: the auto-approve grandchild's question
+        # and RFC plus the parent's own review are all unanswered.
+        dash = slot_public(auth, "/", "dashboard")
+        pinned = dash.get("pinned", [])
+        pinned_ids = [int(b["id"]) for b in pinned]
+        check(len(pinned_ids) >= 3 and pinned_ids == sorted(pinned_ids, reverse=True),
+              "pinned open blocks order newest-first (%s)" % pinned_ids)
+        check({b["block_type"] for b in pinned} >= {"question", "rfc", "review"},
+              "pinned cards cover questions, RFCs and reviews")
+        check(all(b["question"] and b["task_title"] and int(b["task_id"]) > 0
+                  for b in pinned)
+              and all("answer_action" not in b for b in pinned),
+              "pinned cards are link-through only (task title, kind badge, question)")
+
+        # The activity timeline: last <= 50 events, newest first, fed
+        # from the existing tables. Fixture rows a few seconds old
+        # guarantee the progress kind and a human-authored comment ride
+        # along; the probe block below proves same-source ordering.
+        conn = db()
+        conn.execute("insert into task_comments (task_id, author, body, created_at)"
+                     " values (?, 'user:admin', 'timeline fixture comment by a human',"
+                     " strftime('%s','now','-5 seconds'))", (cap_task,))
+        conn.execute("insert into task_progress (task_id, summary, created_at)"
+                     " values (?, 'timeline fixture progress',"
+                     " strftime('%s','now','-5 seconds'))",
+                     (cap_task,))
+        conn.commit()
+        conn.close()
+        dash = slot_public(auth, "/", "dashboard")
+        activity = dash.get("activity", [])
+        epochs = [int(e["at_epoch"]) for e in activity]
+        check(0 < len(activity) <= 50 and epochs == sorted(epochs, reverse=True),
+              "the timeline shows the last <=50 events newest-first (%d events)"
+              % len(activity))
+        kinds = {e["kind"] for e in activity}
+        check({"created", "started", "dispatch", "comment", "progress", "completed"} <= kinds,
+              "the timeline covers status changes, dispatches, comments, progress and"
+              " completions (%s)" % sorted(kinds))
+        check(any(e["kind"] == "comment" and e["text"].startswith("user:admin:")
+                  and "fixture comment" in e["text"] for e in activity),
+              "comment events carry author + snippet")
+
+        # A freshly raised block pins immediately (newest card) and
+        # lands in the timeline as block-raised; answering it records
+        # block-answered.
+        saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
+                   {"type": "block", "session_id": grand_session, "data": {
+                       "question": "Timeline probe: approve the dashboard flow",
+                       "type": "rfc", "body": "# Probe"}})
+        dash = slot_public(auth, "/", "dashboard")
+        check(dash["pinned"][0]["question"] == "Timeline probe: approve the dashboard flow"
+              and int(dash["pinned"][0]["task_id"]) == auto_grand,
+              "the freshest open block pins as the first card")
+        check(dash["activity"][0]["kind"] == "block-raised"
+              and "Timeline probe" in dash["activity"][0]["text"],
+              "the raised block lands atop the timeline")
+        probe_block = [b for b in task_state(auth, auto_grand)["blocks"]
+                       if b["question"] == "Timeline probe: approve the dashboard flow"][0]
+        act(auth, probe_block["answer_action"], {"answer": "Ship the dashboard"})
+        dash = slot_public(auth, "/", "dashboard")
+        activity = dash.get("activity", [])
+        raised_at = next((i for i, e in enumerate(activity)
+                          if e["kind"] == "block-raised" and "Timeline probe" in e["text"]), -1)
+        answered_at = next((i for i, e in enumerate(activity)
+                            if e["kind"] == "block-answered"
+                            and "Ship the dashboard" in e["text"]), -1)
+        check(answered_at >= 0 and raised_at >= 0 and answered_at < raised_at,
+              "the answered event sorts above its raised event (newest-first, same source)")
+        check(all(b["question"] != "Timeline probe: approve the dashboard flow"
+                  for b in dash["pinned"]),
+              "the answered block leaves the pinned cards")
+
+        print("== phase 3 regression: a slow ask after a wake is never dropped ==")
+        # The daemon worker's lost-wake-guard regression: a saddle whose
+        # ask arrives SLOWLY after the wake (busy winding down when the
+        # wake lands) must not lose the wake — the delayed ask (inside
+        # the guard window) claims the task, no re-wake storm fires
+        # while it is pending, and the saddle stays healthy. The
+        # never-asks path (guard -> offline + re-wake of the next
+        # capable) is proven by the racer-three section above.
+        check(wait_for(lambda: queued_count() == 0 or False,
+                       timeout=60, what="queue settles before the slow-wake run") is not None,
+              "the queue settles before the slow-wake run")
+        status, _ = fake2.ask_now()
+        check(status == 204, "the slow-ask racer sleeps before its wake")
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        act(auth, saddles_state["create_capability"], {"name": "slowwake"})
+        slowwake_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")
+                            ["capabilities"] if c["name"] == "slowwake"][0])
+        racer2_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                       if s["name"] == "racer-two"][0]
+        act(auth, racer2_card["attach_capability"], {"capability_id": str(slowwake_cap)})
+        fake2.ask_delay = 1.2   # slow: asks well after the wake, inside the 2s guard
+        fake2.ask_on_wake = True
+        wakes_before_slow = len(fake2.wake_log)
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Slow wake task",
+            "priority": "2",
+            "capability_id": [str(slowwake_cap)],
+        })
+        slow_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                         if t["title"] == "Slow wake task"][0])
+        check(wait_for(lambda: len(fake2.wake_log) > wakes_before_slow or False,
+                       timeout=30, what="the slow-ask racer woken") is not None,
+              "the only capable sleeping saddle is woken")
+        check(wait_task_done(auth, slow_task, timeout=120) is not None,
+              "the delayed ask claims the task — the wake was not dropped")
+        check(any(a.get("status") == 200
+                  and int((a.get("payload") or {}).get("task", {}).get("id") or 0)
+                  == slow_task for a in fake2.asks),
+              "the slow ask's 200 carries the task")
+        check(len(fake2.wake_log) == wakes_before_slow + 1,
+              "the pending-wake skip re-wakes nobody while the slow ask is in flight")
+        racer2_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                       if s["name"] == "racer-two"][0]
+        check(racer2_card["status"] in ("idle", "busy"),
+              "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
+
         # The racers leave the dispatch pool: the remaining sections
         # (rollback resume, R5-R11) predate multi-daemon routing and
         # assert against the primary fake's payloads. racer-three is