Просмотр исходного кода

phase 1: work acquisition flip — ask/allocation, per-saddle tokens, wake computation + lost-wake guard

- POST /api/saddle/next-task (per-saddle token auth): single-transaction
  allocation with workspace affinity (a task parked on this saddle is
  offered only to it) and capability-tag matching; the response is
  today's /start payload (answers, rules, subtasks, project notes,
  callback, llm_config, git_identity, resume_notice/branch_advanced for
  resumed workspaces) plus session_id. Concurrent asks race to exactly
  one winner: BEGIN IMMEDIATE with a fully synchronous claim section.
- per-saddle tokens: saddles.token column (M0010) minted at
  registration and delivered by POST /register {saddle_token,
  web_base_url} (the response's ssh_public_key lands on the row);
  startup copies legacy api_tokens values onto token-less rows (or
  mints fresh ones); removal revokes (row deleted + legacy token rows
  dropped). The shared saddle-callback token is gone — every
  /api/saddle/* call now authenticates by a per-saddle token.
- dispatcher: the pairing/push loop is replaced by wake computation
  (offerable = fresh ready tasks + parked resumes; sleeping = last ask
  returned 204; the affine saddle is targeted first for parked tasks)
  plus the 15s lost-wake guard (SADDLE_LOST_WAKE_S): no ask after a
  wake -> saddle offline, the next capable one is woken. Ask-capable
  daemons resume parked work through the affinity allocation; pre-flip
  daemons (never asked) keep the /answer + /start resume rollback path.
  The /output, /log, /status polling, 3-strike, heal and fence layers
  stay fully functional for phase 2 to retire.
- claim safety: ask allocations commit from HTTP handlers concurrently
  with tick coroutines, so saddle-row writes became single conditional
  UPDATEs (busy/park/free/refresh/unreachable/ssh-key) — a stale
  load-modify-write could strand a freshly claimed task on a busy@0
  saddle nothing polls.
- harness: fake daemons speak ask/wake/register (asks at registration,
  on wake, on task end and when parked idle-awake; title-targeted
  suspension so interleaved dispatches cannot eat the flag). New tests:
  concurrent asks race to one winner, affinity pinning + targeted wake,
  wake -> ask -> allocate, lost-wake -> offline + re-wake, token auth +
  revocation, /register delivery + key capture, startup token migration
  across an app restart, and the pre-flip /start resume path.
clanker 1 месяц назад
Родитель
Сommit
2a8a2ec4b0

+ 24 - 13
README.md

@@ -17,14 +17,18 @@ Browser ←→ clanker-saddle (Spry app: SQLite queue, Statum UI, dispatcher)
 ```
 
 The web app owns projects, tasks, dependencies and the saddle registry.
-Its dispatcher pairs ready tasks (dependencies done, priority first) with
-idle saddles, pushes each assignment to the daemon's `POST /start`
-(including the LLM endpoint config), then polls `GET /output` and
-`GET /status` every five seconds to mirror the agent's bash history into
-`session_commands` and to project its `saddle comment/subtask/done` events
-onto the task. Blocking questions flow back the same way: the daemon's
-question appears on the task page, and the human's answer is pushed to the
-daemon's `POST /answer` on the next tick.
+Work acquisition is ask-based (plan 10): daemons sleep when idle and ask
+`POST /api/saddle/next-task` (per-saddle bearer token); the allocation is
+one write transaction that pairs the asking saddle with the best
+offerable task — workspace affinity first (a task parked on this saddle
+is offered only to it), then capability-tag matching. The dispatcher
+wakes sleeping daemons when work becomes ready (`POST /wake`, the affine
+saddle first for parked tasks) and declares a saddle offline when no ask
+arrives within the lost-wake guard window. During the phase-1 transition
+the poll-based telemetry stays: busy saddles are still polled for
+`GET /output`, `GET /log` and `GET /status` every five seconds (with the
+heal/fence layers), and pre-flip daemons that never ask keep their
+`/answer` and `/start` resume paths.
 
 | Component | Lives | Role |
 |---|---|---|
@@ -38,6 +42,7 @@ Orchestration behaviour to know:
 |---|---|
 | `SADDLE_LLM_BASE_URL` | LLM endpoint sent with every assignment; unset pauses dispatch |
 | `SADDLE_LLM_API_KEY` / `SADDLE_LLM_MODEL` | Optional credentials/model for the above |
+| `SADDLE_LOST_WAKE_S` | Lost-wake guard window (default 15s): no ask after a wake → the saddle goes offline and the next capable one is woken |
 | `SADDLE_PROJECTS_ROOT` | Checkouts are addressed as `root/<project-name>` inside the saddle container (default `/workspace`) |
 
 ## Quick start (local)
@@ -119,14 +124,19 @@ with ephemeral keys.
    work with dependencies: a task waits until everything it depends on is
    done.
 3. **Saddle** — `/saddles` (admin) → name plus the daemon's host:port.
-   Within five seconds the dispatcher assigns the highest-priority ready
-   task to an idle saddle and the agent starts working.
+   Registration mints a per-saddle token and delivers it to the daemon's
+   `POST /register` (with this app's base URL); a phase-1 daemon then
+   asks for work whenever idle-awake and sleeps on a 204 answer, so a
+   ready task reaches it within one dispatcher tick (wake → ask →
+   allocate). Pre-flip daemons without `/register` still register — copy
+   the token from the saddle card instead.
 
 Tasks can also arrive from the agent itself: `saddle subtask` lines
 create child tasks, and push-style daemons may report through
 `POST /api/saddle/{id}/update` (`output`, `comment`, `block`, `subtask`,
 `done`, `status`) and collect answers from
-`GET /api/saddle/{id}/poll`.
+`GET /api/saddle/{id}/poll` — every `/api/saddle/*` call is authenticated
+by the per-saddle bearer token (removing the saddle revokes it).
 
 ## Monitoring agent progress
 
@@ -161,8 +171,9 @@ in link order, deduplicated; reference projects contribute nothing.
 **Project notes** are written by agents mid-task via `saddle note` and
 curated by humans on the project page (delete only — there is no manual
 create). Daemons POST notes to this app's
-`POST /api/saddle/note` callback, authenticated by a dedicated
-`saddle-callback-…` bearer token minted at startup. The callback base
+`POST /api/saddle/note` callback, authenticated by the per-saddle bearer
+token the daemon persisted at registration (plan 10; the shared
+`saddle-callback-…` token is gone). The callback base
 URL daemons must dial is **this app's own externally reachable address**
 (it differs from the browser's when the daemon runs in a container) and
 is configured with the top-level `saddle_callback_base_url` key in

+ 2 - 0
meson.build

@@ -115,10 +115,12 @@ app_sources = files(
     'src/Data/Migrations/M0007_SuspendedAt.vala',
     'src/Data/Migrations/M0008_AutoApprove.vala',
     'src/Data/Migrations/M0009_GitIdentity.vala',
+    'src/Data/Migrations/M0010_SaddleToken.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',
     'src/Services/SaddlePollState.vala',
+    'src/Services/AllocationService.vala',
     'src/Services/StatePulse.vala',
     'src/Services/Dispatcher.vala',
     'src/Services/RuleService.vala',

+ 93 - 22
src/Api/SaddleApi.vala

@@ -372,10 +372,11 @@ namespace ClankerSaddle.Api {
     /**
      * `GET /api/saddle/tasks?project_id=N&q=free-text` — the task-reading
      * API behind the daemon's `saddle tasks` CLI (wire contract):
-     * authenticated by the dedicated callback token (like
-     * `/api/saddle/note`). With `project_id` only that project's tasks;
-     * `q` matches title, description or plan (case-insensitive
-     * substring). Compact rows — id, title, status, parent_task_id.
+     * authenticated by the per-saddle bearer token (plan 10 — any
+     * registered saddle's credential authorises the read-only view).
+     * With `project_id` only that project's tasks; `q` matches title,
+     * description or plan (case-insensitive substring). Compact rows —
+     * id, title, status, parent_task_id.
      */
     public class SaddleTaskListEndpoint : Object, Endpoint {
 
@@ -384,8 +385,8 @@ namespace ClankerSaddle.Api {
 
         public virtual async HttpResult handle_request(HttpContext http_context,
                 RouteContext route_context) throws Error {
-            if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
-                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
+            if (!(yield validate_saddle_bearer(http_context, saddles))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
             }
 
             string project_text = "";
@@ -431,9 +432,9 @@ namespace ClankerSaddle.Api {
 
     /**
      * `GET /api/saddle/task/{id}` — the single-task view behind the
-     * daemon's `saddle task` CLI (wire contract): callback-token
-     * authenticated, joining the parent's title for lineage context.
-     * 404 when the task does not exist.
+     * daemon's `saddle task` CLI (wire contract): per-saddle-token
+     * authenticated (plan 10), joining the parent's title for lineage
+     * context. 404 when the task does not exist.
      */
     public class SaddleTaskDetailEndpoint : Object, Endpoint {
 
@@ -442,8 +443,8 @@ namespace ClankerSaddle.Api {
 
         public virtual async HttpResult handle_request(HttpContext http_context,
                 RouteContext route_context) throws Error {
-            if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
-                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
+            if (!(yield validate_saddle_bearer(http_context, saddles))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
             }
 
             string id_text = "";
@@ -483,11 +484,10 @@ namespace ClankerSaddle.Api {
     /**
      * `POST /api/saddle/subtask` — the synchronous subtask-creation
      * callback a daemon's `saddle subtask` CLI posts to (wire contract):
-     * authenticated by the dedicated saddle-callback bearer token (like
-     * `/api/saddle/note`), creating the task row immediately — same
-     * project, branch and priority as the parent — with
-     * `task_dependencies` rows for every `depends_on` id and the
-     * timeline entry on the parent's session. Idempotent on
+     * authenticated by the per-saddle bearer token (plan 10), creating
+     * the task row immediately — same project, branch and priority as
+     * the parent — with `task_dependencies` rows for every `depends_on`
+     * id and the timeline entry on the parent's session. Idempotent on
      * (parent, title): a retried callback or the later entry-poll
      * fallback returns the existing task's id instead of duplicating.
      *
@@ -506,8 +506,8 @@ namespace ClankerSaddle.Api {
 
         public virtual async HttpResult handle_request(HttpContext http_context,
                 RouteContext route_context) throws Error {
-            if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
-                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
+            if (!(yield validate_saddle_bearer(http_context, saddles))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
             }
 
             Json.Object body;
@@ -577,8 +577,8 @@ namespace ClankerSaddle.Api {
     /**
      * `POST /api/saddle/note` — the callback endpoint a daemon's
      * `saddle note` CLI posts to (wire contract): authenticated by the
-     * dedicated saddle-callback bearer token (see
-     * {@link SaddleService.ensure_callback_token}), inserting
+     * per-saddle bearer token (plan 10 — the credential the daemon
+     * persisted at registration), inserting
      *
      * ```json
      * {"task_id": 1, "project": "ZL4GQ", "body": "…"}
@@ -595,8 +595,8 @@ namespace ClankerSaddle.Api {
 
         public virtual async HttpResult handle_request(HttpContext http_context,
                 RouteContext route_context) throws Error {
-            if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
-                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
+            if (!(yield validate_saddle_bearer(http_context, saddles))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
             }
 
             Json.Object body;
@@ -629,6 +629,65 @@ namespace ClankerSaddle.Api {
         }
     }
 
+    /**
+     * `POST /api/saddle/next-task` (plan 10) — a saddle daemon asks for
+     * work, authorised by its per-saddle bearer token. The allocation
+     * is a single write transaction with two filters: workspace
+     * affinity (a task parked/blocked/suspended on this saddle is
+     * offered only to this saddle) and capability match (the saddle's
+     * tags must cover the task's required tags). Concurrent asks race
+     * to exactly one winner.
+     *
+     * - **200** + the full assignment payload (today's `/start` shape:
+     *   `task` — with pending block answers — `rules`, `subtasks`,
+     *   `parent` for subtasks, `project_notes`, `callback`,
+     *   `llm_config`, `git_identity` from the saddle row, `session_id`,
+     *   and for resumed workspaces `resume_notice`/`branch_advanced`).
+     * - **204** — nothing is offerable; the daemon sleeps until woken.
+     */
+    public class SaddleNextTaskEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private AllocationService allocation = inject<AllocationService>();
+        private ProjectService projects = inject<ProjectService>();
+        private SaddlePollState poll_state = inject<SaddlePollState>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
+            if (saddle == null) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
+            }
+
+            // The ask's arrival answers any pending wake (the lost-wake
+            // guard must not fire for a saddle that just asked); the
+            // outcome is recorded once the allocation resolves.
+            poll_state.mark_ask(((!)saddle).id);
+            Json.Object? assignment = null;
+            string error_text = "";
+            try {
+                assignment = yield allocation.allocate_next(saddles, projects, (!)saddle);
+            } catch (Error e) {
+                error_text = e.message;
+            }
+            poll_state.mark_ask_result(((!)saddle).id, assignment != null);
+            if (error_text.length > 0) {
+                return SaddleUpdateEndpoint.json_error(500,
+                    @"Allocation failed: $(error_text)");
+            }
+            if (assignment == null) {
+                var empty = new HttpDataResult(new ByteBuffer.from_byte_array(new uint8[0]),
+                    (StatusCode) 204);
+                return empty;
+            }
+            var generator = new Json.Generator();
+            generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(assignment));
+            var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
+            result.set_header("Content-Type", "application/json");
+            return result;
+        }
+    }
+
     /**
      * The request's bearer token: the `Authorization` header minus its
      * `Bearer ` prefix, or "" when the header is missing or shaped
@@ -641,4 +700,16 @@ namespace ClankerSaddle.Api {
         }
         return ((!)header).substring("Bearer ".length).strip();
     }
+
+    /**
+     * The per-saddle credential check shared by the id-less
+     * `/api/saddle/*` endpoints (plan 10): the bearer token must belong
+     * to a registered saddle (its row token, or a legacy api_tokens row
+     * mapped to one) — removing the saddle revokes it.
+     */
+    private static async bool validate_saddle_bearer(HttpContext http_context,
+            SaddleService saddles) throws Error {
+        var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
+        return saddle != null;
+    }
 }

+ 6 - 2
src/Data/Entities.vala

@@ -190,7 +190,9 @@ namespace ClankerSaddle.Data {
 
     /**
      * A saddle container registered with this app: one daemon managing one
-     * bash session at a time, controlled over HTTP at {@link host}.
+     * bash session at a time, controlled over HTTP at {@link host}. The
+     * per-saddle token ({@link token}, plan 10) authorises every
+     * daemon→web call; removing the row revokes it.
      */
     public class Saddle : Object {
 
@@ -204,6 +206,7 @@ namespace ClankerSaddle.Data {
         public string? git_name { get; set; default = null; }
         public string? git_email { get; set; default = null; }
         public string? ssh_public_key { get; set; default = null; }
+        public string? token { get; set; default = null; }
 
         public static void entity_mapping(EntityMapperBuilder<Saddle> cfg) {
             cfg.table("saddles")
@@ -216,7 +219,8 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("allocated_at", o => o.allocated_at, (o, v) => o.allocated_at = v)
                 .column<string>("git_name", o => o.git_name, (o, v) => o.git_name = v)
                 .column<string>("git_email", o => o.git_email, (o, v) => o.git_email = v)
-                .column<string>("ssh_public_key", o => o.ssh_public_key, (o, v) => o.ssh_public_key = v);
+                .column<string>("ssh_public_key", o => o.ssh_public_key, (o, v) => o.ssh_public_key = v)
+                .column<string>("token", o => o.token, (o, v) => o.token = v);
         }
     }
 

+ 30 - 0
src/Data/Migrations/M0010_SaddleToken.vala

@@ -0,0 +1,30 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Plan 10 phase 1: the per-saddle token column. Association mints one
+     * token per saddle (shown once at registration, copied from the
+     * saddles page, delivered to the daemon by `POST /register`); every
+     * daemon→web call carries it and removing the saddle revokes it (the
+     * row dies with the token). Legacy `api_tokens` rows keep validating
+     * during the transition; startup copies them onto the row when
+     * present so each saddle has exactly one canonical token.
+     */
+    public class M0010_SaddleToken : Migration {
+
+        public override string name { get { return "Saddle token: saddles.token"; } }
+        public override uint64 serial { get { return 10; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.alter_table("saddles", (t) => {
+                t.add_column<string>("token");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            // Nullable additive column; leaving it behind is harmless.
+        }
+    }
+}

+ 371 - 0
src/Services/AllocationService.vala

@@ -0,0 +1,371 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * One offerable task for the wake computation: a fresh queued task
+     * (dependencies met, {@link affine_saddle_id} 0) or a parked task
+     * ready to resume — blocked/review-requested with its newest block
+     * answered, or suspended with a terminal subtask event since it
+     * parked — pinned to the saddle holding its open session.
+     */
+    public class OfferableTask : Object {
+        public TaskEntity task { get; set; }
+        /** The saddle whose parked workspace holds the task (0 = fresh). */
+        public int64 affine_saddle_id { get; set; }
+        /** True when resuming a parked workspace (vs. a fresh dispatch). */
+        public bool parked { get; set; }
+        /** True when the parked task is a suspended parent's resume. */
+        public bool suspended_resume { get; set; }
+    }
+
+    /**
+     * A claimed allocation: the transactional half of
+     * `POST /api/saddle/next-task` succeeded — the task is `assigned`
+     * to the asking saddle, its session row exists and the saddle row
+     * is busy. The assignment payload is assembled from it afterwards.
+     */
+    public class AllocationClaim : Object {
+        public int64 task_id { get; set; }
+        public int64 session_id { get; set; }
+        public bool parked { get; set; }
+        public bool suspended_resume { get; set; }
+    }
+
+    /**
+     * The ask/allocation half of the plan-10 work-acquisition flip:
+     * daemons ask (`POST /api/saddle/next-task`), the allocation picks
+     * the best offerable task for the asking saddle and claims it in a
+     * single write transaction, and the dispatcher's wake computation
+     * uses the same offerable set to decide which sleeping daemons to
+     * wake.
+     *
+     * Two filters, in priority order:
+     *
+     * 1. **Workspace affinity** — a task parked (blocked, awaiting
+     *    review or suspended) on this saddle is offered ONLY to this
+     *    saddle: its workspace lives here, and capabilities do not
+     *    apply (the parked session pins the pairing, exactly like the
+     *    pre-flip resume paths).
+     * 2. **Capability match** — fresh queued tasks (dependencies met)
+     *    go to the asking saddle only when its capability set is a
+     *    superset of the task's required tags.
+     *
+     * The claim itself runs inside one `BEGIN IMMEDIATE` transaction
+     * with no awaits, so concurrent asks cannot interleave between the
+     * decision and the writes: exactly one ask wins a task even when
+     * several fire simultaneously. Every candidate is re-validated
+     * inside the transaction (a racing ask may have claimed it after
+     * the candidate list was built), and a lost race simply falls
+     * through to the next candidate.
+     */
+    public class AllocationService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private Connection connection = inject<Connection>();
+        private TaskService tasks = inject<TaskService>();
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
+        private StatePulse pulse = inject<StatePulse>();
+
+        /**
+         * Allocates the best offerable task to `saddle`, or null when
+         * nothing is offerable (the daemon sleeps). The transactional
+         * claim runs first; the assignment payload (answers, resume
+         * notice, effective rules, project notes, git identity, llm
+         * config, session id) is assembled from the claimed rows after
+         * the commit.
+         */
+        public async Json.Object? allocate_next(SaddleService saddles, ProjectService projects,
+                Saddle saddle) throws Error {
+            if (LlmEndpoint.from_environment() == null) {
+                // No LLM endpoint configured: assignment stays paused
+                // (the dispatcher logs it once); the daemon sleeps.
+                return null;
+            }
+
+            var candidates = yield candidates_for(saddle);
+            AllocationClaim? claim = null;
+            foreach (var candidate in candidates) {
+                claim = claim_candidate(saddle, candidate);
+                if (claim != null) {
+                    break;
+                }
+            }
+            if (claim == null) {
+                return null;
+            }
+            return yield assemble_assignment(saddles, projects, saddle, (!)claim);
+        }
+
+        /**
+         * Every offerable task (see {@link OfferableTask}) — the
+         * dispatcher's wake computation walks this list: parked tasks
+         * target their affine saddle first, fresh tasks any capable
+         * sleeping one. A parked task without an open session has no
+         * workspace to resume and is not offerable.
+         */
+        public async ImmutableLot<OfferableTask> offerable_tasks() throws Error {
+            var rows = new Vector<OfferableTask>();
+            foreach (var status in new string[] { STATUS_BLOCKED, STATUS_REVIEW_REQUESTED }) {
+                foreach (var task in yield tasks.list_tasks(status, null)) {
+                    if (yield newest_block_answered(task.id)) {
+                        var parked = yield parked_offerable(task, false);
+                        if (parked != null) {
+                            rows.add((!)parked);
+                        }
+                    }
+                }
+            }
+            foreach (var parent in yield tasks.list_tasks(STATUS_SUSPENDED, null)) {
+                if (yield tasks.has_terminal_subtask_since(parent)) {
+                    var parked = yield parked_offerable(parent, true);
+                    if (parked != null) {
+                        rows.add((!)parked);
+                    }
+                }
+            }
+            foreach (var task in yield tasks.get_ready_tasks()) {
+                rows.add(new OfferableTask() {
+                    task = task,
+                    affine_saddle_id = 0,
+                    parked = false,
+                    suspended_resume = false
+                });
+            }
+            return rows.to_immutable_buffer();
+        }
+
+        /**
+         * The offerable tasks `saddle` may be allocated: affinity
+         * candidates first (parked on this saddle, priority order),
+         * then fresh queued tasks whose required capabilities are
+         * covered (ready order — priority, then creation).
+         */
+        private async ImmutableLot<OfferableTask> candidates_for(Saddle saddle) throws Error {
+            var rows = new Vector<OfferableTask>();
+            foreach (var offerable in yield offerable_tasks()) {
+                if (offerable.parked) {
+                    if (offerable.affine_saddle_id == saddle.id) {
+                        rows.add(offerable);
+                    }
+                } else if (yield capabilities.saddle_covers_task(saddle.id,
+                        offerable.task.id)) {
+                    rows.add(offerable);
+                }
+            }
+            return rows.to_immutable_buffer();
+        }
+
+        /**
+         * A parked offerable bound to its session's saddle, or null when
+         * the task has no open (parked) session — nothing to resume.
+         */
+        private async OfferableTask? parked_offerable(TaskEntity task, bool suspended_resume)
+                throws Error {
+            var session = yield tasks.open_session_for_task(task.id);
+            if (session == null) {
+                return null;
+            }
+            return new OfferableTask() {
+                task = task,
+                affine_saddle_id = ((!)session).saddle_id,
+                parked = true,
+                suspended_resume = suspended_resume
+            };
+        }
+
+        /** True when the task's newest block is answered (resume-ready). */
+        private async bool newest_block_answered(int64 task_id) throws Error {
+            TaskBlock? newest = null;
+            foreach (var block in yield tasks.list_blocks(task_id)) {
+                newest = block;
+            }
+            return newest != null && ((!)newest).answer != null;
+        }
+
+        /**
+         * The single-transaction claim. `BEGIN IMMEDIATE` takes the
+         * SQLite write lock up front; the section is fully synchronous
+         * (no awaits), so no other ask, action or tick can interleave
+         * between the re-validation reads and the claim writes —
+         * concurrent asks race to exactly one winner. Returns null when
+         * the candidate was claimed by a racing ask or its saddle
+         * became unavailable (the caller falls through to the next
+         * candidate).
+         */
+        private AllocationClaim? claim_candidate(Saddle saddle, OfferableTask candidate) throws Error {
+            connection.execute("BEGIN IMMEDIATE");
+            try {
+                var task = db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(candidate.task.id)))
+                    .first();
+                if (task == null || !still_claimable((!)task, candidate)) {
+                    connection.execute("ROLLBACK");
+                    return null;
+                }
+
+                var row = db.query<Saddle>()
+                    .where(expr("id == $0", elem<int64?>(saddle.id)))
+                    .first();
+                if (row == null || (((!)row).status != "idle"
+                        && (!(candidate.parked && ((!)row).current_task_id == candidate.task.id)))) {
+                    connection.execute("ROLLBACK");
+                    return null;
+                }
+
+                int64 session_id = 0;
+                if (candidate.parked) {
+                    var session = db.query<Session>()
+                        .where(expr("task_id == $0 && saddle_id == $1",
+                            elem<int64?>(candidate.task.id), elem<int64?>(saddle.id)))
+                        .order_by(expr("id"))
+                        .materialise();
+                    Session? newest_open = null;
+                    foreach (var open in session) {
+                        if (open.ended_at == null) {
+                            newest_open = open;
+                        }
+                    }
+                    if (newest_open == null) {
+                        // The parked session closed underneath us (a
+                        // concurrent requeue); nothing to resume onto.
+                        connection.execute("ROLLBACK");
+                        return null;
+                    }
+                    session_id = ((!)newest_open).id;
+                } else {
+                    Session? reusable = null;
+                    var earlier = db.query<Session>()
+                        .where(expr("task_id == $0 && saddle_id == $1",
+                            elem<int64?>(candidate.task.id), elem<int64?>(saddle.id)))
+                        .order_by(expr("id"))
+                        .materialise();
+                    foreach (var open in earlier) {
+                        if (open.outcome == "push_failed") {
+                            reusable = open;
+                        }
+                    }
+                    if (reusable != null) {
+                        // Reuse the failed attempt's row instead of
+                        // churning a fresh one per retry.
+                        ((!)reusable).outcome = null;
+                        ((!)reusable).ended_at = null;
+                        ((!)reusable).started_at = new DateTime.now_local();
+                        db.update<Session>((!)reusable);
+                        session_id = ((!)reusable).id;
+                    } else {
+                        var session = new Session() {
+                            task_id = candidate.task.id,
+                            saddle_id = saddle.id,
+                            started_at = new DateTime.now_local()
+                        };
+                        db.insert<Session>(session);
+                        session_id = session.id;
+                    }
+                }
+
+                ((!)task).status = STATUS_ASSIGNED;
+                ((!)task).started_at = new DateTime.now_local();
+                db.update<TaskEntity>((!)task);
+
+                ((!)row).status = "busy";
+                ((!)row).current_task_id = candidate.task.id;
+                ((!)row).allocated_at = new DateTime.now_local();
+                db.update<Saddle>((!)row);
+
+                connection.execute("COMMIT");
+                return new AllocationClaim() {
+                    task_id = candidate.task.id,
+                    session_id = session_id,
+                    parked = candidate.parked,
+                    suspended_resume = candidate.suspended_resume
+                };
+            } catch (Error e) {
+                try {
+                    connection.execute("ROLLBACK");
+                } catch (Error rollback_error) {
+                    warning("[ClankerSaddle] Allocation rollback failed: %s",
+                        rollback_error.message);
+                }
+                throw e;
+            }
+        }
+
+        /**
+         * The in-transaction re-validation: the task must still be in
+         * its candidate state (a racing ask that claimed it flipped the
+         * status), and a fresh candidate's dependencies must still be
+         * met.
+         */
+        private bool still_claimable(TaskEntity task, OfferableTask candidate) throws Error {
+            if (candidate.parked) {
+                return task.status == STATUS_BLOCKED
+                    || task.status == STATUS_REVIEW_REQUESTED
+                    || (candidate.suspended_resume && task.status == STATUS_SUSPENDED);
+            }
+            if (task.status != STATUS_QUEUED) {
+                return false;
+            }
+            foreach (var dependency in db.query<TaskDependency>()
+                    .where(expr("task_id == $0", elem<int64?>(task.id)))
+                    .materialise()) {
+                var other = db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(dependency.depends_on_task_id)))
+                    .first();
+                if (other == null || ((!)other).status != STATUS_DONE) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        /**
+         * Assembles the claimed task's assignment payload (today's
+         * /start shape): pending block answers ride the task object,
+         * a suspended parent's resume carries the coalesced subtask
+         * report as `resume_notice` plus the `branch_advanced` verdict,
+         * and the output cursor is seeded from the daemon before the
+         * payload is handed over (the daemon cannot start before the
+         * ask is answered, so the baseline predates its first entry).
+         */
+        private async Json.Object assemble_assignment(SaddleService saddles,
+                ProjectService projects, Saddle saddle, AllocationClaim claim) throws Error {
+            var detail = yield tasks.get_task(claim.task_id);
+            if (detail == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 48,
+                    @"Task $(claim.task_id) vanished after its allocation claim");
+            }
+            var task = ((!)detail).task;
+
+            string? resume_notice = null;
+            bool? branch_advanced = null;
+            if (claim.parked) {
+                branch_advanced = yield tasks.branch_advanced_since(task.id);
+                if (claim.suspended_resume) {
+                    resume_notice = yield tasks.subtask_status_report(task.id);
+                    // The parking window is over: a later suspension
+                    // starts a fresh event window.
+                    yield tasks.clear_suspension(task.id);
+                }
+            }
+
+            yield saddles.seed_session_cursor(saddle.id, claim.session_id);
+            var payload = yield saddles.build_assignment_payload(saddle, task,
+                claim.session_id, projects, resume_notice, branch_advanced);
+            payload.set_int_member("session_id", claim.session_id);
+
+            yield saddles.mark_task_comments_delivered(task.id);
+            subtask_events.record_dispatch(task);
+            yield pulse.bump(task.id, "task dispatched");
+            print(@"[ClankerSaddle] Allocated task $(task.id) to saddle '$(saddle.name)' on ask (session $(claim.session_id))\n");
+            return payload;
+        }
+    }
+}

+ 129 - 90
src/Services/Dispatcher.vala

@@ -6,10 +6,20 @@ using ClankerSaddle.Data;
 namespace ClankerSaddle.Services {
 
     /**
-     * The task dispatcher: a five-second timer that pairs ready tasks with
-     * idle saddles, resumes answered-but-paused tasks on the saddle that
-     * holds their parked workspace, pushes human comments to busy daemons
-     * and keeps busy saddles' output in sync.
+     * The task dispatcher (plan 10, phase 1): a five-second timer that
+     * computes wakes — when a task becomes offerable and a capable
+     * SLEEPING saddle exists (its last ask returned 204), the daemon is
+     * `POST /wake`d and fetches the work itself via
+     * `POST /api/saddle/next-task`; parked tasks target their affine
+     * saddle first. The lost-wake guard declares a saddle offline when
+     * no ask arrives within the guard window after a wake, freeing the
+     * work for the next capable sleeping saddle.
+     *
+     * The pre-flip machinery stays fully functional during phase 1
+     * (rollback path): parked-task resumes on non-sleeping (pre-flip)
+     * daemons still go out as /answer deliveries and /start pushes,
+     * and busy saddles keep being polled for /output, /log and /status
+     * with the 3-strike, heal and fence layers.
      *
      * Each tick resolves fresh request-scoped services (so no database
      * scope is held between ticks) and never overlaps with itself: a slow
@@ -18,6 +28,7 @@ namespace ClankerSaddle.Services {
     public class Dispatcher : Object {
 
         private Container container = inject<Container>();
+        private SaddlePollState poll_state = inject<SaddlePollState>();
         private bool ticking = false;
         private int64 tick_started_ms = 0;
         private bool warned_no_llm = false;
@@ -82,25 +93,31 @@ namespace ClankerSaddle.Services {
             strikes.set(saddle_id.to_string(), count.to_string());
         }
 
+        /** SADDLE_LOST_WAKE_S (default 15s), in milliseconds. */
+        public static uint lost_wake_guard_ms() {
+            var raw = Environment.get_variable("SADDLE_LOST_WAKE_S") ?? "";
+            int64 parsed = int64.parse(raw);
+            return parsed > 0 ? (uint) (parsed * 1000) : 15000;
+        }
+
         /**
-          * One dispatch cycle: for every ready task (priority order) grab an
-          * idle saddle whose capability set is a superset of the task's
-          * required capabilities (tag-based routing — a task with no
-          * requirements matches any saddle; a task that cannot be matched
-          * is skipped with a log note and lower-priority tasks get their
-          * chance), then resume answered-but-paused tasks on the saddle
-          * that holds their parked workspace, and poll busy saddles for
-          * output and status changes. Assignment pauses while
-          * {@link LlmEndpoint} is unconfigured (polling continues).
+          * One dispatch cycle: recovery passes first (stale-busy healing
+          * frees saddles within the same tick they strand), then the
+          * lost-wake guard offlines saddles whose wake went unanswered,
+          * then the wake computation offers every offerable task to a
+          * capable sleeping daemon. Parked-task resumes against
+          * non-sleeping (pre-flip) daemons still run the /answer + /start
+          * resume paths, subtask events are pushed, and busy saddles are
+          * polled for output and status changes. Wake computation pauses
+          * while {@link LlmEndpoint} is unconfigured (polling and
+          * resumes continue).
           */
         public async void tick() throws Error {
             var scope = container.create_scope();
-            var orm = scope.resolve<OrmSession>();
             var tasks = scope.resolve<TaskService>();
             var saddles = scope.resolve<SaddleService>();
-            var projects = scope.resolve<ProjectService>();
             var capabilities = scope.resolve<CapabilityService>();
-            var subtask_events = scope.resolve<SubtaskEventQueue>();
+            var allocation = scope.resolve<AllocationService>();
 
             if (LlmEndpoint.from_environment() == null) {
                 if (!warned_no_llm) {
@@ -116,72 +133,12 @@ namespace ClankerSaddle.Services {
                 yield saddles.requeue_tasks_on_offline_saddles(tasks);
                 yield saddles.recover_stranded_assignments(tasks);
                 yield saddles.recover_fenced_saddles();
-                var ready = yield tasks.get_ready_tasks();
-                foreach (var task in ready) {
-                    var saddle = yield pick_idle_capable_saddle(saddles, capabilities, task);
-                    if (saddle == null) {
-                        bool any_idle = (yield saddles.list_idle_saddles()).length > 0;
-                        if (!any_idle) {
-                            break;
-                        }
-                        // A capable saddle may free up later; meanwhile
-                        // lower-priority tasks still get their turn.
-                        if (capability_wait_logged.add(task.id.to_string())) {
-                            var required = new Invercargill.DataStructures.Vector<string>();
-                            foreach (var capability in yield capabilities.list_for_task(task.id)) {
-                                required.add(capability.name);
-                            }
-                            string required_text = string.joinv(", ", required.to_array());
-                            print(@"[ClankerSaddle] Task $(task.id) waits for a capable saddle (requires: $(required_text))\n");
-                        }
-                        continue;
-                    }
-                    capability_wait_logged.remove(task.id.to_string());
-
-                    // A failed push marks its session push_failed; the
-                    // next attempt reuses that row instead of churning a
-                    // fresh one per retry.
-                    var session = yield tasks.reuse_push_failed_session(task.id, ((!)saddle).id);
-                    if (session == null) {
-                        session = new Session() {
-                            task_id = task.id,
-                            saddle_id = ((!)saddle).id,
-                            started_at = new DateTime.now_local()
-                        };
-                        yield orm.insert_async<Session>((!)session);
-                    }
-
-                    task.status = STATUS_ASSIGNED;
-                    task.started_at = new DateTime.now_local();
-                    yield orm.update_async<TaskEntity>(task);
-                    yield saddles.update_saddle_status(((!)saddle).id, "busy", task.id);
-                    subtask_events.record_dispatch(task);
-
-                    try {
-                        yield saddles.seed_session_cursor(((!)saddle).id, ((!)session).id);
-                        bool pushed = yield saddles.push_with_zombie_recovery(((!)saddle),
-                            task, ((!)session).id, projects, tasks);
-                        if (!pushed) {
-                            // Double-booked: the saddle is fenced and both
-                            // tasks carry comments; requeue this one.
-                            yield tasks.mark_session_push_failed(((!)session).id);
-                            yield tasks.requeue_task(task.id);
-                            continue;
-                        }
-                        yield saddles.mark_task_comments_delivered(task.id);
-                        record_strike(((!)saddle).id, 0);
-                        print(@"[ClankerSaddle] Dispatched task $(task.id) to saddle '$(((!)saddle).name)' (session $(((!)session).id))\n");
-                    } catch (Error e) {
-                        warning(@"[ClankerSaddle] Pushing task $(task.id) to saddle $(((!)saddle).id) failed: $(e.message)");
-                        yield tasks.mark_session_push_failed(((!)session).id);
-                        yield abandon_saddle(((!)saddle).id, saddles, tasks,
-                            @"assignment push failed: $(e.message)");
-                    }
-                }
+                yield enforce_lost_wake_guard(saddles, tasks);
+                yield wake_sleeping_saddles(saddles, capabilities, allocation);
             }
 
-            yield resume_answered_tasks(tasks, saddles, projects);
-            yield resume_suspended_parents(tasks, saddles, projects);
+            yield resume_answered_tasks(tasks, saddles, scope.resolve<ProjectService>());
+            yield resume_suspended_parents(tasks, saddles, scope.resolve<ProjectService>());
             yield saddles.deliver_pending_subtask_events(tasks);
 
             foreach (var saddle in yield saddles.list_busy_saddles()) {
@@ -283,19 +240,101 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * The first idle saddle whose capability set covers `task`'s
-         * required capabilities (registration order), or null when every
-         * idle saddle is missing a tag — the tag-based routing half of
-         * the pairing loop.
+         * The lost-wake guard: a wake was sent, the guard window has
+         * passed, and still no ask arrived ({@link
+         * SaddlePollState.mark_ask} clears the pending wake the moment
+         * one does) — the daemon is gone or wedged. The saddle goes
+         * offline (the normal unreachable bookkeeping); the wake pass
+         * below then offers the work to the next capable sleeping
+         * saddle in the same tick.
          */
-        private async Saddle? pick_idle_capable_saddle(SaddleService saddles,
-                CapabilityService capabilities, TaskEntity task) throws Error {
-            foreach (var saddle in yield saddles.list_idle_saddles()) {
-                if (yield capabilities.saddle_covers_task(saddle.id, task.id)) {
-                    return saddle;
+        private async void enforce_lost_wake_guard(SaddleService saddles, TaskService tasks)
+                throws Error {
+            foreach (var saddle in yield saddles.list_saddles()) {
+                if (!poll_state.wake_overdue(saddle.id, lost_wake_guard_ms())) {
+                    continue;
+                }
+                int64 task_id = poll_state.wake_task_of(saddle.id);
+                warning(@"[ClankerSaddle] Lost wake: saddle '$(saddle.name)' never asked within"
+                    + @" $(lost_wake_guard_ms() / 1000)s of its wake (task $(task_id)); marking offline");
+                yield saddles.mark_saddle_unreachable(saddle.id, tasks,
+                    @"lost wake: no ask within $(lost_wake_guard_ms() / 1000)s of POST /wake");
+            }
+        }
+
+        /**
+         * The wake computation: for every offerable task (see
+         * {@link AllocationService.offerable_tasks}) find a capable
+         * SLEEPING saddle and POST /wake — the daemon asks and the
+         * allocation transaction hands the task over. Parked tasks
+         * target their affine saddle first (only it can be allocated
+         * them); a non-sleeping affine saddle (a pre-flip daemon, or
+         * one blocked in its answer loop) is left to the /answer +
+         * /start resume paths. Saddles with a pending wake inside the
+         * guard window are skipped — the guard decides their fate.
+         */
+        private async void wake_sleeping_saddles(SaddleService saddles,
+                CapabilityService capabilities, AllocationService allocation) throws Error {
+            foreach (var offerable in yield allocation.offerable_tasks()) {
+                Saddle? target = null;
+                if (offerable.parked) {
+                    var affine = offerable.affine_saddle_id > 0
+                        ? yield saddles.get_saddle(offerable.affine_saddle_id) : null;
+                    if (affine != null && ((!)affine).status == "idle"
+                            && poll_state.is_sleeping(((!)affine).id)
+                            && !poll_state.wake_pending(((!)affine).id, lost_wake_guard_ms())) {
+                        target = (!)affine;
+                    }
+                    // Otherwise: the resume paths own the task this tick.
+                } else {
+                    foreach (var candidate in yield saddles.list_idle_saddles()) {
+                        if (!(yield capabilities.saddle_covers_task(candidate.id,
+                                offerable.task.id))) {
+                            continue;
+                        }
+                        if (!poll_state.is_sleeping(candidate.id)) {
+                            // A pre-flip daemon never asks; waking it is
+                            // pointless. It stays eligible for the
+                            // rollback /start paths only.
+                            continue;
+                        }
+                        if (poll_state.wake_pending(candidate.id, lost_wake_guard_ms())) {
+                            continue;
+                        }
+                        target = candidate;
+                        break;
+                    }
+                }
+
+                if (target != null) {
+                    poll_state.note_wake(((!)target).id, offerable.task.id);
+                    try {
+                        yield saddles.wake_saddle(((!)target));
+                        print(@"[ClankerSaddle] Woke saddle '$(((!)target).name)' for task $(offerable.task.id)\n");
+                    } catch (Error e) {
+                        // The wake is on the books; the guard offlines the
+                        // saddle if no ask follows.
+                        warning(@"[ClankerSaddle] Waking saddle $(((!)target).id) failed: $(e.message)");
+                    }
+                    capability_wait_logged.remove(offerable.task.id.to_string());
+                    continue;
+                }
+
+                bool any_idle = (yield saddles.list_idle_saddles()).length > 0;
+                if (!any_idle) {
+                    break;
+                }
+                // A capable saddle may free up later; meanwhile
+                // lower-priority tasks still get their turn.
+                if (capability_wait_logged.add(offerable.task.id.to_string())) {
+                    var required = new Vector<string>();
+                    foreach (var capability in yield capabilities.list_for_task(offerable.task.id)) {
+                        required.add(capability.name);
+                    }
+                    string required_text = string.joinv(", ", required.to_array());
+                    print(@"[ClankerSaddle] Task $(offerable.task.id) waits for a capable saddle (requires: $(required_text))\n");
                 }
             }
-            return null;
         }
     }
 }

+ 125 - 1
src/Services/SaddlePollState.vala

@@ -9,7 +9,11 @@ namespace ClankerSaddle.Services {
      * `session_commands` rows), the last daemon-side raw-log `seq`
      * stored per session (`session_raw_log`), the block answers and
      * human comments already handed to a saddle (each delivered exactly
-     * once; a restart re-delivers and the daemon deduplicates).
+     * once; a restart re-delivers and the daemon deduplicates), plus
+     * the plan-10 ask/wake liveness bookkeeping: the outcome of a
+     * saddle's last `POST /api/saddle/next-task` ask (a saddle counts
+     * as SLEEPING when its last ask returned 204) and the pending wake
+     * each sleeping saddle was sent (the lost-wake guard's timer).
      *
      * A singleton by necessity: {@link SaddleService} is request-scoped,
      * so this object is where the cross-request cursors live. Ids are
@@ -28,6 +32,10 @@ namespace ClankerSaddle.Services {
         private HashSet<string> delivered_answers = new HashSet<string>();
         private HashSet<string> delivered_comments = new HashSet<string>();
         private HashSet<string> resumed_notified = new HashSet<string>();
+        private Dictionary<string, string> ask_outcome = new Dictionary<string, string>();
+        private HashSet<string> ask_seen = new HashSet<string>();
+        private Dictionary<string, string> wake_at_ms = new Dictionary<string, string>();
+        private Dictionary<string, string> wake_task = new Dictionary<string, string>();
         private Mutex state_lock = Mutex();
 
         /** The last output entry id stored for `session_id` (0 when none). */
@@ -195,5 +203,121 @@ namespace ClankerSaddle.Services {
             state_lock.unlock();
             return already;
         }
+
+        /**
+         * Records an ask arriving from `saddle_id` (plan 10): the
+         * pending wake is answered — the lost-wake guard must not fire
+         * for it — and the saddle is awake until the ask resolves. The
+         * first ask also marks the daemon ask-capable (post-flip): its
+         * parked-task resumes flow through wake + next-task from then
+         * on, never through /start pushes.
+         */
+        public void mark_ask(int64 saddle_id) {
+            state_lock.lock();
+            string key = saddle_id.to_string();
+            ask_outcome.set(key, "awake");
+            ask_seen.add(key);
+            wake_at_ms.remove(key);
+            wake_task.remove(key);
+            state_lock.unlock();
+        }
+
+        /**
+         * Records the outcome of `saddle_id`'s ask: `204` puts it to
+         * sleep (a later wake targets it), anything else leaves it
+         * awake.
+         */
+        public void mark_ask_result(int64 saddle_id, bool assigned) {
+            state_lock.lock();
+            ask_outcome.set(saddle_id.to_string(), assigned ? "assigned" : "204");
+            state_lock.unlock();
+        }
+
+        /**
+         * True when `saddle_id`'s last ask returned 204 — the sleeping
+         * state a wake targets.
+         */
+        public bool is_sleeping(int64 saddle_id) {
+            state_lock.lock();
+            string stored = "";
+            bool sleeping = ask_outcome.try_get(saddle_id.to_string(), out stored)
+                && stored == "204";
+            state_lock.unlock();
+            return sleeping;
+        }
+
+        /**
+         * True once `saddle_id` has ever asked (post-flip daemon): it
+         * asks whenever idle-awake, so its work — fresh or resumed —
+         * always flows through the allocation, and the /answer and
+         * /start push paths leave it alone (except a daemon blocked in
+         * its answer loop, which cannot ask and still takes /answer).
+         */
+        public bool has_ever_asked(int64 saddle_id) {
+            state_lock.lock();
+            bool seen = ask_seen.has(saddle_id.to_string());
+            state_lock.unlock();
+            return seen;
+        }
+
+        /** Forgets the ask state (the saddle went offline / was removed). */
+        public void clear_ask(int64 saddle_id) {
+            state_lock.lock();
+            string key = saddle_id.to_string();
+            ask_outcome.remove(key);
+            wake_at_ms.remove(key);
+            wake_task.remove(key);
+            state_lock.unlock();
+        }
+
+        /**
+         * Records a wake sent to `saddle_id` for `task_id` — starts the
+         * lost-wake guard's timer.
+         */
+        public void note_wake(int64 saddle_id, int64 task_id) {
+            state_lock.lock();
+            string key = saddle_id.to_string();
+            wake_at_ms.set(key, (get_monotonic_time() / 1000).to_string());
+            wake_task.set(key, task_id.to_string());
+            state_lock.unlock();
+        }
+
+        /**
+         * True when `saddle_id` has a pending wake at least
+         * `guard_ms` old — an ask should have arrived by now; the
+         * guard declares the saddle offline and frees the work for the
+         * next capable one.
+         */
+        public bool wake_overdue(int64 saddle_id, uint guard_ms) {
+            state_lock.lock();
+            string stored = "";
+            bool overdue = wake_at_ms.try_get(saddle_id.to_string(), out stored)
+                && (get_monotonic_time() / 1000 - int64.parse(stored)) >= guard_ms;
+            state_lock.unlock();
+            return overdue;
+        }
+
+        /**
+         * True when `saddle_id` carries a pending wake younger than
+         * `guard_ms` — the wake pass skips it instead of re-waking on
+         * every tick while the guard window runs.
+         */
+        public bool wake_pending(int64 saddle_id, uint guard_ms) {
+            state_lock.lock();
+            string stored = "";
+            bool pending = wake_at_ms.try_get(saddle_id.to_string(), out stored)
+                && (get_monotonic_time() / 1000 - int64.parse(stored)) < guard_ms;
+            state_lock.unlock();
+            return pending;
+        }
+
+        /** The task id `saddle_id` was last woken for (0 when none). */
+        public int64 wake_task_of(int64 saddle_id) {
+            state_lock.lock();
+            string stored = "";
+            bool found = wake_task.try_get(saddle_id.to_string(), out stored);
+            state_lock.unlock();
+            return found ? int64.parse(stored) : 0;
+        }
     }
 }

+ 351 - 145
src/Services/SaddleService.vala

@@ -1,6 +1,7 @@
 using Invercargill;
 using Invercargill.DataStructures;
 using Invercargill.Expressions;
+using InvercargillSql;
 using InvercargillSql.Orm;
 using Inversion;
 using Soup;
@@ -8,9 +9,6 @@ using ClankerSaddle.Data;
 
 namespace ClankerSaddle.Services {
 
-    /** Prefix of the dedicated saddle-callback API token (feature 6). */
-    public const string CALLBACK_TOKEN_PREFIX = "saddle-callback-";
-
     /** Caps of the `project_notes` array in the /start payload. */
     public const int64 PROJECT_NOTES_MAX_ENTRIES = 30;
     public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
@@ -72,6 +70,7 @@ namespace ClankerSaddle.Services {
     public class SaddleService : Object {
 
         private OrmSession db = inject<OrmSession>();
+        private Connection connection = inject<Connection>();
         /** Consecutive failed probes per fenced saddle (string-keyed). */
         private Invercargill.DataStructures.Dictionary<string, string> fence_strikes =
             new Invercargill.DataStructures.Dictionary<string, string>();
@@ -97,32 +96,96 @@ namespace ClankerSaddle.Services {
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private StatePulse pulse = inject<StatePulse>();
         private RuleService rules = inject<RuleService>();
+        private TaskService task_service = inject<TaskService>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
 
         /**
-         * Registers a saddle container and generates its API token.
-         * `host` is host:port of its control server; the returned
-         * {@link SaddleRegistration} carries the token the daemon must
-         * present as `Authorization: Bearer …` on `/api/saddle/*` calls.
+         * Registers a saddle container: inserts the row with a freshly
+         * minted per-saddle token (plan 10) and hands the credential to
+         * the daemon via `POST /register {saddle_token, web_base_url}`
+         * (best effort — an older daemon without the route stays
+         * registered; the operator copies the token from the saddles
+         * page instead). `host` is host:port of its control server; the
+         * returned {@link SaddleRegistration} carries the token the
+         * daemon must present as `Authorization: Bearer …` on
+         * `/api/saddle/*` calls.
          */
         public async SaddleRegistration register_saddle(string name, string host) throws Error {
             var saddle = new Saddle() {
                 name = name,
                 host = host,
-                status = "idle"
+                status = "idle",
+                token = mint_saddle_token()
             };
             yield db.insert_async<Saddle>(saddle);
-            string token = yield generate_token(saddle.id);
+            yield register_daemon(saddle);
             yield pulse.bump(0, "saddle registered");
-            return new SaddleRegistration() { saddle = saddle, token = token };
+            return new SaddleRegistration() { saddle = saddle, token = saddle.token ?? "" };
+        }
+
+        /** A fresh per-saddle bearer token (`saddle-<random>`). */
+        private static string mint_saddle_token() {
+            return @"saddle-$(Uuid.string_random().replace("-", ""))";
+        }
+
+        /**
+         * Delivers the per-saddle token to the daemon's
+         * `POST /register` endpoint (wire contract): the daemon
+         * persists it for its calls home and answers
+         * `{ok, ssh_public_key, version}` — the key is stored on the
+         * row the moment the daemon reports one. A daemon without the
+         * route (HTTP 404/405) or unreachable at registration time is
+         * tolerated: the row keeps the token, the operator can rety
+         * via a refresh or copy the token manually.
+         */
+        public async void register_daemon(Saddle saddle) throws Error {
+            var payload = new Json.Builder();
+            payload.begin_object();
+            payload.set_member_name("saddle_token");
+            payload.add_string_value(saddle.token ?? "");
+            payload.set_member_name("web_base_url");
+            payload.add_string_value(callback_base_url());
+            payload.end_object();
+            try {
+                var response = yield request_saddle_json(saddle.host, "POST", "/register",
+                    (owned) payload);
+                string reported_key = string_member(response, "ssh_public_key");
+                if (reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? "")) {
+                    saddle.ssh_public_key = reported_key;
+                    yield db.update_async<Saddle>(saddle);
+                }
+                print(@"[ClankerSaddle] Registered with saddle '$(saddle.name)'s daemon (token delivered)\n");
+            } catch (Error e) {
+                print(@"[ClankerSaddle] Saddle '$(saddle.name)'s daemon did not accept /register ($(e.message)); token stays on the row\n");
+            }
         }
 
         /**
-         * Generates and stores a fresh API token for `saddle_id`,
-         * replacing any earlier one on the wire (older rows remain valid
-         * until removed; in practice each saddle holds one).
+         * Startup migration of the per-saddle token: every saddle row
+         * gets its canonical token — the latest legacy `api_tokens`
+         * value it already holds (daemons registered before plan 10
+         * keep their credential), or a freshly minted one. Existing
+         * rows with a token are left alone.
          */
+        public async void ensure_saddle_tokens() throws Error {
+            foreach (var saddle in yield list_saddles()) {
+                if ((saddle.token ?? "").length > 0) {
+                    continue;
+                }
+                string? legacy = yield token_for_saddle(saddle.id);
+                saddle.token = legacy != null && ((!)legacy).length > 0
+                    ? (!)legacy : mint_saddle_token();
+                yield db.update_async<Saddle>(saddle);
+                print(@"[ClankerSaddle] Saddle '$(saddle.name)' token ready ($(saddle.token.substring(0, 12))…)\n");
+            }
+        }
+
+        /**
+          * Generates and stores a fresh API token for `saddle_id`,
+          * replacing any earlier one on the wire (older rows remain valid
+          * until removed; in practice each saddle holds one).
+          */
         public async string generate_token(int64 saddle_id) throws Error {
             var row = new ApiToken() {
                 saddle_id = saddle_id,
@@ -134,14 +197,20 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * True when `token` matches a stored {@link ApiToken} row of
-         * `saddle_id` — the credential check for the `/api/saddle/*`
-         * endpoints.
+         * True when `token` matches the credential of `saddle_id` — its
+         * per-saddle row token (plan 10), or any legacy `api_tokens`
+         * row still held for it (tokens minted before the column
+         * existed keep validating) — the credential check for the
+         * `/api/saddle/*` endpoints.
          */
         public async bool validate_token(int64 saddle_id, string token) throws Error {
             if (token.length == 0) {
                 return false;
             }
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle != null && ((!)saddle).token == token) {
+                return true;
+            }
             var rows = yield db.query<ApiToken>()
                 .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
                 .materialise_async();
@@ -153,62 +222,50 @@ namespace ClankerSaddle.Services {
             return false;
         }
 
-        /** The most recent API token issued for `saddle_id`, or null. */
-        public async string? token_for_saddle(int64 saddle_id) throws Error {
-            ApiToken? latest = null;
-            var rows = yield db.query<ApiToken>()
-                .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
-                .order_by(expr("id"))
-                .materialise_async();
-            foreach (var row in rows) {
-                latest = row;
-            }
-            return latest != null ? ((!)latest).token : null;
-        }
-
         /**
-         * The dedicated callback token authorising daemon calls to
-         * `POST /api/saddle/note` (`saddle note`): minted on first use
-         * and at startup, kept stable afterwards. It lives in
-         * `api_tokens` with `saddle_id` 0, so it matches no registered
-         * saddle and cannot be used on the per-saddle endpoints.
+         * The saddle a bearer token belongs to, or null — the
+         * credential check for the id-less `/api/saddle/*` endpoints
+         * (`next-task`, `note`, `subtask`, `tasks`): the row token
+         * first, then any legacy `api_tokens` row (mapped to its
+         * saddle). Removing the saddle revokes both.
          */
-        public async string ensure_callback_token() throws Error {
-            var existing = yield db.query<ApiToken>()
-                .where(expr("saddle_id == $0", elem<int64?>(0)))
-                .order_by(expr("id"))
-                .materialise_async();
-            foreach (var row in existing) {
-                if (row.token.has_prefix(CALLBACK_TOKEN_PREFIX)) {
-                    return row.token;
-                }
+        public async Saddle? saddle_for_token(string token) throws Error {
+            if (token.length == 0) {
+                return null;
             }
-            var token = new ApiToken() {
-                saddle_id = 0,
-                token = @"$(CALLBACK_TOKEN_PREFIX)$(Uuid.string_random().replace("-", ""))",
-                created_at = new DateTime.now_local()
-            };
-            yield db.insert_async<ApiToken>(token);
-            return token.token;
+            var by_row = yield db.query<Saddle>()
+                .where(expr("token == $0", elem(token)))
+                .first_async();
+            if (by_row != null) {
+                return by_row;
+            }
+            var legacy = yield db.query<ApiToken>()
+                .where(expr("token == $0", elem(token)))
+                .first_async();
+            if (legacy == null) {
+                return null;
+            }
+            return yield get_saddle(((!)legacy).saddle_id);
         }
 
         /**
-         * True when `token` is the dedicated callback token — the
-         * credential check for `POST /api/saddle/note`.
+         * The most recent API token issued for `saddle_id`, or null: the
+         * row token first, then the latest legacy `api_tokens` row.
          */
-        public async bool validate_callback_token(string token) throws Error {
-            if (token.length == 0 || !token.has_prefix(CALLBACK_TOKEN_PREFIX)) {
-                return false;
+        public async string? token_for_saddle(int64 saddle_id) throws Error {
+            var saddle = yield get_saddle(saddle_id);
+            if (saddle != null && (((!)saddle).token ?? "").length > 0) {
+                return ((!)saddle).token;
             }
+            ApiToken? latest = null;
             var rows = yield db.query<ApiToken>()
-                .where(expr("saddle_id == $0", elem<int64?>(0)))
+                .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
+                .order_by(expr("id"))
                 .materialise_async();
             foreach (var row in rows) {
-                if (row.token == token) {
-                    return true;
-                }
+                latest = row;
             }
-            return false;
+            return latest != null ? ((!)latest).token : null;
         }
 
         /**
@@ -233,7 +290,13 @@ namespace ClankerSaddle.Services {
             return "http://localhost:8080";
         }
 
-        /** Removes a saddle registration; tasks it was running are re-queued. */
+        /**
+         * Removes a saddle registration; tasks it was running are
+         * re-queued. Revocation: the row (and its per-saddle token)
+         * dies here, and every legacy `api_tokens` row held for the
+         * saddle is deleted — nothing authorises `/api/saddle/*` for
+         * it afterwards.
+         */
         public async void unregister_saddle(int64 id) throws Error {
             var saddle = yield get_saddle(id);
             if (saddle == null) {
@@ -250,6 +313,11 @@ namespace ClankerSaddle.Services {
                     yield db.update_async<TaskEntity>((!)task);
                 }
             }
+            foreach (var row in yield db.query<ApiToken>()
+                    .where(expr("saddle_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<ApiToken>(row);
+            }
             yield db.delete_async<Saddle>((!)saddle);
             yield pulse.bump(0, "saddle removed");
         }
@@ -304,7 +372,9 @@ namespace ClankerSaddle.Services {
          * Persists the daemon's reported `ssh_public_key` onto the saddle
          * row (the plan-9 contract: the key travels on /status, empty
          * until the daemon generates one; the saddles page shows it for
-         * adding as a deploy key). Only writes when it changed.
+         * adding as a deploy key). A single-column UPDATE — the
+         * full-row write could stomp a concurrent ask claim. Only writes
+         * when it changed.
          */
         private async void store_reported_ssh_key(Saddle saddle, Json.Object status) throws Error {
             if (!status.has_member("ssh_public_key")) {
@@ -315,11 +385,22 @@ namespace ClankerSaddle.Services {
             if (reported == stored) {
                 return;
             }
-            saddle.ssh_public_key = reported.length > 0 ? reported : null;
-            yield db.update_async<Saddle>(saddle);
+            var command = connection.create_command(
+                "UPDATE saddles SET ssh_public_key = :key WHERE id = :id");
+            command.with_parameter<Invercargill.Element>("key", elem(reported.length > 0 ? reported : null));
+            command.with_parameter<Invercargill.Element>("id", elem<int64?>(saddle.id));
+            yield command.execute_non_query_async();
         }
 
-        private void record_status_probe(int64 saddle_id, Json.Object status) {
+        /**
+         * Records a /status observation: the health snapshot, the
+         * daemon-reported ssh key, and — because the daemon surfaces
+         * `resumed` on the FIRST status after a workspace restore,
+         * whichever probe happens to receive it (the dispatcher's poll,
+         * a heal pass, a manual refresh) — the once-per-session resume
+         * progress entry.
+         */
+        private async void record_status_probe(Saddle saddle, Json.Object status) throws Error {
             string state = status.has_member("state")
                 ? status.get_string_member_with_default("state", "") : "";
             int64 uptime = status.has_member("uptime_seconds")
@@ -328,7 +409,21 @@ namespace ClankerSaddle.Services {
             int64 activity = status.has_member("last_activity_epoch")
                 && status.get_member("last_activity_epoch").get_value_type() == typeof(int64)
                 ? status.get_int_member("last_activity_epoch") : 0;
-            poll_state.record_probe(saddle_id, state, uptime, activity);
+            poll_state.record_probe(saddle.id, state, uptime, activity);
+            yield store_reported_ssh_key(saddle, status);
+
+            // The "interrupted -> resumed" timeline arc. Absent on older
+            // daemons; marked once per session in SaddlePollState.
+            if (status.has_member("resumed")
+                    && status.get_member("resumed").get_value_type() == typeof(bool)
+                    && status.get_boolean_member("resumed")
+                    && saddle.current_task_id > 0) {
+                var session = yield task_service.open_session_for_task(saddle.current_task_id);
+                if (session != null && poll_state.mark_resumed(((!)session).id)) {
+                    yield task_service.add_progress(saddle.current_task_id,
+                        "Saddle restored the session context; task resumed");
+                }
+            }
         }
 
         /**
@@ -439,6 +534,25 @@ namespace ClankerSaddle.Services {
             }
         }
 
+        /**
+         * Wakes a sleeping daemon (plan 10 wire contract): `POST /wake`
+         * `{}` — a capable task is ready; sleeping daemons ask for work,
+         * busy ones ignore it, idle-awake ones re-ask. Idempotent. The
+         * caller records the pending wake for the lost-wake guard.
+         */
+        public async void wake_saddle(Saddle saddle) throws Error {
+            var payload = new Json.Builder();
+            payload.begin_object();
+            payload.end_object();
+            Json.Node? root = null;
+            int status_code = yield request_saddle(saddle.host, "POST", "/wake",
+                (owned) payload, out root);
+            if (status_code >= 400) {
+                throw new Error(Quark.from_string("clanker-saddle"), 49,
+                    @"wake returned HTTP $(status_code)");
+            }
+        }
+
         /**
          * Cancelling / requeueing / stopping a task with a live session
          * now reaches its daemon: `POST /abort` (authoritative stop, the
@@ -512,7 +626,7 @@ namespace ClankerSaddle.Services {
                     var status = yield request_saddle_json(saddle.host, "GET", "/status", null);
                     string state = status.has_member("state")
                         ? status.get_string_member("state") : "";
-                    record_status_probe(saddle.id, status);
+                    yield record_status_probe(saddle, status);
                     if (state == "idle") {
                         saddle.status = "idle";
                         saddle.current_task_id = 0;
@@ -567,7 +681,7 @@ namespace ClankerSaddle.Services {
                         // Unreachable: the three-strike path owns that.
                         continue;
                     }
-                    record_status_probe(saddle.id, status);
+                    yield record_status_probe(saddle, status);
                     string state = status.has_member("state")
                         ? status.get_string_member("state") : "";
                     if (state == "idle") {
@@ -738,7 +852,13 @@ namespace ClankerSaddle.Services {
 
         /**
          * Updates a saddle's status and current task. A null
-         * `current_task_id` clears the assignment (and `allocated_at`).
+         * `current_task_id` clears the assignment (and `allocated_at`) —
+         * no current caller does (the poller parks via
+         * {@link park_if_current}). The busy write is a single
+         * conditional UPDATE: the ask allocation claims concurrently
+         * from HTTP asks, and a load-modify-write here could stomp a
+         * fresh claim with a stale row — the write only lands when the
+         * saddle holds no OTHER task.
          */
         public async void update_saddle_status(int64 id, string status, int64? current_task_id) throws Error {
             var saddle = yield get_saddle(id);
@@ -746,13 +866,45 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 10,
                     @"No saddle exists with id $(id)");
             }
-            ((!)saddle).status = status;
-            ((!)saddle).current_task_id = current_task_id != null ? ((!)current_task_id) : 0;
-            ((!)saddle).allocated_at = current_task_id != null ? new DateTime.now_local() : null;
-            yield db.update_async<Saddle>((!)saddle);
+            if (current_task_id == null) {
+                ((!)saddle).status = status;
+                ((!)saddle).current_task_id = 0;
+                ((!)saddle).allocated_at = null;
+                yield db.update_async<Saddle>((!)saddle);
+            } else {
+                var command = connection.create_command(
+                    "UPDATE saddles SET status = :status, current_task_id = :task,"
+                    + " allocated_at = :now WHERE id = :id"
+                    + " AND (current_task_id = 0 OR current_task_id = :task)");
+                command.with_parameter<Invercargill.Element>("status", elem(status));
+                command.with_parameter<Invercargill.Element>("id", elem<int64?>(id));
+                command.with_parameter<Invercargill.Element>("task", elem<int64?>(((!)current_task_id)));
+                command.with_parameter<Invercargill.Element>("now",
+                    elem<DateTime?>(new DateTime.now_local()));
+                yield command.execute_non_query_async();
+            }
             yield pulse.bump(current_task_id ?? 0, @"saddle $(status)");
         }
 
+        /**
+         * Parks a saddle (idle, no task) only when it still runs
+         * `task_id` — a single conditional UPDATE, atomic against the
+         * ask allocation claiming a NEW task for the saddle between the
+         * poller's read and its write (the poller's "blocked"/"suspended"
+         * parking races endpoint-side claims otherwise, leaving the
+         * freshly-claimed task assigned to a busy@0 saddle nothing
+         * polls).
+         */
+        public async void park_if_current(int64 saddle_id, int64 task_id) throws Error {
+            var command = connection.create_command(
+                "UPDATE saddles SET status = 'idle', current_task_id = 0, allocated_at = NULL"
+                + " WHERE id = :id AND current_task_id = :task");
+            command.with_parameter<Invercargill.Element>("id", elem<int64?>(saddle_id));
+            command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
+            yield command.execute_non_query_async();
+            yield pulse.bump(0, "saddle parked");
+        }
+
         /** The first saddle with status 'idle', or null when none is free. */
         public async Saddle? get_idle_saddle() throws Error {
             return yield db.query<Saddle>()
@@ -787,6 +939,7 @@ namespace ClankerSaddle.Services {
             if (saddle == null) {
                 return;
             }
+            int64 held_task_at_entry = ((!)saddle).current_task_id;
             if (((!)saddle).current_task_id > 0) {
                 int64 task_id = ((!)saddle).current_task_id;
                 var task = yield db.query<TaskEntity>()
@@ -816,28 +969,26 @@ namespace ClankerSaddle.Services {
                     yield db.update_async<ClankerSaddle.Data.Session>(session);
                 }
             }
-            ((!)saddle).status = "offline";
-            ((!)saddle).current_task_id = 0;
-            ((!)saddle).allocated_at = null;
-            yield db.update_async<Saddle>((!)saddle);
+            var offline_write = connection.create_command(
+                "UPDATE saddles SET status = 'offline', current_task_id = 0, allocated_at = NULL"
+                + " WHERE id = :id AND current_task_id = :seen");
+            offline_write.with_parameter<Invercargill.Element>("id", elem<int64?>(id));
+            offline_write.with_parameter<Invercargill.Element>("seen",
+                elem<int64?>(held_task_at_entry));
+            yield offline_write.execute_non_query_async();
+            // An offline saddle is neither sleeping nor wake-awaiting;
+            // a restarted daemon re-establishes both by asking.
+            poll_state.clear_ask(id);
             yield pulse.bump(0, "saddle unreachable");
         }
 
         /**
          * Pushes a task assignment to the saddle daemon's `POST /start`
-         * endpoint in its wire shape — `{task, rules, project_notes,
-         * callback, llm_config}` — with the project, co-developed/
-         * reference links, branch fields, plan and recent comments inside
-         * the task object, plus any block answers not yet handed over (a
-         * resumed workspace picks its pending answers up from the
-         * assignment itself), the effective rule set ({@link
-         * RuleService.effective_rules}) as the top-level `rules` array,
-         * the capped `project_notes` of the primary and co-developed
-         * projects, and the note-creation `callback` (base URL + bearer
-         * token). Project checkouts are addressed by the
-         * `SADDLE_PROJECTS_ROOT` convention (`root/task-<task id>/name`)
-         * and cloned from each project's `git_url`; the LLM endpoint by
-         * the {@link LlmEndpoint} environment. Returns the parsed JSON
+         * endpoint (the phase-1 rollback path — dispatch itself flows
+         * through `POST /api/saddle/next-task` now; /start survives for
+         * resumes against pre-flip daemons and as the daemon-side
+         * fallback). The payload is assembled by
+         * {@link build_assignment_payload}; returns the parsed JSON
          * response body.
          */
         public async Json.Object push_task_to_saddle(int64 saddle_id, TaskEntity task, int64 session_id,
@@ -848,6 +999,43 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 11,
                     @"No saddle exists with id $(saddle_id)");
             }
+            var assignment = yield build_assignment_payload((!)saddle, task, session_id,
+                projects, resume_notice, branch_advanced);
+            var payload = new Json.Builder();
+            payload.begin_object();
+            foreach (string member in assignment.get_members()) {
+                payload.set_member_name(member);
+                payload.add_value(assignment.get_member(member).copy());
+            }
+            payload.end_object();
+            return yield request_saddle_json(((!)saddle).host, "POST", "/start",
+                (owned) payload);
+        }
+
+        /**
+         * Assembles the assignment payload shared by the /start push and
+         * the `POST /api/saddle/next-task` response — `{task, rules,
+         * subtasks, project_notes, callback, llm_config, session_id}` —
+         * with the project, co-developed/reference links, branch fields,
+         * plan and recent comments inside the task object, plus any
+         * block answers not yet handed over (a resumed workspace picks
+         * its pending answers up from the assignment itself), the
+         * effective rule set ({@link RuleService.effective_rules}) as
+         * 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
+         * `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
+         * addressed by the `SADDLE_PROJECTS_ROOT` convention
+         * (`root/task-<task id>/name`) and cloned from each project's
+         * `git_url`; the LLM endpoint by the {@link LlmEndpoint}
+         * environment.
+         */
+        public async Json.Object build_assignment_payload(Saddle saddle, TaskEntity task,
+                int64 session_id, ProjectService projects, string? resume_notice = null,
+                bool? branch_advanced = null) throws Error {
             var endpoint = LlmEndpoint.from_environment();
             if (endpoint == null) {
                 throw new Error(Quark.from_string("clanker-saddle"), 24,
@@ -1092,13 +1280,20 @@ namespace ClankerSaddle.Services {
 
             // The wire contract's `callback` addition: this app's
             // externally reachable base URL plus the bearer token the
-            // daemon presents when posting notes back.
+            // daemon presents when posting back — its own per-saddle
+            // credential (plan 10), which every `/api/saddle/*` call
+            // carries.
+            string callback_token = saddle.token ?? "";
+            if (callback_token.length == 0) {
+                string? legacy = yield token_for_saddle(saddle.id);
+                callback_token = legacy ?? "";
+            }
             payload.set_member_name("callback");
             payload.begin_object();
             payload.set_member_name("base_url");
             payload.add_string_value(callback_base_url());
             payload.set_member_name("token");
-            payload.add_string_value(yield ensure_callback_token());
+            payload.add_string_value(callback_token);
             payload.end_object();
 
             payload.set_member_name("llm_config");
@@ -1110,10 +1305,16 @@ namespace ClankerSaddle.Services {
             payload.set_member_name("model");
             payload.add_string_value(((!)endpoint).model);
             payload.end_object();
+
+            // The web-side run row the daemon's pushed telemetry keys on
+            // (plan-10 phase-1 wire addition: it rides every assignment
+            // so /start consumers can ignore it harmlessly).
+            payload.set_member_name("session_id");
+            payload.add_int_value(session_id);
             payload.end_object();
 
-            return yield request_saddle_json(((!)saddle).host, "POST", "/start",
-                (owned) payload);
+            var root = payload.get_root().copy();
+            return root.get_object();
         }
 
         /**
@@ -1194,20 +1395,10 @@ namespace ClankerSaddle.Services {
 
             var status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
             string state = status.has_member("state") ? status.get_string_member("state") : "";
-            record_status_probe(saddle_id, status);
-            yield store_reported_ssh_key(((!)saddle), status);
-
-            // A crash-resumable daemon reports `resumed: true` on the
-            // first status after restoring a workspace: surface it once
-            // per session as a progress entry (the timeline's
-            // "interrupted → resumed" arc). Absent on older daemons.
-            if (status.has_member("resumed")
-                    && status.get_member("resumed").get_value_type() == typeof(bool)
-                    && status.get_boolean_member("resumed")
-                    && session != null && poll_state.mark_resumed(((!)session).id)) {
-                yield tasks.add_progress(task_id,
-                    "Saddle restored the session context; task resumed");
-            }
+            // The resumed flag (the "interrupted → resumed" timeline arc)
+            // is observed inside the probe: whichever pass polls first
+            // converts it, once per session.
+            yield record_status_probe(((!)saddle), status);
 
             switch (state) {
                 case "done":
@@ -1229,14 +1420,14 @@ namespace ClankerSaddle.Services {
                     // freeing the saddle for other work. The blocked task
                     // plus its open session is the pause record the
                     // dispatcher resumes from once the block is answered.
-                    yield update_saddle_status(saddle_id, "idle", null);
+                    yield park_if_current(saddle_id, task_id);
                     break;
                 case "suspended":
                     // `saddle suspend`: the same parking as a block, but
                     // the web drives resumption — a fresh /start (with a
                     // resume_notice) once a subtask event lands.
                     yield tasks.suspend_task(task_id);
-                    yield update_saddle_status(saddle_id, "idle", null);
+                    yield park_if_current(saddle_id, task_id);
                     break;
                 case "working":
                     // Never downgrade a pending review: the agent keeps
@@ -1265,8 +1456,9 @@ namespace ClankerSaddle.Services {
         /**
          * Probes a saddle's `GET /status` and refreshes its registered
          * status: 'idle' or 'busy' from the daemon state, 'offline' when
-         * the daemon cannot be reached. The current task assignment is
-         * left untouched.
+         * the daemon cannot be reached. The write is guarded on the
+         * assignment seen at load — an ask allocation may claim the
+         * saddle while the probe is in flight, and must not be stomped.
          */
         public async void refresh_saddle_status(int64 id) throws Error {
             var saddle = yield get_saddle(id);
@@ -1274,34 +1466,39 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 17,
                     @"No saddle exists with id $(id)");
             }
+            int64 seen_task = ((!)saddle).current_task_id;
+            string next_status = "idle";
+            int64 next_task = 0;
+            DateTime? next_allocated = null;
             try {
                 var status = yield request_saddle_json(((!)saddle).host, "GET", "/status", null);
                 string state = status.has_member("state") ? status.get_string_member_with_default("state", "") : "";
-                record_status_probe(id, status);
-                yield store_reported_ssh_key(((!)saddle), status);
+                yield record_status_probe(((!)saddle), status);
                 if (state == "working") {
                     // Reconcile with the web's own records: busy only when
                     // a live session truly holds this saddle.
                     var session = yield open_session_on_saddle(id);
-                    ((!)saddle).status = "busy";
-                    ((!)saddle).current_task_id = session != null ? ((!)session).task_id : 0;
-                    ((!)saddle).allocated_at = session != null ? ((!)session).started_at : null;
-                } else {
-                    // Idle AND the parked states (blocked/suspended, plus
-                    // terminal and unknown states): the workspace is
-                    // parked, so the saddle is logically free — marking it
-                    // busy here used to wedge assignment against a daemon
-                    // that would never poll.
-                    ((!)saddle).status = "idle";
-                    ((!)saddle).current_task_id = 0;
-                    ((!)saddle).allocated_at = null;
-                }
+                    next_status = "busy";
+                    next_task = session != null ? ((!)session).task_id : 0;
+                    next_allocated = session != null ? ((!)session).started_at : null;
+                }
+                // Idle AND the parked states (blocked/suspended, plus
+                // terminal and unknown states): the workspace is
+                // parked, so the saddle is logically free — marking it
+                // busy here used to wedge assignment against a daemon
+                // that would never poll.
             } catch (Error e) {
-                ((!)saddle).status = "offline";
-                ((!)saddle).current_task_id = 0;
-                ((!)saddle).allocated_at = null;
-            }
-            yield db.update_async<Saddle>((!)saddle);
+                next_status = "offline";
+            }
+            var command = connection.create_command(
+                "UPDATE saddles SET status = :status, current_task_id = :task,"
+                + " allocated_at = :allocated WHERE id = :id AND current_task_id = :seen");
+            command.with_parameter<Invercargill.Element>("status", elem(next_status));
+            command.with_parameter<Invercargill.Element>("task", elem<int64?>(next_task));
+            command.with_parameter<Invercargill.Element>("allocated", elem<DateTime?>(next_allocated));
+            command.with_parameter<Invercargill.Element>("id", elem<int64?>(id));
+            command.with_parameter<Invercargill.Element>("seen", elem<int64?>(seen_task));
+            yield command.execute_non_query_async();
             yield pulse.bump(0, "saddle status refreshed");
         }
 
@@ -1519,11 +1716,14 @@ namespace ClankerSaddle.Services {
          * Resumes a task whose block has been answered on the saddle that
          * holds its open (parked) session. A daemon still waiting in its
          * answer loop (`state == blocked`) receives the answer via
-         * `POST /answer` and carries on; a parked workspace is re-pushed
-         * via `POST /start` — the assignment carries the pending answers —
-         * and the saddle returns to busy. Returns true when the task was
-         * resumed; false when the saddle is unavailable (busy with other
-         * work or offline).
+         * `POST /answer` and carries on; a working one gets answered
+         * verdicts via `POST /notify`. An IDLE daemon splits by
+         * capability (plan 10): a post-flip daemon (it has asked) asks
+         * again on its own — the affinity allocation serves the resume
+         * on its next ask, and only a pre-flip daemon (never asked)
+         * still takes the `POST /start` re-push (the rollback path).
+         * Returns true when the task was resumed; false when the saddle
+         * is unavailable or the resume belongs to the ask flow.
          */
         public async bool resume_answered_task(TaskEntity task, ClankerSaddle.Data.Session session,
                 TaskService tasks, ProjectService projects, string? resume_notice = null) throws Error {
@@ -1550,19 +1750,25 @@ namespace ClankerSaddle.Services {
                 yield update_saddle_status(((!)saddle).id, "busy", task.id);
             } else if (state == "working") {
                 // A non-blocking prompt was answered (e.g. `saddle review`,
-                // which reports without waiting): re-pushing /start would
-                // collide with the live session, so the verdict joins the
+                // which reports without waiting): re-pushing would collide
+                // with the live session, so the verdict joins the
                 // conversation as a human comment via /notify.
                 yield deliver_answered_verdicts((!)saddle, task.id, advanced);
                 yield tasks.update_task(task.id, null, null, null, STATUS_WORKING);
                 yield update_saddle_status(((!)saddle).id, "busy", task.id);
+            } else if (poll_state.has_ever_asked(((!)saddle).id)) {
+                // A post-flip daemon parked its workspace and asks on its
+                // own; the affinity allocation carries the resume (plus
+                // the answers) on its next ask. Pushing /start here would
+                // double-deliver against that ask.
+                return false;
             } else {
-                // Idle daemon (a parked/blocked/suspended workspace): a
-                // fresh /start adopts the workspace and restores the
-                // conversation — carrying the resume notice, when the
-                // dispatcher resumes a suspended parent, as the injected
-                // next user turn, plus whether the shared branch moved
-                // while parked.
+                // Idle pre-flip daemon (a parked/blocked/suspended
+                // workspace): a fresh /start adopts the workspace and
+                // restores the conversation — carrying the resume notice,
+                // when the dispatcher resumes a suspended parent, as the
+                // injected next user turn, plus whether the shared branch
+                // moved while parked.
                 yield update_saddle_status(((!)saddle).id, "busy", task.id);
                 yield seed_session_cursor(((!)saddle).id, session.id);
                 yield push_task_to_saddle(((!)saddle).id, task, session.id, projects,

+ 23 - 13
src/Services/TaskService.vala

@@ -1,6 +1,7 @@
 using Invercargill;
 using Invercargill.DataStructures;
 using Invercargill.Expressions;
+using InvercargillSql;
 using InvercargillSql.Orm;
 using Inversion;
 using ClankerSaddle.Data;
@@ -84,6 +85,7 @@ namespace ClankerSaddle.Services {
     public class TaskService : Object {
 
         private OrmSession db = inject<OrmSession>();
+        private Connection connection = inject<Connection>();
         private StatePulse pulse = inject<StatePulse>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
 
@@ -1112,13 +1114,22 @@ namespace ClankerSaddle.Services {
          * single report.
          */
         public async string subtask_status_report(int64 parent_id) throws Error {
+            return build_subtask_report((yield db.query<TaskEntity>()
+                .where(expr("parent_task_id == $0", elem<int64?>(parent_id)))
+                .order_by(expr("id"))
+                .materialise_async()));
+        }
+
+        /**
+         * The formatting core of {@link subtask_status_report}, shared
+         * with the ask/allocation path (which holds the children rows
+         * already).
+         */
+        public static string build_subtask_report(Lot<TaskEntity> children) {
             var report = new StringBuilder();
             report.append("Subtask update — resuming your suspended session:\n\n");
             bool all_terminal = true;
-            foreach (var child in yield db.query<TaskEntity>()
-                    .where(expr("parent_task_id == $0", elem<int64?>(parent_id)))
-                    .order_by(expr("id"))
-                    .materialise_async()) {
+            foreach (var child in children) {
                 if (child.status == STATUS_DONE) {
                     // append_printf: printf would truncate what's there.
                     report.append_printf("- #%lld %s — completed: %s\n", child.id, child.title,
@@ -1314,15 +1325,14 @@ namespace ClankerSaddle.Services {
         }
 
         private async void free_saddles_running(int64 task_id) throws Error {
-            var saddles = yield db.query<Saddle>()
-                .where(expr("current_task_id == $0", elem<int64?>(task_id)))
-                .materialise_async();
-            foreach (var saddle in saddles) {
-                saddle.status = "idle";
-                saddle.current_task_id = 0;
-                saddle.allocated_at = null;
-                yield db.update_async<Saddle>(saddle);
-            }
+            // One conditional UPDATE, atomic against a concurrent ask
+            // allocation claiming a new task for the saddle between a
+            // read and a full-row write.
+            var command = connection.create_command(
+                "UPDATE saddles SET status = 'idle', current_task_id = 0, allocated_at = NULL"
+                + " WHERE current_task_id = :task");
+            command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
+            yield command.execute_non_query_async();
         }
     }
 }

+ 13 - 8
src/main.vala

@@ -35,6 +35,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0007_SuspendedAt>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0008_AutoApprove>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0009_GitIdentity>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0010_SaddleToken>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -61,6 +62,7 @@ int main(string[] args) {
         application.add_scoped<SaddleService>();
         application.add_scoped<RuleService>();
         application.add_scoped<CapabilityService>();
+        application.add_scoped<AllocationService>();
         application.add_singleton<SubtaskEventQueue>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<StatePulse>();
@@ -134,6 +136,8 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/{id}/update", Method.POST));
         application.add_endpoint<SaddlePollEndpoint>(
             new EndpointRoute("/api/saddle/{id}/poll", Method.GET));
+        application.add_endpoint<SaddleNextTaskEndpoint>(
+            new EndpointRoute("/api/saddle/next-task", Method.POST));
         application.add_endpoint<SaddleNoteEndpoint>(
             new EndpointRoute("/api/saddle/note", Method.POST));
         application.add_endpoint<SaddleSubtaskEndpoint>(
@@ -147,7 +151,7 @@ int main(string[] args) {
 
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);
-            mint_callback_token.begin(application.container);
+            ensure_saddle_tokens.begin(application.container);
             start_dispatcher.begin(application.container);
             return false;
         });
@@ -192,16 +196,17 @@ private async void start_dispatcher(Container container) {
 }
 
 /**
- * Mints the dedicated 'saddle-callback' token at startup when absent —
- * the credential daemons use to POST `saddle note` remarks back (see
- * {@link ClankerSaddle.Api.SaddleNoteEndpoint}).
+ * Startup migration of the per-saddle tokens (plan 10): every saddle
+ * row gets its canonical credential — the legacy `api_tokens` value it
+ * already holds, or a freshly minted one — so the operator can copy it
+ * to daemons registered before the flip (see
+ * {@link ClankerSaddle.Services.SaddleService.ensure_saddle_tokens}).
  */
-private async void mint_callback_token(Container container) {
+private async void ensure_saddle_tokens(Container container) {
     try {
         var scope = container.create_scope();
-        string token = yield scope.resolve<SaddleService>().ensure_callback_token();
-        print("[ClankerSaddle] Saddle callback token ready (%s…)\n", token.substring(0, 16));
+        yield scope.resolve<SaddleService>().ensure_saddle_tokens();
     } catch (Error e) {
-        printerr("[ClankerSaddle] Callback token minting failed: %s\n", e.message);
+        printerr("[ClankerSaddle] Saddle token migration failed: %s\n", e.message);
     }
 }

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


+ 633 - 176
test/web_stack_test.py

@@ -7,20 +7,34 @@ LLM), this suite spawns everything it needs:
 - the clanker-saddle app binary (path from argv[1], or builddir default)
   in a scratch directory with its own web-config.json (statum keys copied
   from the repo, plus `saddle_callback_base_url`) and SQLite database
-- an in-process fake saddle daemon speaking the wire contract: POST
-  /start (captures the payload), GET /status (scripted state machine),
-  GET /output (empty), GET /log?since= (raw PTY chunks, one new chunk
-  per request so since-paging is exercised)
+- in-process fake saddle daemons speaking the wire contract: POST
+  /register (plan 10: receives {saddle_token, web_base_url}, answers
+  {ok, ssh_public_key, version}), POST /wake (records the wake; the
+  daemon then asks POST /api/saddle/next-task like a phase-1 daemon),
+  POST /start (captures the payload — the rollback path), GET /status
+  (scripted state machine), GET /output (empty), GET /log?since= (raw
+  PTY chunks, one new chunk per request so since-paging is exercised)
 
 It then drives the app the way statum.js does (entrypoint loads + action
 POSTs) and asserts the Workstream B features against the wire contract:
 
 3. plan-review blocks hide their duplicate body preview
-5. rules CRUD/attach + the effective `rules` array in /start
+5. rules CRUD/attach + the effective `rules` array in assignments
 6. project notes, POST /api/saddle/note, `project_notes` + `callback`
-   in /start
+   in assignments
    plus the raw-log side: session_raw_log polling + terminal raw view
 
+Plan 10 phase 1 (work acquisition flip):
+
+- per-saddle tokens: registration mints + /register-delivers them,
+  legacy tokens migrate onto the row at startup, removal revokes
+- token auth on every /api/saddle/* endpoint
+- next-task allocation: concurrent asks race exactly one winner,
+  affinity pinning for parked tasks, resume_notice on suspended resumes
+- dispatcher: wake -> ask -> allocate roundtrip, the lost-wake guard
+  (no ask after a wake -> offline, re-wake the next capable), and the
+  pre-flip /answer + /start resume paths kept as the rollback route
+
 Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
 """
 import json
@@ -194,44 +208,65 @@ def db():
 
 
 class FakeSaddle:
-    """In-process saddle daemon implementing the wire contract."""
+    """In-process saddle daemon implementing the wire contract.
+
+    Phase-1 ask behaviour: the daemon asks POST /api/saddle/next-task
+    whenever idle-awake — at registration (its token arrives via
+    /register), on POST /wake, after its task reaches a terminal state,
+    and whenever a /status poll observes it parked idle (throttled).
+    Every trigger can be disabled per test for determinism; ask_now()
+    performs a synchronous manual ask.
+    """
 
     PROMPT = "bbarrow@fedora:/workspace/task-1$ "
 
     def __init__(self):
         self.lock = threading.Lock()
-        self.start_payloads = []       # captured POST /start bodies
+        self.start_payloads = []       # captured assignment payloads (/start + next-task 200s)
         self.chunks = []               # [{seq, text}] — the raw log ring
         self.next_seq = 1
         self.state = "idle"
         self.summary = ""
         self.state_entered = time.monotonic()
-        self.fail_next = False          # next /start drives the task to failed
-        self.start_counts = {}          # task id -> /start count
+        self.fail_next = False          # next task drives the task to failed
+        self.start_counts = {}          # task id -> assignment count
         self.resumed_pending = False    # crash-resume flag on the next /status
-        self.suspend_after_start = False  # next task parks as suspended
+        self.suspend_on_title = None    # this task (by title) parks as suspended
         self.pending_output = []        # entries drained by the next /output poll
         self.output_seq = 100
         self.last_activity_epoch = int(time.time())
         self.subtask_status_events = []  # recorded POST /subtask-status payloads
         self.fail_subtask_status = False  # exercise the web's retry path
-        self.hang_next = {}              # path suffix -> hang count (never responds)
+        self.hang_next = {}             # path suffix -> hang count (never responds)
         self.fail_log_count = 0          # /log requests failing (500) while > 0
         self.scripted_log = None         # fixed chunk set served by /log
         self.scripted_next = 0           # the daemon-style next for it
         self.scripted_corpus = None      # chunks "produced" when a task starts
         self.race_texts = None           # chunks appended DURING /log response
-                                       # construction (the daemon-side bug)
+                                        # construction (the daemon-side bug)
         self.answer_bodies = []          # recorded POST /answer payloads
         self.abort_failures = 0          # /abort requests failing (500) while > 0
         self.abort_log = []              # task ids aborted via POST /abort
         self.running_task = 0            # task id the daemon believes it runs
+        self.running_title = ""          # its title (suspend targeting)
         self.zombie_409 = 0              # /start 409s once per unit (then decrements)
         self.conflict_running = 0        # while > 0, /start persistently 409s with this id
         self.hold_state = False          # freeze /status lifecycle transitions
-        self.ssh_public_key = ""         # reported on /status (plan-9 contract)
+        self.ssh_public_key = ""         # reported on /status + /register
         self.started_mono = time.monotonic()
         self.freeze_activity = False     # hold last_activity_epoch still
+        # --- plan 10 phase 1: ask/wake/register ---
+        self.token = None                # per-saddle token from POST /register
+        self.register_log = []           # recorded /register bodies
+        self.wake_log = []               # recorded POST /wake arrivals
+        self.asks = []                   # [{"status": int, "payload": dict|None}]
+        self.ask_on_register = True
+        self.ask_on_wake = True
+        self.ask_on_terminal = True
+        self.ask_on_idle_status = True
+        self.serve_register = True         # False: 404 /register (pre-flip daemon)
+        self.last_ask_trigger = 0.0
+        self.port = None
 
     def set_state(self, state, summary=None):
         """Advances the scripted lifecycle.
@@ -279,6 +314,63 @@ class FakeSaddle:
             chunks = [c for c in self.chunks if c["seq"] >= since]
             return chunks, self.next_seq
 
+    # --- plan 10 phase 1: the ask path ---
+
+    def ask_now(self):
+        """Synchronously asks the web for work (a phase-1 daemon's idle
+        ask). A 200 assignment is accepted exactly like POST /start;
+        returns (status, payload|None)."""
+        token = self.token
+        if token is None:
+            raise SystemExit("fake daemon asked before receiving its token")
+        status, payload = saddle_api("POST", "/api/saddle/next-task", token)
+        with self.lock:
+            self.asks.append({"status": status, "payload": payload})
+        if status == 200:
+            self.accept_assignment(payload)
+        return status, payload
+
+    def _trigger_ask(self, reason):
+        """Schedules a delayed ask (the daemon woke / went idle-awake)."""
+        def run():
+            try:
+                self.ask_now()
+            except OSError:
+                pass  # app restarting / shutting down mid-test
+        threading.Thread(target=lambda: (time.sleep(0.2), run()), daemon=True).start()
+
+    def maybe_trigger_ask(self, reason):
+        now = time.monotonic()
+        if now - self.last_ask_trigger < 1.0:
+            return
+        self.last_ask_trigger = now
+        self._trigger_ask(reason)
+
+    def accept_assignment(self, payload):
+        """A 200 next-task assignment: adopted exactly like POST /start."""
+        self.start_payloads.append(payload)
+        task = payload.get("task") or {}
+        task_id = task.get("id")
+        self.running_task = int(task_id or 0)
+        self.running_title = task.get("title") or ""
+        count = self.start_counts.get(task_id, 0) + 1
+        self.start_counts[task_id] = count
+        if count > 1:
+            # Crash-resume contract: re-pushing a task whose
+            # workspace still exists restores it; the first
+            # /status after the restore reports "resumed": true.
+            self.resumed_pending = True
+        self.append_chunk(self.PROMPT)
+        self.append_chunk("echo hello\r\n")
+        self.append_chunk("hello\r\n")
+        self.set_state("failing" if self.fail_next else "working")
+        self.fail_next = False
+        if self.scripted_corpus is not None:
+            with self.lock:
+                for chunk in self.scripted_corpus:
+                    self.scripted_log.append(dict(chunk))
+                self.scripted_next = self.scripted_corpus[-1]["seq"] + 1
+
     class Handler(BaseHTTPRequestHandler):
         daemon = None  # set on the class before serving
         protocol_version = "HTTP/1.1"
@@ -307,6 +399,34 @@ class FakeSaddle:
             self.daemon.touch_activity()
             length = int(self.headers.get("Content-Length") or 0)
             raw = self.rfile.read(length).decode() if length else "{}"
+            if self.path == "/register":
+                # Plan 10: the web delivers the per-saddle token + its
+                # own base URL; the daemon persists the token for its
+                # calls home and reports its ssh key + version.
+                if not self.daemon.serve_register:
+                    self._send(404, {"error": "no such route"})
+                    return
+                try:
+                    payload = json.loads(raw)
+                except ValueError:
+                    payload = {"raw": raw}
+                self.daemon.register_log.append(payload)
+                if payload.get("saddle_token"):
+                    self.daemon.token = payload["saddle_token"]
+                self._send(200, {"ok": True,
+                                 "ssh_public_key": self.daemon.ssh_public_key,
+                                 "version": "0.1-fake"})
+                if self.daemon.ask_on_register:
+                    self.daemon.maybe_trigger_ask("register")
+                return
+            if self.path == "/wake":
+                # Plan 10: a capable task is ready; a sleeping daemon
+                # asks for work.
+                self.daemon.wake_log.append(time.monotonic())
+                self._send(200, {"ok": True})
+                if self.daemon.ask_on_wake:
+                    self.daemon.maybe_trigger_ask("wake")
+                return
             if self.path == "/start":
                 if self.daemon.conflict_running > 0 or self.daemon.zombie_409 > 0:
                     # Wire contract: a machine-readable conflict.
@@ -327,26 +447,7 @@ class FakeSaddle:
                     payload = json.loads(raw)
                 except ValueError:
                     payload = {"raw": raw}
-                self.daemon.start_payloads.append(payload)
-                task_id = (payload.get("task") or {}).get("id")
-                self.daemon.running_task = int(task_id or 0)
-                count = self.daemon.start_counts.get(task_id, 0) + 1
-                self.daemon.start_counts[task_id] = count
-                if count > 1:
-                    # Crash-resume contract: re-pushing a task whose
-                    # workspace still exists restores it; the first
-                    # /status after the restore reports "resumed": true.
-                    self.daemon.resumed_pending = True
-                self.daemon.append_chunk(self.daemon.PROMPT)
-                self.daemon.append_chunk("echo hello\r\n")
-                self.daemon.append_chunk("hello\r\n")
-                self.daemon.set_state("failing" if self.daemon.fail_next else "working")
-                self.daemon.fail_next = False
-                if self.daemon.scripted_corpus is not None:
-                    with self.daemon.lock:
-                        for chunk in self.daemon.scripted_corpus:
-                            self.daemon.scripted_log.append(dict(chunk))
-                        self.daemon.scripted_next = self.daemon.scripted_corpus[-1]["seq"] + 1
+                self.daemon.accept_assignment(payload)
                 self._send(200, {})
             elif self.path == "/answer":
                 try:
@@ -365,6 +466,8 @@ class FakeSaddle:
                 self.daemon.running_task = 0
                 self.daemon.abort_log.append(aborted)
                 self._send(200, {"ok": True, "aborted_task_id": aborted})
+                # An aborted daemon returns to its idle loop: it asks.
+                self.daemon.maybe_trigger_ask("aborted")
             elif self.path == "/subtask-status":
                 try:
                     payload = json.loads(raw)
@@ -399,7 +502,7 @@ class FakeSaddle:
                     self._send(200, response)
                     return
                 if self.daemon.state == "failing":
-                    # Fails fast (a rejected /start script).
+                    # Fails fast (a rejected assignment script).
                     if time.monotonic() - self.daemon.state_entered >= 1:
                         self.daemon.set_state("failed",
                                               "bash -n rejected the script (simulated)")
@@ -408,13 +511,21 @@ class FakeSaddle:
                     # (dispatcher polls, stale-busy healing, refreshes)
                     # must not advance the lifecycle.
                     if time.monotonic() - self.daemon.state_entered >= 8:
-                        if self.daemon.suspend_after_start:
-                            # `saddle suspend`: park until a fresh /start
-                            # resumes the workspace (one-shot flag).
+                        if self.daemon.suspend_on_title \
+                                and self.daemon.suspend_on_title == self.daemon.running_title:
+                            # `saddle suspend`: park until a fresh assignment
+                            # resumes the workspace (title-targeted one-shot,
+                            # so an interleaved dispatch cannot eat the flag).
                             self.daemon.set_state("suspended")
-                            self.daemon.suspend_after_start = False
+                            self.daemon.suspend_on_title = None
                         else:
                             self.daemon.set_state("done", "fake daemon finished")
+                # A parked/terminal daemon is idle-awake: a phase-1
+                # daemon asks (and sleeps on 204). Throttled so status
+                # poll bursts do not hammer the ask endpoint.
+                if self.daemon.ask_on_idle_status and self.daemon.state in (
+                        "idle", "done", "failed", "suspended"):
+                    self.daemon.maybe_trigger_ask("idle-status")
                 self._send(200, response)
             elif self.path.startswith("/output"):
                 with self.daemon.lock:
@@ -454,10 +565,51 @@ class FakeSaddle:
                 self._send(404, {"error": "no such route"})
                 self.close_connection = True
 
-    def serve(self):
+    def run_lifecycle(self):
+        """Applies the scripted lifecycle on a timer (like a real daemon
+        whose task ends on its own, not when the web happens to poll):
+        working ages into done/suspended, failing into failed — and a
+        task END makes the idle-awake daemon ask for work again."""
+        while True:
+            time.sleep(0.5)
+            if self.hold_state:
+                continue
+            if self.state == "failing":
+                if time.monotonic() - self.state_entered >= 1:
+                    self.set_state("failed", "bash -n rejected the script (simulated)")
+                    if self.ask_on_terminal:
+                        self.maybe_trigger_ask("terminal")
+            elif self.state == "working":
+                if time.monotonic() - self.state_entered >= 8:
+                    if self.suspend_on_title and self.suspend_on_title == self.running_title:
+                        self.set_state("suspended")
+                        self.suspend_on_title = None
+                        # A parked daemon is idle-awake: it asks (and
+                        # sleeps on 204) so a later wake can reach it.
+                        if self.ask_on_idle_status:
+                            self.maybe_trigger_ask("parked")
+                    else:
+                        self.set_state("done", "fake daemon finished")
+                        if self.ask_on_terminal:
+                            self.maybe_trigger_ask("terminal")
+
+    def serve(self, port=None):
+        """Binds the daemon's HTTP server. A requested port that is
+        already taken (free_port() hands out recyclable ports — two
+        calls can collide) falls back to an OS-assigned one instead of
+        silently dying and impersonating whatever owns that port."""
+        if port is None:
+            port = DAEMON_PORT
         handler = type("BoundHandler", (FakeSaddle.Handler,), {"daemon": self})
-        self.server = ThreadingHTTPServer(("127.0.0.1", DAEMON_PORT), handler)
+        self.server = None
+        try:
+            self.server = ThreadingHTTPServer(("127.0.0.1", port), handler)
+        except OSError:
+            print("(fake daemon: port %d taken, falling back)" % port)
+            self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
+        self.port = self.server.server_address[1]
         threading.Thread(target=self.server.serve_forever, daemon=True).start()
+        threading.Thread(target=self.run_lifecycle, daemon=True).start()
         return self.server
 
     def kill(self):
@@ -486,12 +638,16 @@ def start_app():
         "SADDLE_RAW_PROBE_INTERVAL_S": "5",
         "SADDLE_STRAND_GRACE_S": "10",
         "SADDLE_WEDGE_THRESHOLD_S": "8",
+        "SADDLE_LOST_WAKE_S": "2",
         # Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
         # resolves the ancient /lib64 copies): empty makes the loader use
         # the binary's RUNPATH, which is the dev stack it was built
         # against.
         "LD_LIBRARY_PATH": "",
     })
+    log_previous = os.path.join(WORK, "app.log.1")
+    if os.path.exists(os.path.join(WORK, "app.log")):
+        shutil.move(os.path.join(WORK, "app.log"), log_previous)
     log = open(os.path.join(WORK, "app.log"), "w")
     command = [APP, str(APP_PORT)]
     if os.environ.get("WEBSTACK_GDB"):
@@ -612,6 +768,30 @@ def main():
         fake_id = int(registered[0]["id"])
         fake_token = registered[0]["token"]
 
+        # Plan 10 phase 1: registration delivers the per-saddle token to
+        # the daemon via POST /register {saddle_token, web_base_url}.
+        check(wait_for(lambda: len(fake.register_log) > 0 or False,
+                       timeout=15, what="/register delivery") is not None,
+              "registration POSTs /register to the daemon")
+        register_body = fake.register_log[0]
+        check(register_body.get("saddle_token") == fake_token,
+              "/register carries the minted per-saddle token")
+        check(register_body.get("web_base_url") == WEB,
+              "/register carries the web base URL")
+        check(fake.token == fake_token,
+              "daemon persisted the token (its asks use it)")
+        token_conn = db()
+        row_token = token_conn.execute("select token from saddles where id = ?",
+                                       (fake_id,)).fetchone()[0]
+        token_conn.close()
+        check(row_token == fake_token,
+              "per-saddle token lives on the saddle row")
+        # The register-time ask (idle-awake daemon): 204, nothing
+        # offerable yet — the saddle is asleep and wakeable.
+        check(wait_for(lambda: any(a["status"] == 204 for a in fake.asks) or False,
+                       timeout=15, what="register-time ask") is not None,
+              "the daemon's idle ask returns 204 (nothing offerable)")
+
         conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
                      " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
                      (plan_task, fake_id))
@@ -816,20 +996,18 @@ def main():
               "dispatched task completes against the fake daemon")
 
         print("== feature 6: project notes + callback ==")
-        conn = db()
-        callback_rows = conn.execute(
-            "select token from api_tokens where saddle_id = 0").fetchall()
-        conn.close()
-        check(len(callback_rows) == 1 and callback_rows[0][0].startswith("saddle-callback-"),
-              "dedicated saddle-callback token minted at startup")
-        callback_token = callback_rows[0][0]
+        # Plan 10 phase 1: the shared saddle-callback token is gone —
+        # the per-saddle token authorises the callback endpoints.
+        callback_token = fake_token
+        check(callback_token.startswith("saddle-") and "saddle-callback-" not in callback_token,
+              "note callback authorised by the per-saddle token")
 
         status, _ = saddle_api("POST", "/api/saddle/note", None,
                                {"project": "demo", "body": "x"})
         check(status == 401, "note endpoint rejects a missing token")
         status, _ = saddle_api("POST", "/api/saddle/note", "saddle-callback-not-real",
                                {"project": "demo", "body": "x"})
-        check(status == 401, "note endpoint rejects a wrong token")
+        check(status == 401, "note endpoint rejects a wrong (legacy-style) token")
 
         status, body = saddle_api("POST", "/api/saddle/note", callback_token,
                                   {"task_id": rule_task, "project": "demo",
@@ -1341,7 +1519,7 @@ def main():
               and "fake daemon finished" in (task_state(auth, rejected_task).get("summary") or ""),
               "rejected entries never fail the task")
         print("== B7: suspended state + per-subtask resume ==")
-        fake.suspend_after_start = True
+        fake.suspend_on_title = "Suspend parent"
         pushes_before = len(fake.start_payloads)
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
@@ -1412,7 +1590,7 @@ def main():
 
         print("== R3 B1: subtask status push ==")
         # A parked (suspended) parent gives delivery a stable open session.
-        fake.suspend_after_start = True
+        fake.suspend_on_title = "Push parent"
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
             "title": "Push parent",
@@ -1456,8 +1634,11 @@ def main():
               "dispatch pushes a working status event")
 
         # Best-effort retry: while the daemon 500s, events stay queued and
-        # land once it recovers.
+        # land once it recovers. The parent's resumed run re-parks (the
+        # ask-flow resume races this section otherwise: its completion
+        # would close the parked session the events are delivered to).
         fake.fail_subtask_status = True
+        fake.suspend_on_title = "Push parent"
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
             "title": "Push child two",
@@ -1497,7 +1678,33 @@ def main():
               "the busy@0 heal is logged")
 
         # Contradiction: the daemon reports idle while the web believes the
-        # saddle is running the orphaned task.
+        # saddle is running the orphaned task. Drain everything in flight
+        # first — forcing the daemon's state mid-run requeues whatever it
+        # is really executing.
+        def in_flight_count():
+            conn = db()
+            try:
+                # The plan-review fixture (task 1) is inserted 'working'
+                # on purpose and never dispatches; it is not in flight.
+                return conn.execute(
+                    "select count(*) from tasks where status in"
+                    " ('queued', 'assigned', 'working')"
+                    " and title != 'Plan review task'").fetchone()[0]
+            finally:
+                conn.close()
+
+        def in_flight_titles():
+            conn = db()
+            try:
+                return [r[0] for r in conn.execute(
+                    "select title from tasks where status in"
+                    " ('queued', 'assigned', 'working')"
+                    " and title != 'Plan review task'")]
+            finally:
+                conn.close()
+        check(wait_for(lambda: in_flight_count() == 0 or False,
+                       timeout=90, what="in-flight work settles (%s)" % in_flight_titles()) is not None,
+              "in-flight work settles before the contradiction run")
         conn = db()
         conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
                      " values (1, 'Orphan task', 'working', 2, strftime('%s','now'))")
@@ -1517,15 +1724,11 @@ def main():
 
 
         print("== R4 B1: synchronous subtask callback ==")
-        conn = db()
-        callback_rows = conn.execute(
-            "select token from api_tokens where saddle_id = 0"
-            " and token like 'saddle-callback-%'").fetchall()
-        conn.close()
-        r4_token = callback_rows[0][0] if callback_rows else callback_token
+        # Plan 10: the subtask callback is per-saddle-token authenticated.
+        r4_token = fake_token
 
         # Parent with a live parked session (suspended) hosts the callback.
-        fake.suspend_after_start = True
+        fake.suspend_on_title = "Callback parent"
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
             "title": "Callback parent",
@@ -1623,7 +1826,7 @@ def main():
         # Parent parks; its toggle then covers all descendants. Children are
         # created with an unmet 'gpu' requirement so they stay queued (and
         # untouched) until the requirement is lifted for the final-resume leg.
-        fake.suspend_after_start = True
+        fake.suspend_on_title = "Auto parent"
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
             "title": "Auto parent",
@@ -1731,44 +1934,288 @@ def main():
               "resume notice ends with the final-review instruction")
 
 
-        print("== R4 B3: dispatch-failure observability ==")
-        # A dead saddle that alone carries the required tag attracts the
-        # dispatch; the push fails and the task learns why via a bot comment.
+        print("== phase 1: ask/allocation — token auth + concurrent race ==")
+        # Plan 10 phase 1. Registration mints per-saddle tokens; the
+        # ask endpoint authenticates by them; concurrent asks race to
+        # exactly one winner through the allocation transaction.
+        status, _ = saddle_api("POST", "/api/saddle/next-task", None)
+        check(status == 401, "next-task rejects a missing token")
+        status, _ = saddle_api("POST", "/api/saddle/next-task", "saddle-bogus-token")
+        check(status == 401, "next-task rejects a wrong token")
+        status, body = saddle_api("POST", "/api/saddle/next-task", fake_token)
+        check(status == 204, "next-task answers 204 (nothing offerable — daemon sleeps)")
+
+        # Two more racers: real daemons on their own ports; fake2 reports
+        # its ssh key at /register so the key lands on the row directly.
+        fake2 = FakeSaddle()
+        fake2.ssh_public_key = "ssh-ed25519 AAAAFake2RacerKey saddle-2@fedora"
+        fake2.serve(port=free_port())
+        fake3 = FakeSaddle()
+        fake3.serve(port=free_port())
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        act(auth, saddles_state["register"], {"name": "racer-two",
+                                              "host": "127.0.0.1:%d" % fake2.port})
+        act(auth, saddles_state["register"], {"name": "racer-three",
+                                              "host": "127.0.0.1:%d" % fake3.port})
+        check(wait_for(lambda: len(fake2.register_log) > 0 and len(fake3.register_log) > 0
+                       or False, timeout=15, what="racers registered") is not None,
+              "both racers received /register")
+        check(wait_for(lambda: "AAAAFake2RacerKey" in
+                       [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                        if s["name"] == "racer-two"][0].get("ssh_public_key", "") or False,
+                       timeout=15, what="register-reported key") is not None,
+              "the ssh key reported at /register lands on the saddle row")
+
+        racer_cards = {}
+        for name, daemon in (("fake-saddle", fake), ("racer-two", fake2), ("racer-three", fake3)):
+            card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                    if s["name"] == name][0]
+            racer_cards[daemon] = {"id": int(card["id"]), "token": card["token"]}
+        # Revocation: a registered saddle's token works until the saddle
+        # is removed, then nothing authorises its calls.
+        fake4 = FakeSaddle()
+        fake4.ask_on_register = False  # a pre-flip daemon: never asks
+        fake4.serve(port=free_port())
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        act(auth, saddles_state["register"], {"name": "old-daemon",
+                                              "host": "127.0.0.1:%d" % fake4.port})
+        old_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                    if s["name"] == "old-daemon"][0]
+        status, _ = saddle_api("POST", "/api/saddle/next-task", old_card["token"])
+        check(status in (200, 204),
+              "the old daemon's fresh token authenticates next-task")
+        status, _ = saddle_api("POST", "/api/saddle/note", old_card["token"],
+                               {"project": "demo", "body": "from the old daemon"})
+        check(status == 200, "note accepts any registered saddle's token")
+        act(auth, old_card["unregister"], {})
+        status, _ = saddle_api("POST", "/api/saddle/next-task", old_card["token"])
+        check(status == 401, "removing the saddle revokes its token")
+        status, _ = saddle_api("POST", "/api/saddle/note", old_card["token"],
+                               {"project": "demo", "body": "should fail"})
+        check(status == 401, "revocation covers the note endpoint too")
+
+        # Deterministic race: settle the queue first (the auto-approve
+        # stragglers must finish — their parked resumes stay offerable
+        # otherwise and would steal the race's asks), freeze every
+        # daemon's self-ask triggers, put all three to sleep (204),
+        # then fire one task worth of concurrent asks.
+        check(wait_task_done(auth, auto_child, timeout=90) is not None
+              and wait_task_done(auth, auto_grand, timeout=90) is not None
+              and wait_task_done(auth, auto_parent, timeout=90) is not None,
+              "the auto-approve stragglers settle before the race")
+        for daemon in (fake, fake2, fake3):
+            daemon.ask_on_register = False
+            daemon.ask_on_wake = False
+            daemon.ask_on_terminal = False
+            daemon.ask_on_idle_status = False
+        time.sleep(1.5)  # let any in-flight triggered asks land
+        def queued_count():
+            conn = db()
+            try:
+                return conn.execute(
+                    "select count(*) from tasks where status = 'queued'").fetchone()[0]
+            finally:
+                conn.close()
+
+        check(wait_for(lambda: queued_count() == 0 or False,
+                       timeout=60, what="queue settles before the race") is not None,
+              "the queue settles before the race")
+
+        sleep_statuses = [daemon.ask_now()[0] for daemon in (fake, fake2, fake3)]
+        check(sleep_statuses == [204, 204, 204],
+              "all three racers sleep (204) before the task exists")
+
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Race task",
+            "priority": "2",
+        })
+        race_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                         if t["title"] == "Race task"][0])
+
+        race_results = []
+        def race_ask(daemon):
+            # A real racing daemon accepts the assignment it wins — the
+            # manual ask path does exactly that on a 200.
+            race_results.append(daemon.ask_now())
+        threads = [threading.Thread(target=race_ask, args=(daemon,))
+                   for daemon in (fake, fake2, fake3)]
+        for thread in threads:
+            thread.start()
+        for thread in threads:
+            thread.join()
+        race_statuses = sorted(r[0] for r in race_results)
+        check(race_statuses == [200, 204, 204],
+              "concurrent asks race to exactly one winner (%s)" % race_statuses)
+        winner = [r for r in race_results if r[0] == 200][0][1]
+        check(int(winner["task"]["id"]) == race_task, "the winner's payload carries the task")
+        check(int(winner["session_id"]) > 0, "the assignment carries the session id")
+        conn = db()
+        race_sessions = conn.execute(
+            "select count(*) from sessions where task_id = ?", (race_task,)).fetchone()[0]
+        conn.close()
+        check(race_sessions == 1,
+              "exactly one session row — the transaction serialised the claims (%d)"
+              % race_sessions)
+        check(wait_task_done(auth, race_task, timeout=60) is not None,
+              "the raced task completes on the winning daemon")
+        for daemon in (fake, fake2, fake3):
+            daemon.ask_on_register = True
+            daemon.ask_on_wake = True
+            daemon.ask_on_terminal = True
+            daemon.ask_on_idle_status = True
+        # Re-establish sleeping explicitly: the winner's post-race
+        # re-ask timing is not deterministic, and the affinity section
+        # below needs every racer provably asleep. Any stray offerable
+        # drains into these asks first.
+        def all_asleep():
+            return all(daemon.ask_now()[0] == 204 for daemon in (fake, fake2, fake3))
+        check(wait_for(lambda: all_asleep() or False, timeout=30,
+                       what="racers re-sleep after the race") is not None,
+              "all racers re-sleep after the race")
+
+        print("== phase 1: workspace affinity pins parked tasks ==")
+        # A suspended parent parked on racer-two may be resumed ONLY by
+        # racer-two: a tag-capable non-affine saddle asking gets 204, and
+        # the wake targets the affine saddle. Only racer-two carries the
+        # tag at dispatch time (the race's winner is nondeterministic, so
+        # the park must not depend on which racers are free); racer-three
+        # gains it afterwards to prove the pinning is by workspace, not
+        # tags.
         saddles_state = slot_public(auth, "/saddles", "saddles")
-        act(auth, saddles_state["register"], {"name": "dead-saddle",
-                                              "host": "127.0.0.1:1"})
-        dead_row = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
-                    if s["name"] == "dead-saddle"][0]
-        act(auth, dead_row["attach_capability"], {"capability_id": str(gpu_option)})
+        act(auth, saddles_state["create_capability"], {"name": "affinity-tag"})
+        affinity_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
+                            if c["name"] == "affinity-tag"][0])
+        racer2_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                       if s["name"] == "racer-two"][0]
+        racer3_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                       if s["name"] == "racer-three"][0]
+        act(auth, racer2_card["attach_capability"], {"capability_id": str(affinity_cap)})
+
+        fake2.suspend_on_title = "Affinity parent"
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
-            "title": "Dead saddle task",
-            "capability_id": [str(gpu_option)],
+            "title": "Affinity parent",
+            "priority": "2",
+            "capability_id": [str(affinity_cap)],
         })
-        dead_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
-                         if t["title"] == "Dead saddle task"][0])
-        check(wait_for(lambda: any("assignment push failed" in c["body"]
-                                   for c in task_state(auth, dead_task).get("comments", []))
-                       or False, timeout=60, what="push-failure comment") is not None,
-              "failed push writes the reason on the task")
-        check(task_state(auth, dead_task)["status"] == "queued",
-              "push-failed task returns to the queue")
-        dead_rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
-                     if s["name"] == "dead-saddle"]
-        check(dead_rows[0]["status"] == "offline",
-              "the failing saddle goes offline")
-        act(auth, dead_rows[0]["unregister"], {})
+        affinity_parent = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                               if t["title"] == "Affinity parent"][0])
+        check(wait_for(lambda: task_state(auth, affinity_parent).get("status") == "suspended"
+                       or False, timeout=60, what="affinity parent parks") is not None,
+              "affinity parent parks (suspended) on the tag-carrying racer")
         conn = db()
-        conn.execute("delete from task_capabilities where capability_id = ?"
-                     " and task_id = ?", (gpu_option, dead_task))
+        affinity_session = conn.execute(
+            "select saddle_id from sessions where task_id = ? and ended_at is null",
+            (affinity_parent,)).fetchone()[0]
+        conn.close()
+        check(affinity_session == int(racer2_card["id"]),
+              "the parked workspace sits on racer-two (the only tag-carrier)")
+
+        # Now racer-three carries the tag too — the pinning is by
+        # workspace, not tags.
+
+        # A terminal subtask makes the parked task resume-offerable —
+        # affine to racer-two only. The child's completion is stamped
+        # slightly in the future: the poller may not have observed (and
+        # stamped) the parent's suspension yet, and the resume trigger
+        # needs completed_at > suspended_at. Freeze racer-two's ask
+        # triggers so the manual ask below is the only contender (its
+        # park-time idle-status ask would otherwise race this claim).
+        conn = db()
+        conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
+                     " summary, completed_at, created_at) values (1, 'Affinity child', 'done',"
+                     " 5, ?, 'affinity child finished', strftime('%s','now','+15 seconds'),"
+                     " strftime('%s','now'))", (affinity_parent,))
+        conn.commit()
+        conn.close()
+        fake2.ask_on_wake = False
+        fake2.ask_on_idle_status = False
+        time.sleep(1.2)  # any in-flight park-time trigger ask lands (204)
+        act(auth, racer3_card["attach_capability"], {"capability_id": str(affinity_cap)})
+        status, _ = fake3.ask_now()
+        check(status == 204,
+              "a tag-capable non-affine saddle is refused the parked task (204)")
+
+        wakes_before_2 = len(fake2.wake_log)
+        check(wait_for(lambda: len(fake2.wake_log) > wakes_before_2 or False,
+                       timeout=30, what="affine wake") is not None,
+              "the dispatcher's wake targets the affine saddle")
+        status, resume_payload = fake2.ask_now()
+        fake2.ask_on_wake = True       # restored whatever the claim returned
+        fake2.ask_on_idle_status = True
+        check(status == 200 and int(resume_payload["task"]["id"]) == affinity_parent,
+              "the affine saddle's ask claims the parked task")
+        check("Affinity child" in (resume_payload.get("resume_notice") or "")
+              and "affinity child finished" in (resume_payload.get("resume_notice") or ""),
+              "the resume assignment carries the subtask report notice")
+        check(wait_task_done(auth, affinity_parent, timeout=60) is not None,
+              "the resumed parent completes")
+
+        print("== phase 1: lost wake -> offline + re-wake of the next capable ==")
+        # racer-three sleeps through wakes (a daemon that died after its
+        # last 204): the guard window (SADDLE_LOST_WAKE_S=2) passes with
+        # no ask, the saddle goes offline, and the work moves to the
+        # next capable sleeping saddle.
+        fake3.ask_on_wake = False
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        act(auth, saddles_state["create_capability"], {"name": "lostwake"})
+        lostwake_cap = int([c["id"] for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
+                            if c["name"] == "lostwake"][0])
+        racer3_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                       if s["name"] == "racer-three"][0]
+        act(auth, racer3_card["attach_capability"], {"capability_id": str(lostwake_cap)})
+        # Racer-three must be asleep to be woken: park it with a manual
+        # 204 ask (nothing offerable for it — the task below is not
+        # created yet).
+        status, _ = fake3.ask_now()
+        check(status == 204, "racer-three sleeps before the lost-wake run")
+
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Lost wake task",
+            "priority": "2",
+            "capability_id": [str(lostwake_cap)],
+        })
+        lost_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                         if t["title"] == "Lost wake task"][0])
+        check(wait_for(lambda: len(fake3.wake_log) > 0 or False,
+                       timeout=30, what="wake sent to racer-three") is not None,
+              "the only capable sleeping saddle is woken")
+        check(wait_for(lambda: [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                                if s["name"] == "racer-three"][0]["status"] == "offline"
+                       or False, timeout=30, what="lost-wake -> offline") is not None,
+              "no ask after the wake: the saddle goes offline (lost-wake guard)")
+        check(task_state(auth, lost_task)["status"] == "queued",
+              "the task is untouched while its wake was pending")
+
+        # Re-wake the next capable: the healthy saddle gains the tag and
+        # picks the work up via its own wake -> ask -> allocate roundtrip.
+        fake_card = saddle_card("fake-saddle")
+        act(auth, fake_card["attach_capability"], {"capability_id": str(lostwake_cap)})
+        wakes_before = len(fake.wake_log)
+        check(wait_for(lambda: len(fake.wake_log) > wakes_before or False,
+                       timeout=30, what="re-wake of the next capable") is not None,
+              "the next capable sleeping saddle is woken for the task")
+        check(wait_task_done(auth, lost_task, timeout=60) is not None,
+              "wake -> ask -> allocate: the task completes on the healthy saddle")
+        check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == lost_task
+                                   for p in fake.start_payloads) or False,
+                       timeout=10, what="lost task assignment captured") is not None,
+              "the re-woken saddle's ask produced the assignment")
+        fake3.ask_on_wake = True
+
+        # 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
+        # already offline (the lost-wake guard); racer-two joins it.
+        conn = db()
+        conn.execute("update saddles set status = 'offline' where name = 'racer-two'")
         conn.commit()
         conn.close()
-        check(wait_task_done(auth, dead_task, timeout=60) is not None,
-              "the task recovers on the healthy saddle")
 
         # Tasks caught working on a saddle that went offline requeue with a
         # bot comment each tick.
-        fake.suspend_after_start = False
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
             "title": "Offline saddle task",
@@ -1916,7 +2363,7 @@ def main():
 
 
         print("== R6 F2: refresh must not corrupt parked bookkeeping ==")
-        fake.suspend_after_start = True
+        fake.suspend_on_title = "Parked refresh parent"
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
             "title": "Parked refresh parent",
@@ -1945,7 +2392,6 @@ def main():
 
 
         print("== R6 F3: raw-log probe retries instead of deciding once ==")
-        fake.suspend_after_start = False
         fake.fail_log_count = 1  # the seed-time probe fails exactly once
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
             "project_id": "1",
@@ -2055,95 +2501,54 @@ def main():
               "queued work dispatches once the fence clears")
 
 
-        print("== R7 B2: 409-aware dispatch ==")
-        # Zombie: the daemon (per the fake) still runs an old completed task;
-        # one 409, then abort + retry recovers without churning sessions.
-        fake.state = "idle"
-        fake.zombie_409 = 1
-        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
-            "project_id": "1",
-            "title": "Zombie recovery",
-            "priority": "2",
-        })
-        zombie_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
-                           if t["title"] == "Zombie recovery"][0])
-        check(wait_task_done(auth, zombie_task, timeout=90) is not None,
-              "zombie conflict recovers and the task completes")
-        detail = task_state(auth, zombie_task)
-        check(any("recovered zombie session" in c["body"] for c in detail.get("comments", [])),
-              "the zombie recovery is narrated")
-        conn = db()
-        zombie_sessions = conn.execute(
-            "select count(*) from sessions where task_id = ?", (zombie_task,)).fetchone()[0]
-        conn.close()
-        check(zombie_sessions == 1,
-              "no session churn: one row across the failed and retried push (%d)"
-              % zombie_sessions)
+        print("== phase 1: rollback resume path (pre-flip daemon) ==")
+        # Phase 1 keeps the /answer + /start resume machinery for daemons
+        # that never ask (the rollback route; the 409-aware /start push
+        # machinery itself is rollback-only now that dispatch flows
+        # through asks). A pre-flip daemon's parked suspended parent is
+        # resumed by a /start push carrying the subtask report.
+        fake4.ask_on_register = False
+        fake4.ask_on_wake = False
+        fake4.ask_on_terminal = False
+        fake4.ask_on_idle_status = False
+        fake4.serve_register = False  # a pre-flip daemon has no /register
+        registers_before = len(fake4.register_log)
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        act(auth, saddles_state["register"], {"name": "preflip-daemon",
+                                              "host": "127.0.0.1:%d" % fake4.port})
+        preflip_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                        if s["name"] == "preflip-daemon"][0]
+        preflip_id = int(preflip_card["id"])
+        check(len(fake4.register_log) == registers_before,
+              "the pre-flip daemon 404s /register (registration tolerated)")
 
-        # Live conflict: the daemon claims to run a task the web still sees
-        # live — fence + alert, never abort live work.
-        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
-            "project_id": "1",
-            "title": "Conflict live task",
-            "priority": "2",
-        })
-        conflict_live = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
-                             if t["title"] == "Conflict live task"][0])
-        check(wait_for(lambda: task_state(auth, conflict_live).get("status") == "working"
-                       or False, timeout=60, what="conflict task working") is not None,
-              "live task reaches working")
-        # Hold the daemon mid-working and desynchronise the web's view:
-        # double-booking means the web believes the saddle free while the
-        # daemon still claims the live task.
-        fake.hold_state = True
-        fake.conflict_running = conflict_live
         conn = db()
-        conn.execute("update saddles set status = 'idle', current_task_id = 0,"
-                     " allocated_at = null where name = 'fake-saddle'")
+        conn.execute("insert into tasks (project_id, title, status, priority, suspended_at,"
+                     " created_at) values (1, 'Preflip parked parent', 'suspended', 2,"
+                     " strftime('%s','now','-60 seconds'), strftime('%s','now','-120 seconds'))")
+        preflip_parent = conn.execute("select max(id) from tasks").fetchone()[0]
+        conn.execute("insert into sessions (task_id, saddle_id, started_at)"
+                     " values (?, ?, strftime('%s','now','-90 seconds'))",
+                     (preflip_parent, preflip_id))
+        conn.execute("insert into tasks (project_id, title, status, priority, parent_task_id,"
+                     " summary, completed_at, created_at) values (1, 'Preflip child', 'done',"
+                     " 5, ?, 'child of the pre-flip era',"
+                     " strftime('%s','now','-30 seconds'),"
+                     " strftime('%s','now','-100 seconds'))", (preflip_parent,))
         conn.commit()
         conn.close()
-        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
-            "project_id": "1",
-            "title": "Conflict newcomer",
-            "priority": "3",
-        })
-        conflict_new = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
-                            if t["title"] == "Conflict newcomer"][0])
-        check(wait_for(lambda: saddle_card("fake-saddle")["status"] == "recovering" or False,
-                       timeout=30, what="double-booked saddle fenced") is not None,
-              "double-booked saddle is fenced")
-        check(any("double-booked" in c["body"]
-                  for c in task_state(auth, conflict_live).get("comments", [])),
-              "the live task gets the alert comment")
-        check(any("dispatch refused" in c["body"]
-                  for c in task_state(auth, conflict_new).get("comments", [])),
-              "the refused task gets its comment")
-        conn = db()
-        new_sessions = conn.execute(
-            "select outcome from sessions where task_id = ?", (conflict_new,)).fetchall()
-        conn.close()
-        check(len(new_sessions) == 1 and new_sessions[0][0] == "push_failed",
-              "failed push marks the session push_failed (no churn)")
-        check(conflict_live not in fake.abort_log,
-              "the live task is never aborted")
-        fake.conflict_running = 0
-        fake.hold_state = False
-        fake.state = "idle"
-        act(auth, task_state(auth, conflict_live)["complete"],
-            {"summary": "finished before the conflict"})
-        check(wait_for(lambda: task_state(auth, conflict_live).get("status") == "done"
-                       or False, timeout=30, what="live task completed") is not None,
-              "the live task completes without an abort")
-        check(wait_for(lambda: saddle_card("fake-saddle")["status"] == "idle" or False,
-                       timeout=40, what="fence clears after conflict") is not None,
-              "fence clears once the daemon goes idle")
-        conn = db()
-        after_clear = conn.execute(
-            "select count(*) from sessions where task_id = ?", (conflict_new,)).fetchone()[0]
-        conn.close()
-        check(after_clear == 1, "still one session row for the refused task")
-        check(wait_task_done(auth, conflict_new, timeout=90) is not None,
-              "the refused task dispatches after the fence clears")
+
+        pushes_before = len(fake4.start_payloads)
+        check(wait_for(lambda: len(fake4.start_payloads) > pushes_before or False,
+                       timeout=45, what="pre-flip /start resume push") is not None,
+              "the pre-flip daemon's parked parent resumes via POST /start")
+        preflip_push = [p for p in fake4.start_payloads
+                        if int(p.get("task", {}).get("id") or 0) == preflip_parent][-1]
+        check("Preflip child" in (preflip_push.get("resume_notice") or "")
+              and "child of the pre-flip era" in (preflip_push.get("resume_notice") or ""),
+              "the rollback /start resume carries the subtask report notice")
+        check(wait_task_done(auth, preflip_parent, timeout=60) is not None,
+              "the pre-flip parent completes through the rollback path")
 
 
         print("== R7 B3: wedged daemon, health panel, raw log, vanishing daemon ==")
@@ -2534,6 +2939,58 @@ def main():
         check(row.get("unreachable") is True,
               "queue row shows the unreachable state in the window")
 
+        print("== phase 1: startup migrates the per-saddle token ==")
+        # Saddles registered before the flip hold legacy api_tokens rows.
+        # On startup every saddle row gets its canonical token: the
+        # legacy value it already holds (daemons keep their credential)
+        # or a freshly minted one.
+        conn = db()
+        conn.execute("insert into api_tokens (saddle_id, token, created_at)"
+                     " values (?, 'saddle-legacy-copied-token', strftime('%s','now'))",
+                     (int(racer_cards[fake2]["id"]),))
+        conn.execute("update saddles set token = null where name = 'racer-two'")
+        conn.execute("insert into saddles (name, host, status, current_task_id)"
+                     " values ('tokenless-orphan', '127.0.0.1:1', 'offline', 0)")
+        conn.commit()
+        conn.close()
+
+        app.terminate()
+        app.wait(timeout=10)
+        app = start_app()
+        auth = login("admin", "adminpass123")
+
+        def saddle_row(name):
+            conn = db()
+            try:
+                return conn.execute("select id, token from saddles where name = ?",
+                                    (name,)).fetchone()
+            finally:
+                conn.close()
+
+        check(wait_for(lambda: saddle_row("tokenless-orphan") is not None
+                       and (saddle_row("tokenless-orphan")[1] or "").startswith("saddle-")
+                       or False, timeout=20, what="tokenless row minted") is not None,
+              "startup mints a token for a saddle row without one")
+        check(wait_for(lambda: saddle_row("racer-two") is not None
+                       and saddle_row("racer-two")[1] == "saddle-legacy-copied-token"
+                       or False, timeout=20, what="legacy token copied") is not None,
+              "startup copies the legacy api_tokens value onto the row")
+        fake2.token = "saddle-legacy-copied-token"  # the daemon asks with it
+        status, _ = saddle_api("POST", "/api/saddle/next-task", "saddle-legacy-copied-token")
+        check(status in (200, 204),
+              "the legacy token still authenticates (copied, not replaced)")
+        conn = db()
+        conn.execute("delete from saddles where name = 'tokenless-orphan'")
+        conn.commit()
+        conn.close()
+
+        # The restart wiped the in-memory ask bookkeeping; the fleet's
+        # daemons re-establish sleeping with a fresh ask each (the real
+        # daemons' idle-awake ask / 15-minute safety self-ask).
+        for daemon in (fake2, fake3):
+            daemon.ask_now()
+        check(True, "fleet re-asks after the restart (sleeping re-established)")
+
 
     finally:
         # Exit-code diagnostic: distinguishes a crashed app (negative