소스 검색

r23: sessions as truth — the saddle-row work cache dies, IDLE/BUSY derive from open sessions

Item 23's normal form: sessions own 'who runs what'. The saddles table
loses current_task_id and allocated_at (fresh-DB schema — M0001 edited
in place; the entity mapping follows); the row's status column reduces
to the OFFLINE probe state ('idle' default / 'offline' stamp), cleared
by a successful probe or a proof-of-life ask. Busy/idle DERIVE: busy
iff an open session on an assigned/working task exists — the
task-status join makes the derivation drift-tolerant (a stale open row
on a task that moved on cannot wedge a saddle busy forever), and with
D4's park-closes the invariant open⇔running holds by construction.

The ONE truth accessor: SaddleService.current_work_view grows
session_id + started_at and keys every surface — the dashboard and
saddles cards (status/current task/allocation stamp via
derived_status), the dispatcher's busy pass (comment delivery keys the
work's task; the staleness probe keys its session id), list_busy/
list_idle_saddles (the id batch rides the decimal-string OR-chain —
the Invercargill boxed-int64 corruption trap), and unregister's
derived release.

Guarded UPDATEs die where the duplication died: park_if_current (the
task's parked status IS the pause — every park closed its session at
the raise), release_task's idle-free (the close IS the free), the
claim's row writes (the open session IS the busy record), and
refresh_saddle_status's reconcile write (nothing to reconcile — the
daemon's working state needs no row). The claim KEEPS its atomic
guard, re-expressed: BEGIN IMMEDIATE still serialises two asks, the
saddle check becomes 'no open session on a live-running task' (the
parked-resume special case dies with the cache — its session closed at
its park) plus an offline-row refusal. The offline stamp survives as
the ONE guarded saddle write, its seen-guard expressed against
sessions: NOT EXISTS a running session newer than the one observed at
probe entry — an ask claim that landed mid-probe is proof of life and
is never stomped. mark_saddle_unreachable releases its DERIVED held
task (guarded flip; a mid-flight park keeps its verdict) before the
stamp; a 'working' state push only advances assigned/legacy-queued
tasks, so no push resurrects a parked round.

The SaddlePollState verdict (R3+R5 said delete-likely): the
delivered-answer/comment ledger STAYS — is_delivered still gates
ensure_block's re-raise dedupe through the answered-but-undelivered
window and is_comment_delivered gates the dispatcher's comment pass;
the class doc records the audit. SaddlePollState itself was never only
that ledger (ask/wake/push-clock/probe health all load-bearing).

Tests: lifecycle pins the schema (no cache columns) and reworks the
unregister routes around the derived hold — reachable-host abort-
delivered release, and a drift row (working task, NO session) that
unregister provably cannot release; pages closes its yeehaw fixture
round (a fixture open session is real busy work under the derived
view). 860 checks (was 855). Full suite green: parallel x3 +
sequential.
clanker 1 개월 전
부모
커밋
cb269d0d30

+ 1 - 1
src/Api/SaddleApi.vala

@@ -825,7 +825,7 @@ namespace ClankerSaddle.Api {
                         body.get_array_member("entries"));
                     yield saddles.apply_entry_events(((!)session).task_id, stored, tasks);
                 }
-                yield saddles.apply_result_state((!)saddle, (!)session, state, block,
+                yield saddles.apply_result_state((!)session, state, block,
                     summary, tasks);
                 return SaddleWire.json_ok(response);
             } catch (Error e) {

+ 9 - 6
src/Data/Entities.vala

@@ -188,16 +188,21 @@ 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}. The
      * per-saddle token ({@link token}, plan 10) authorises every
-     * daemon→web call; removing the row revokes it.
+     * daemon→web call; removing the row revokes it. Plan 16 item 23:
+     * sessions are the source of truth for "who runs what" — busy/idle
+     * are DERIVED (busy iff an open session on an assigned/working task
+     * exists; see {@link Services.SaddleService.current_work_view}) and
+     * the row's `status` column is only the OFFLINE probe state
+     * ('offline' vs 'idle'): the poller's unreachable stamp, cleared by
+     * a successful probe or a proof-of-life ask. The former
+     * `current_task_id`/`allocated_at` cache columns are gone.
      */
     public class Saddle : Object {
 
         public int64 id { get; set; }
         public string name { get; set; default = ""; }
         public string host { get; set; default = ""; }
-        public string status { get; set; default = "offline"; }
-        public int64 current_task_id { get; set; default = 0; }
-        public DateTime? allocated_at { get; set; default = null; }
+        public string status { get; set; default = "idle"; }
         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; }
@@ -209,8 +214,6 @@ namespace ClankerSaddle.Data {
                 .column<string>("name", o => o.name, (o, v) => o.name = v)
                 .column<string>("host", o => o.host, (o, v) => o.host = v)
                 .column<string>("status", o => o.status, (o, v) => o.status = v)
-                .column<int64?>("current_task_id", o => o.current_task_id, (o, v) => o.current_task_id = v)
-                .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)

+ 7 - 4
src/Data/Migrations/M0001_Initial.vala

@@ -232,6 +232,12 @@ namespace ClankerSaddle.Data.Migrations {
             // written on ingest.
 
             b.create_table("saddles", (t) => {
+                // Plan 16 item 23: the saddle row carries NO work
+                // cache — sessions are the source of truth for "who
+                // runs what" (busy/idle derived from open sessions).
+                // `status` is only the OFFLINE probe state: 'idle'
+                // (reachable/unknown) or 'offline' (the poller's
+                // unreachable stamp).
                 t.column<int64?>("id")
                     .auto_increment()
                     .primary_key();
@@ -240,10 +246,7 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("host")
                     .not_null();
                 t.column<string>("status")
-                    .default_value<string>("offline");
-                t.column<int64?>("current_task_id")
-                    .references("tasks", "id");
-                t.column<DateTime?>("allocated_at");
+                    .default_value<string>("idle");
                 t.column<string>("git_name");
                 t.column<string>("git_email");
                 t.column<string>("ssh_public_key");

+ 34 - 8
src/Services/AllocationService.vala

@@ -30,8 +30,9 @@ namespace ClankerSaddle.Services {
     /**
      * 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.
+     * to the asking saddle and its open session row IS the busy record
+     * (plan 16 item 23: no saddle-row write remains). The assignment
+     * payload is assembled from it afterwards.
      */
     public class AllocationClaim : Object {
         public int64 task_id { get; set; }
@@ -217,11 +218,37 @@ namespace ClankerSaddle.Services {
                 var row = db.query<Saddle>()
                     .where(expr("id == $0", elem<int64?>(saddle.id)))
                     .first();
-                if (row == null || (((!)row).status != SADDLE_IDLE
-                        && (!(candidate.parked && ((!)row).current_task_id == candidate.task.id)))) {
+                if (row == null || ((!)row).status == SADDLE_OFFLINE) {
+                    // An offline row never wins a claim (the probe
+                    // stamp outranks the candidate list); everything
+                    // else about availability is DERIVED from the
+                    // sessions (plan 16 item 23): the saddle is free
+                    // iff it holds no running session — an open
+                    // session on an assigned/working task. The
+                    // transaction makes the check atomic against
+                    // every other ask: two asks racing for one saddle
+                    // resolve to exactly one winner, and the parked
+                    // resume needs no special case anymore (its own
+                    // session closed 'parked' at the park — D4).
                     connection.execute("ROLLBACK");
                     return null;
                 }
+                foreach (var open in db.query<Session>()
+                        .where(expr("saddle_id == $0", elem<int64?>(saddle.id)))
+                        .order_by(expr("id"))
+                        .materialise()) {
+                    if (open.ended_at != null) {
+                        continue;
+                    }
+                    var holder = db.query<TaskEntity>()
+                        .where(expr("id == $0", elem<int64?>(open.task_id)))
+                        .first();
+                    if (holder != null && (((!)holder).status == STATUS_ASSIGNED
+                            || ((!)holder).status == STATUS_WORKING)) {
+                        connection.execute("ROLLBACK");
+                        return null;
+                    }
+                }
 
                 int64 session_id = 0;
                 if (candidate.parked) {
@@ -276,10 +303,9 @@ namespace ClankerSaddle.Services {
                 ((!)task).started_at = new DateTime.now_local();
                 db.update<TaskEntity>((!)task);
 
-                ((!)row).status = SADDLE_BUSY;
-                ((!)row).current_task_id = candidate.task.id;
-                ((!)row).allocated_at = new DateTime.now_local();
-                db.update<Saddle>((!)row);
+                // No saddle-row write remains (plan 16 item 23): the
+                // open session this claim just created IS the saddle's
+                // busy record — every reader derives it.
 
                 connection.execute("COMMIT");
                 return new AllocationClaim() {

+ 17 - 11
src/Services/Dispatcher.vala

@@ -131,26 +131,28 @@ namespace ClankerSaddle.Services {
             yield push.flush();
 
             foreach (var saddle in yield saddles.list_busy_saddles()) {
+                // The derived busy pass (plan 16 item 23): each busy
+                // saddle's running work IS its open session on an
+                // assigned/working task — the ONE truth accessor
+                // resolves it (a saddle whose work closed between the
+                // two reads reads idle next tick; nothing to do).
+                var work = yield saddles.current_work_view(saddle);
+                if (work == null) {
+                    continue;
+                }
                 // Human comments ride POST /task-update to the RUNNING
                 // daemon (best effort — a transport failure stays
                 // undelivered for the next tick; the staleness probe
                 // owns the daemon's fate, not a strike counter).
                 try {
-                    yield saddles.deliver_pending_comments(saddle.id, saddle.current_task_id);
+                    yield saddles.deliver_pending_comments(saddle.id, ((!)work).task_id);
                 } catch (Error e) {
                     warning(@"[ClankerSaddle] Comment delivery to saddle $(saddle.id) failed: $(e.message)");
                 }
 
-                if (saddle.current_task_id == 0) {
-                    continue;
-                }
-                var session = yield tasks.open_session_for_task(saddle.current_task_id);
-                if (session == null) {
-                    continue;
-                }
-                if (poll_state.take_stale_probe(((!)session).id,
+                if (poll_state.take_stale_probe(((!)work).session_id,
                         SaddleService.stale_after_seconds() * 1000)) {
-                    yield saddles.probe_stale_saddle(saddle, tasks);
+                    yield saddles.probe_stale_saddle(saddle, tasks, ((!)work).task_id);
                 }
             }
         }
@@ -202,7 +204,11 @@ namespace ClankerSaddle.Services {
                 if (offerable.parked) {
                     var affine = offerable.affine_saddle_id > 0
                         ? yield saddles.get_saddle(offerable.affine_saddle_id) : null;
-                    if (affine != null && ((!)affine).status == SADDLE_IDLE
+                    // The affine target must be DERIVED free (plan 16
+                    // item 23: no running session — status says only
+                    // offline or not) and ask-capable.
+                    if (affine != null && ((!)affine).status != SADDLE_OFFLINE
+                            && (yield saddles.current_work_view((!)affine)) == null
                             && poll_state.has_ever_asked(((!)affine).id)
                             && !poll_state.wake_pending(((!)affine).id, lost_wake_guard_ms())) {
                         target = (!)affine;

+ 3 - 2
src/Services/LifecycleService.vala

@@ -56,10 +56,11 @@ namespace ClankerSaddle.Services {
 
         /**
          * The manual requeue-for-resume (a task stuck working or
-         * parked on a saddle that went offline): a live session is
+         * parked on a saddle that went offline): a RUNNING session is
          * aborted ("stopped") and its saddle marked unreachable — the
          * unreachable bookkeeping requeues the held task with its
-         * notice comment; without a session the task simply requeues.
+         * notice comment; a parked round (its session closed 'parked',
+         * D4) has nothing live to abort — the task simply requeues.
          * The next dispatch restores the daemon-side workspace.
          */
         public async void requeue_for_resume(int64 task_id) throws Error {

+ 10 - 0
src/Services/SaddlePollState.vala

@@ -15,6 +15,16 @@ namespace ClankerSaddle.Services {
      * (the staleness probe's timer — one exceptional probe per silent
      * episode, reset by the next push).
      *
+     * Plan 16 item 23 verdict: the delivered-answer/comment ledger
+     * STAYS — it is not dead bookkeeping. `is_delivered` still gates
+     * ensure_block's re-raise dedupe (an answered-but-undelivered block
+     * still represents its prompt through the resume window) and
+     * `is_comment_delivered` gates the dispatcher's comment pass
+     * (without it every tick would re-POST every user comment to the
+     * running daemon). The saddle-row cache columns it was checked
+     * against are gone; this ledger is not a row cache — it is the
+     * delivery-once record for a channel with no other memory.
+     *
      * The plan-13 entry-ingestion dedup deliberately does NOT live
      * here: it is daemon-global and persistent (the
      * `saddle_entry_dedup` table), so it survives restarts and session

+ 258 - 188
src/Services/SaddleService.vala

@@ -61,6 +61,10 @@ namespace ClankerSaddle.Services {
         /** Set when the current task is a goal agent's run: the card links the goal. */
         public int64 goal_id { get; set; }
         public string goal_title { get; set; default = ""; }
+        /** The running session the derivation keyed on (plan 16 item 23). */
+        public int64 session_id { get; set; }
+        /** When that session started — the "allocated" display stamp. */
+        public DateTime? started_at { get; set; default = null; }
     }
 
     /**
@@ -90,13 +94,16 @@ namespace ClankerSaddle.Services {
 
     /**
      * Saddle registry and control-plane client (plan 10 phase 2): registers
-     * containers, finds idle capacity for the dispatcher's wake
-     * computation, wakes sleeping daemons, and ingests the telemetry the
-     * daemons PUSH — raw PTY chunks (`POST /api/saddle/chunks`) and round
-     * results with state transitions (`POST /api/saddle/results`) — reusing
-     * the storage logic the deleted `/output` + `/log` pollers ran. GET
-     * /status against a daemon is exceptional only: the staleness probe
-     * for a silent runner and the manual refresh button. Block answers are
+     * containers, derives idle capacity for the dispatcher's wake
+     * computation (plan 16 item 23: busy iff an open session on an
+     * assigned/working task exists — {@link current_work_view} is the ONE
+     * truth accessor; the saddle row keeps only the OFFLINE probe state),
+     * wakes sleeping daemons, and ingests the telemetry the daemons PUSH —
+     * raw PTY chunks (`POST /api/saddle/chunks`) and round results with
+     * state transitions (`POST /api/saddle/results`) — reusing the storage
+     * logic the deleted `/output` + `/log` pollers ran. GET /status
+     * against a daemon is exceptional only: the staleness probe for a
+     * silent runner and the manual refresh button. Block answers are
      * handed out exactly once (delivered ids are remembered in memory; a
      * restart re-delivers and the daemon deduplicates).
      */
@@ -233,20 +240,22 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Removes a saddle registration; the task it was running
-         * releases through the ONE path — back to the queue, its open
-         * sessions closed stopped (no orphan open session outlives the
-         * row). Revocation: the row dies here, and its per-saddle
-         * token with it — nothing authorises `/api/saddle/*` for it
-         * afterwards.
+         * Removes a saddle registration; the task it was running (the
+         * DERIVED running work — plan 16 item 23: sessions are the
+         * truth) releases through the ONE path — back to the queue,
+         * its open sessions closed stopped (no orphan open session
+         * outlives the row). Revocation: the row dies here, and its
+         * per-saddle token with it — 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) {
                 return;
             }
-            if (((!)saddle).current_task_id > 0) {
-                yield task_service.release_task(((!)saddle).current_task_id, OUTCOME_STOPPED, true);
+            var work = yield current_work_view((!)saddle);
+            if (work != null) {
+                yield task_service.release_task(((!)work).task_id, OUTCOME_STOPPED, true);
             }
             yield db.delete_async<Saddle>((!)saddle);
             yield pulse.bump(0, "saddle removed");
@@ -267,20 +276,30 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * The saddle's current work resolved for display — the ONE
-         * lookup the dashboard's saddle cards and the saddles page's
-         * rows share: the current task's title, and the goal it
-         * stands for when it is a goal agent's internal run (runs are
-         * internal — never a task destination at a glance). Null when
-         * the saddle holds no task.
+         * The saddle's current work resolved from the SESSION records —
+         * the ONE truth accessor every surface reads (plan 16 item 23:
+         * sessions own "who runs what"; the saddle row's cache columns
+         * are gone). The running session is the newest open session on
+         * the saddle whose task is still assigned/working — busy iff it
+         * exists, idle otherwise; a parked task's session closed
+         * 'parked' at its park (D4), so parks read as free exactly
+         * like the daemon's file-log brackets say. The task join also
+         * makes the derivation drift-tolerant: a stale open row on a
+         * task that has since moved on cannot wedge a saddle busy.
+         * Null when the saddle holds no running work.
          */
         public async CurrentWork? current_work_view(Saddle saddle) throws Error {
-            if (saddle.current_task_id <= 0) {
+            var session = yield running_session_on_saddle(saddle.id);
+            if (session == null) {
                 return null;
             }
-            var work = new CurrentWork() { task_id = saddle.current_task_id };
+            var work = new CurrentWork() {
+                task_id = ((!)session).task_id,
+                session_id = ((!)session).id,
+                started_at = ((!)session).started_at
+            };
             var task = yield db.query<TaskEntity>()
-                .where(expr("id == $0", elem<int64?>(saddle.current_task_id)))
+                .where(expr("id == $0", elem<int64?>(((!)session).task_id)))
                 .first_async();
             if (task == null) {
                 return work;
@@ -299,13 +318,102 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Lists saddles with status 'busy' (the dispatcher's poll
-         * candidates).
+         * The derived busy/idle/offline status label every saddle view
+         * shares — 'busy' iff {@link current_work_view} holds running
+         * work, else the row's stored OFFLINE probe state ('offline' or
+         * 'idle').
+         */
+        public static string derived_status(Saddle saddle, CurrentWork? work) {
+            if (work != null) {
+                return SADDLE_BUSY;
+            }
+            return saddle.status == SADDLE_OFFLINE ? SADDLE_OFFLINE : SADDLE_IDLE;
+        }
+
+        /**
+         * The saddle's RUNNING session — the newest open session whose
+         * task is assigned/working (the null check runs in SQL: the
+         * expression layer cannot compare columns against null). At
+         * most one exists per saddle by construction: every park closes
+         * its row (D4) and the claim's guard refuses a saddle that
+         * already holds one.
+         */
+        private async ClankerSaddle.Data.Session? running_session_on_saddle(int64 saddle_id)
+                 throws Error {
+            var command = connection.create_command(
+                "SELECT s.id AS session_id, s.task_id AS task_id, s.started_at AS started"
+                + " FROM sessions s JOIN tasks t ON t.id = s.task_id"
+                + " WHERE s.saddle_id = :saddle AND s.ended_at IS NULL"
+                + " AND t.status IN ('assigned', 'working')"
+                + " ORDER BY s.id DESC LIMIT 1");
+            command.with_parameter<Invercargill.Element>("saddle",
+                elem<int64?>(saddle_id));
+            ClankerSaddle.Data.Session? found = null;
+            foreach (var row in yield command.execute_query_async()) {
+                int64? session_id = 0;
+                int64? task_id = 0;
+                ((!)row.get("session_id")).try_get_as<int64?>(out session_id);
+                ((!)row.get("task_id")).try_get_as<int64?>(out task_id);
+                found = new ClankerSaddle.Data.Session() {
+                    id = session_id ?? 0,
+                    task_id = task_id ?? 0,
+                    saddle_id = saddle_id,
+                    started_at = raw_date_time_of(row.get("started"))
+                };
+            }
+            return found;
+        }
+
+        /** A raw query column back as a DateTime (null when null/unreadable). */
+        private static DateTime? raw_date_time_of(Invercargill.Element? element) {
+            if (element == null) {
+                return null;
+            }
+            DateTime value;
+            return ((!)element).try_get_as<DateTime>(out value) ? value : null;
+        }
+
+        /**
+         * The ids (decimal strings — the OR-chain batch convention) of
+         * every saddle holding a running session — the derived busy
+         * set the fleet lists and the offline stamp share.
+         */
+        private async Invercargill.DataStructures.HashSet<string> busy_saddle_ids()
+                 throws Error {
+            var busy = new Invercargill.DataStructures.HashSet<string>();
+            var command = connection.create_command(
+                "SELECT DISTINCT s.saddle_id AS saddle_id FROM sessions s"
+                + " JOIN tasks t ON t.id = s.task_id"
+                + " WHERE s.ended_at IS NULL"
+                + " AND t.status IN ('assigned', 'working')");
+            foreach (var row in yield command.execute_query_async()) {
+                int64? saddle_id = 0;
+                ((!)row.get("saddle_id")).try_get_as<int64?>(out saddle_id);
+                if (saddle_id != null) {
+                    busy.add(((!)saddle_id).to_string());
+                }
+            }
+            return busy;
+        }
+
+        /**
+         * Saddles holding a running session (the dispatcher's poll
+         * candidates) — derived from the session records, registration
+         * order.
          */
         public async ImmutableLot<Saddle> list_busy_saddles() throws Error {
-            return yield db.query<Saddle>()
-                .where(expr("status == $0", elem(SADDLE_BUSY)))
-                .materialise_async();
+            var busy = yield busy_saddle_ids();
+            if (busy.count() == 0) {
+                return (new Vector<Saddle>()).to_immutable_buffer();
+            }
+            var rows = new Vector<Saddle>();
+            foreach (var saddle in yield db.query<Saddle>()
+                    .where(TaskService.id_in("id", busy))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                rows.add(saddle);
+            }
+            return rows.to_immutable_buffer();
         }
 
         /**
@@ -445,14 +553,15 @@ namespace ClankerSaddle.Services {
 
         /**
          * {@link abort_live_session} keyed by saddle instead of task —
-         * the unregister path. The cancel is delivered to the saddle
-         * whose own open session was found (NOT re-resolved through the
-         * task's lowest-id open session, which a stale fixture row on
-         * another saddle would redirect to the wrong daemon).
+         * the unregister path. The cancel is delivered to the saddle's
+         * RUNNING session (the derived record; plan 16 item 23) — not
+         * re-resolved through a task's lowest-id open session, which a
+         * stale fixture row on another saddle would redirect to the
+         * wrong daemon.
          */
         public async void abort_saddle_session(TaskService tasks, int64 saddle_id,
                 string verb) throws Error {
-            var session = yield open_session_on_saddle(saddle_id);
+            var session = yield running_session_on_saddle(saddle_id);
             if (session == null) {
                 return;
             }
@@ -499,11 +608,11 @@ namespace ClankerSaddle.Services {
          * offline (a manual status refresh, a restart) — without this
          * pass they would wait forever for a daemon that will not answer.
          * Only `assigned`/`working` tasks move: parked tasks
-         * (blocked/review/suspended) keep their sessions, which the
-         * resume machinery needs. Each move is {@link TaskService.requeue_task}
-         * — the release owner closes the session and frees the saddle —
-         * plus a agent comment so the timeline shows why the task
-         * restarted.
+         * (blocked/review/suspended, their sessions closed 'parked')
+         * keep their records, which the resume machinery needs. Each
+         * move is {@link TaskService.requeue_task} — the release owner
+         * closes the session (the close IS the free, item 23) — plus a
+         * system comment so the timeline shows why the task restarted.
          */
         public async void requeue_tasks_on_offline_saddles(TaskService tasks) throws Error {
             foreach (var status in new string[] { STATUS_ASSIGNED, STATUS_WORKING }) {
@@ -532,46 +641,39 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * 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 = '" + SADDLE_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");
-        }
-
-        /**
-         * Every saddle with status 'idle', registration order — the
-         * dispatcher's capability-matching candidate list.
+         * Every saddle free for work — derived (plan 16 item 23): not
+         * offline (the probe state) and holding no running session —
+         * registration order, the dispatcher's capability-matching
+         * candidate list. A parked task's closed session reads free
+         * exactly like the freed daemon it brackets.
          */
         public async ImmutableLot<Saddle> list_idle_saddles() throws Error {
-            return yield db.query<Saddle>()
-                .where(expr("status == $0", elem(SADDLE_IDLE)))
-                .order_by(expr("id"))
-                .materialise_async();
+            var busy = yield busy_saddle_ids();
+            var rows = new Vector<Saddle>();
+            foreach (var saddle in yield db.query<Saddle>()
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (saddle.status == SADDLE_OFFLINE
+                        || busy.contains(((int64) saddle.id).to_string())) {
+                    continue;
+                }
+                rows.add(saddle);
+            }
+            return rows.to_immutable_buffer();
         }
 
         /**
          * Marks a saddle offline when its control server cannot be
-         * reached, releasing the task it was holding back to 'queued'
-         * and closing that task's open session (outcome 'stopped' —
-         * {@link TaskService.release_task} owns that bookkeeping; the
-         * saddle row's own fate is the guarded offline write, never
-         * the idle free). The requeue lands as a agent comment on the
-         * task, so the timeline shows the interruption; combined with
-         * the daemon's crash-resume, the next dispatch restores the
-         * workspace and reports `resumed` on /status.
+         * reached. The task it was RUNNING (the derived running
+         * session — plan 16 item 23) releases back to 'queued' through
+         * {@link TaskService.release_task} (the ONE owner; it closes
+         * the session 'stopped'), with a system comment so the timeline
+         * shows the interruption; combined with the daemon's
+         * crash-resume, the next dispatch restores the workspace and
+         * reports `resumed` on /status. A task that parked mid-flight
+         * (its session already closed 'parked', the verdict awaiting
+         * the resume) keeps its park. The offline stamp itself is the
+         * ONE surviving guarded write — see {@link stamp_offline}.
          */
         public async void mark_saddle_unreachable(int64 id, TaskService tasks,
                 string? reason = null) throws Error {
@@ -579,61 +681,63 @@ namespace ClankerSaddle.Services {
             if (saddle == null) {
                 return;
             }
-            int64 held_task_at_entry = ((!)saddle).current_task_id;
+            var session = yield running_session_on_saddle(id);
+            int64 seen_session = session != null ? ((!)session).id : 0;
             int64 held_task = 0;
-            bool release_held = false;
-            if (held_task_at_entry > 0) {
-                held_task = held_task_at_entry;
-                bool keep_parked = false;
-                var task = yield db.query<TaskEntity>()
-                    .where(expr("id == $0", elem<int64?>(held_task)))
-                    .first_async();
-                if (task != null && (((!)task).status == STATUS_ASSIGNED
-                        || ((!)task).status == STATUS_WORKING || ((!)task).status == STATUS_BLOCKED
-                        || ((!)task).status == STATUS_REVIEW_REQUESTED)) {
-                    keep_parked = yield tasks.newest_block_answered(held_task);
-                    if (keep_parked) {
-                        // Parked-with-answered: the verdict must survive
-                        // for the resume (answer delivery, or the
-                        // affinity allocation when the daemon asks
-                        // again) — the session stays open and the task
-                        // stays parked.
-                        print(@"[ClankerSaddle] Task $(held_task) keeps its parked session: its answered block awaits delivery\n");
-                    } else {
-                        ((!)task).status = STATUS_QUEUED;
-                        yield db.update_async<TaskEntity>((!)task);
-                        string unreachable_text = reason != null && reason.length > 0
-                            ? @"Saddle became unreachable ($(reason)); task requeued"
-                            : "Saddle became unreachable; task requeued";
-                        yield tasks.add_system_comment(held_task, unreachable_text);
-                    }
+            if (session != null) {
+                held_task = ((!)session).task_id;
+                // The requeue flip is guarded: a park (or terminal)
+                // interleaving this path keeps its state — the parked
+                // verdict must survive for the resume.
+                var flip = connection.create_command(
+                    "UPDATE tasks SET status = 'queued' WHERE id = :id"
+                    + " AND status IN ('assigned', 'working')");
+                flip.with_parameter<Invercargill.Element>("id", elem<int64?>(held_task));
+                if ((yield flip.execute_non_query_async()) > 0) {
+                    string unreachable_text = reason != null && reason.length > 0
+                        ? @"Saddle became unreachable ($(reason)); task requeued"
+                        : "Saddle became unreachable; task requeued";
+                    yield tasks.add_system_comment(held_task, unreachable_text);
+                    // Closes the held session FIRST — the offline
+                    // stamp's guard refuses to land over a running
+                    // session, and by then there is none.
+                    yield tasks.release_task(held_task, OUTCOME_STOPPED, false);
+                } else {
+                    // Parked (or finished) mid-flight — the park closed
+                    // its session and the verdict must survive for the
+                    // resume; nothing to release.
+                    held_task = 0;
                 }
-                release_held = !keep_parked;
-            }
-            var offline_write = connection.create_command(
-                "UPDATE saddles SET status = '" + SADDLE_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();
+            }
+            yield stamp_offline(id, seen_session);
             // An offline saddle is neither sleeping nor wake-awaiting;
             // a restarted daemon re-establishes both by asking.
             poll_state.clear_ask(id);
-            if (release_held) {
-                // The held task's session close runs through the ONE
-                // release owner. Its idle-free no-ops against THIS row
-                // (the guarded offline write above already cleared
-                // current_task_id under the entry-seen guard); the
-                // close applies whatever the task's state was —
-                // flipped-and-requeued above, parked-suspended left
-                // alone, or a row that no longer exists.
-                yield tasks.release_task(held_task, OUTCOME_STOPPED, false);
-            }
             yield pulse.bump(0, "saddle unreachable");
         }
 
+        /**
+         * The ONE surviving guarded saddle write (plan 16 item 23):
+         * the OFFLINE probe stamp. The guard is the sessions-truth
+         * analogue of the old seen-assignment guard — the stamp lands
+         * unless a running session the probe did NOT see at entry
+         * appeared meanwhile (an ask claim is proof of life and must
+         * not be stomped offline). The session observed at entry
+         * (`seen_session`) never blocks its own stamp.
+         */
+        private async void stamp_offline(int64 saddle_id, int64 seen_session) throws Error {
+            var command = connection.create_command(
+                "UPDATE saddles SET status = '" + SADDLE_OFFLINE + "' WHERE id = :id"
+                + " AND NOT EXISTS (SELECT 1 FROM sessions s"
+                + " JOIN tasks t ON t.id = s.task_id"
+                + " WHERE s.saddle_id = :id AND s.ended_at IS NULL"
+                + " AND t.status IN ('assigned', 'working')"
+                + " AND s.id != :seen)");
+            command.with_parameter<Invercargill.Element>("id", elem<int64?>(saddle_id));
+            command.with_parameter<Invercargill.Element>("seen", elem<int64?>(seen_session));
+            yield command.execute_non_query_async();
+        }
+
         /**
          * Assembles the assignment payload shared by the /start push and
          * the `POST /api/saddle/next-task` response — `{task, kind,
@@ -982,11 +1086,15 @@ 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 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.
+         * Probes a saddle's `GET /status` and records what a probe can
+         * tell: the health snapshot, the daemon-reported ssh key and
+         * the goal-branch push flags. Busy/idle need NO write anymore —
+         * they are derived from the session records (plan 16 item 23).
+         * The row's only remaining write is the OFFLINE probe state: a
+         * successful probe is proof of life and clears it; a failed
+         * probe stamps it through the guarded write (an ask claim that
+         * landed while the probe was in flight is a NEWER proof of
+         * life and must not be stomped).
          */
         public async void refresh_saddle_status(int64 id) throws Error {
             var saddle = yield get_saddle(id);
@@ -994,61 +1102,22 @@ 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 = SADDLE_IDLE;
-            int64 next_task = 0;
-            DateTime? next_allocated = null;
+            var session = yield running_session_on_saddle(id);
+            int64 seen_session = session != null ? ((!)session).id : 0;
             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", "") : "";
                 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);
-                    next_status = SADDLE_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.
+                var clear = connection.create_command(
+                    "UPDATE saddles SET status = '" + SADDLE_IDLE
+                    + "' WHERE id = :id AND status = '" + SADDLE_OFFLINE + "'");
+                clear.with_parameter<Invercargill.Element>("id", elem<int64?>(id));
+                yield clear.execute_non_query_async();
             } catch (Error e) {
-                next_status = SADDLE_OFFLINE;
+                yield stamp_offline(id, seen_session);
             }
-            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");
         }
 
-        /**
-         * The saddle's open (live or parked) session, or null — the
-         * reconciliation source for whether a daemon is genuinely
-         * running one of our tasks.
-         */
-        private async ClankerSaddle.Data.Session? open_session_on_saddle(int64 saddle_id) throws Error {
-            var sessions = yield db.query<ClankerSaddle.Data.Session>()
-                .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
-                .order_by(expr("id"))
-                .materialise_async();
-            ClankerSaddle.Data.Session? newest_open = null;
-            foreach (var session in sessions) {
-                if (session.ended_at == null) {
-                    newest_open = session;
-                }
-            }
-            return newest_open;
-        }
-
         /**
          * Answered blocks of `task_id` not yet handed over anywhere;
          * marks them delivered — a resumed assignment carries its
@@ -1493,18 +1562,20 @@ namespace ClankerSaddle.Services {
          * plan 10 phase 2) — the same task/saddle bookkeeping the
          * deleted /status poll switch ran, driven by the daemon's push:
          * `done`/`failed` complete or fail the task with the pushed
-         * summary (freeing the saddle and closing the session) —
+         * summary (closing the session through the release owner) —
          * except on a goal-run session, where they submit the
          * completion report or fail the goal (plan 12); `blocked`
          * raises the block payload as an answerable block on the
          * goal run's OWN task row (plan 15 C1: the goal agent's
          * blocks ride the run task — no mirrored second row exists)
-         * and parks the saddle; `suspended`
+         * and parks the round (the raise closes its session 'parked',
+         * D4); `suspended`
          * parks the same way — for a goal agent the resume is driven
          * by the goal's task-terminal events; and `working` flips an
          * ASSIGNED task to working (a parked task keeps its park —
          * the verdict arrives as a resuming allocation, never as a
-         * state push; plan 16 D4).
+         * state push; plan 16 D4). No saddle-row write remains
+         * (plan 16 item 23): busy/idle derive from the sessions.
          *
          * Plan 13 B2: a push against a task that is already terminal —
          * or a goal-context push against a finished goal — is an
@@ -1512,7 +1583,7 @@ namespace ClankerSaddle.Services {
          * it carries; terminal transitions and their narrative fire
          * once, ever.
          */
-        public async void apply_result_state(Saddle saddle, ClankerSaddle.Data.Session session,
+        public async void apply_result_state(ClankerSaddle.Data.Session session,
                 string state, Json.Object? block, string? summary, TaskService tasks) throws Error {
             int64 task_id = session.task_id;
             int64 goal_id = yield goal_id_of_run(task_id);
@@ -1536,7 +1607,6 @@ namespace ClankerSaddle.Services {
                         yield goal_service.submit_completion(goal_id,
                             summary != null && summary.length > 0 ? summary
                                 : "The goal agent submitted its completion report");
-                        yield park_if_current(saddle.id, task_id);
                         break;
                     }
                     yield tasks.complete_task(task_id, summary, OUTCOME_DONE);
@@ -1565,7 +1635,6 @@ namespace ClankerSaddle.Services {
                         if (block == null) {
                             yield tasks.set_status(task_id, STATUS_BLOCKED);
                             yield tasks.park_open_sessions(task_id);
-                            yield park_if_current(saddle.id, task_id);
                             break;
                         }
                         string question = block_question_from_payload((!)block);
@@ -1583,7 +1652,6 @@ namespace ClankerSaddle.Services {
                                 EntryJson.optional_string_member((!)block, "body"));
                         }
                         yield tasks.set_status(task_id, STATUS_BLOCKED);
-                        yield park_if_current(saddle.id, task_id);
                         break;
                     }
                     yield ensure_block(tasks, task_id, block != null ? (!)block : new Json.Object());
@@ -1596,20 +1664,21 @@ namespace ClankerSaddle.Services {
                         yield tasks.set_status(task_id,
                             review ? STATUS_REVIEW_REQUESTED : STATUS_BLOCKED);
                     }
-                    // The daemon parks the task's workspace while blocked,
-                    // freeing the saddle for other work. The blocked task
-                    // plus its open session is the pause record the
-                    // affinity allocation resumes from once the block is
-                    // answered.
-                    yield park_if_current(saddle.id, task_id);
+                    // The daemon parks the task's workspace while
+                    // blocked, freeing the saddle for other work
+                    // (plan 16 item 23: the raise already closed the
+                    // round's session 'parked' and the task's blocked
+                    // status IS the pause — no saddle-row write
+                    // remains; busy/idle derive from the sessions).
                     break;
                 case "suspended":
                     // `saddle suspend`: the same parking as a block, but
                     // the web drives resumption — a wake of the affine
                     // saddle once a goal event lands, the resume
                     // assignment carrying the coalesced report.
+                    // (set_parked closed the round's session 'parked';
+                    // the freed saddle is the derived view's reading.)
                     yield tasks.set_parked(task_id);
-                    yield park_if_current(saddle.id, task_id);
                     break;
                 case "working":
                     // A working push only advances a task that is
@@ -1681,11 +1750,12 @@ namespace ClankerSaddle.Services {
          * bookkeeping). The probe flag was taken by the caller; the
          * next push resets it.
          */
-        public async void probe_stale_saddle(Saddle saddle, TaskService tasks) throws Error {
+        public async void probe_stale_saddle(Saddle saddle, TaskService tasks,
+                int64 running_task_id) throws Error {
             try {
                 var status = yield request_saddle_json(saddle.host, "GET", "/status", null);
                 yield record_status_probe(saddle, status);
-                print(@"[ClankerSaddle] Stale saddle '$(saddle.name)' answered its exceptional status probe; task $(saddle.current_task_id) keeps running\n");
+                print(@"[ClankerSaddle] Stale saddle '$(saddle.name)' answered its exceptional status probe; task $(running_task_id) keeps running\n");
             } catch (Error e) {
                 warning(@"[ClankerSaddle] Saddle '$(saddle.name)' sent neither chunks nor results for $(stale_after_seconds())s and its status probe failed ($(e.message)); marking offline");
                 yield mark_saddle_unreachable(saddle.id, tasks,

+ 20 - 22
src/Services/TaskService.vala

@@ -1136,7 +1136,15 @@ namespace ClankerSaddle.Services {
          * (the grammar has no IN operator), or null for an empty
          * batch — the WHERE-in the batched lookups build.
          */
-        private static Expression? id_in(string column,
+        /**
+         * The OR-chain batch convention (plan 16 item 23 note): id
+         * batches compare through decimal STRINGS — Invercargill
+         * collections corrupt boxed int64 vectors, and the expression
+         * layer's string compare against integer columns reads fine
+         * (SQLite's dynamic typing). Shared with SaddleService's
+         * derived fleet lists.
+         */
+        internal static Expression? id_in(string column,
                 Invercargill.DataStructures.HashSet<string> ids) throws Error {
             Expression? chain = null;
             foreach (var id in ids) {
@@ -1653,27 +1661,23 @@ namespace ClankerSaddle.Services {
         /**
          * The ONE release path (plan 16 R2, arch item 13): every path
          * that takes a task off its saddle runs through here. Closes
-         * the task's open sessions with `outcome` and frees whatever
-         * saddle row still runs it — one conditional UPDATE, atomic
-         * against a concurrent ask allocation claiming a new task for
-         * the saddle between a read and a full-row write. `requeue`
-         * first returns a non-terminal task to the queue
-         * (assigned/working/blocked/review_requested/suspended — the
-         * suspension stamp withdraws so the next dispatch opens a
-         * fresh parking window).
+         * the task's open sessions with `outcome` — the close IS the
+         * free (plan 16 item 23: busy/idle derive from the open
+         * sessions; no saddle-row write remains to race an ask
+         * allocation). `requeue` first returns a non-terminal task to
+         * the queue (assigned/working/blocked/review_requested/
+         * suspended — the suspension stamp withdraws so the next
+         * dispatch opens a fresh parking window).
          *
          * The terminal callers (complete/cancel/fail) pass their own
          * outcome with the status already stamped; requeue_task and
          * the unregister path ride the requeue; retry_task does its
          * own unconditional flip (summary and completed_at die with
          * the attempt) and releases without one. The offline variant
-         * ({@link SaddleService.mark_saddle_unreachable}) keeps its
-         * own guarded OFFLINE write for the saddle row and calls this
-         * after it — the idle-free below no-ops against that row (its
-         * current_task_id already cleared under the entry-seen guard)
-         * while the session close applies; its keep-parked gate (an
-         * answered block awaiting delivery) skips the release
-         * entirely.
+         * ({@link SaddleService.mark_saddle_unreachable}) calls this
+         * for its derived held task before its own guarded OFFLINE
+         * stamp; its keep-parked gate (a task that parked mid-flight)
+         * skips the release entirely.
          */
         public async void release_task(int64 task_id, string outcome, bool requeue) throws Error {
             if (requeue) {
@@ -1702,12 +1706,6 @@ namespace ClankerSaddle.Services {
                 session.outcome = outcome;
                 yield db.update_async<Session>(session);
             }
-            var command = connection.create_command(
-                "UPDATE saddles SET status = '" + SADDLE_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();
         }
     }
 }

+ 2 - 2
src/actions/TaskActions.vala

@@ -477,9 +477,9 @@ namespace ClankerSaddle {
 
     /**
      * Manually requeues a task stuck working/blocked on a saddle that
-     * went offline (the automatic three-strike path may never fire when
+     * went offline (the automatic staleness probe may never fire when
      * the saddle row was refreshed to offline first). The same path as
-     * the dispatcher's abandon: requeue + agent comment + saddle
+     * the dispatcher's abandon: requeue + system comment + saddle
      * offline — owned by {@link LifecycleService.requeue_for_resume}.
      */
     public class ResumeTaskAction : SealedTaskAction {

+ 8 - 4
src/entrypoints/HomeEntrypoint.vala

@@ -167,17 +167,21 @@ namespace ClankerSaddle {
 
             var saddle_rows = new Vector<DashboardSaddleView>();
             foreach (var saddle in yield saddles.list_saddles()) {
-                if (saddle.status == SADDLE_IDLE) {
+                // Plan 16 item 23: the card reads the DERIVED view —
+                // busy iff a running session exists, else the row's
+                // stored OFFLINE probe state.
+                var work = yield saddles.current_work_view(saddle);
+                string status = SaddleService.derived_status(saddle, work);
+                if (status == SADDLE_IDLE) {
                     pub.idle_saddle_count++;
                 }
                 var view = new DashboardSaddleView() {
                     id = saddle.id,
                     name = saddle.name,
-                    status = saddle.status,
+                    status = status,
                     host = saddle.host,
-                    current_task_id = saddle.current_task_id
+                    current_task_id = work != null ? ((!)work).task_id : 0
                 };
-                var work = yield saddles.current_work_view(saddle);
                 if (work != null) {
                     view.current_task_title = ((!)work).task_title;
                     if (((!)work).goal_id > 0) {

+ 9 - 4
src/entrypoints/SaddlesEntrypoint.vala

@@ -156,11 +156,16 @@ namespace ClankerSaddle {
                 string last_contact = contact_ms > 0
                     ? relative_time(now_ms - contact_ms) : "never";
                 var sealed = new SaddleActionPrivate() { saddle_id = saddle.id };
+                // Plan 16 item 23: the row reads the DERIVED view —
+                // busy iff a running session exists (its started_at is
+                // the allocation stamp), else the stored OFFLINE probe
+                // state.
+                var work = yield saddle_service.current_work_view(saddle);
                 var view = new SaddleRowView() {
                     id = saddle.id,
                     name = saddle.name,
                     host = saddle.host,
-                    status = saddle.status,
+                    status = SaddleService.derived_status(saddle, work),
                     health = health,
                     daemon_state = daemon_state,
                     uptime_display = uptime > 0 ? relative_time(uptime * 1000) : "",
@@ -169,14 +174,14 @@ namespace ClankerSaddle {
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
                     update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
-                    current_task_id = saddle.current_task_id,
-                    allocated_display = format_stamp(saddle.allocated_at),
+                    current_task_id = work != null ? ((!)work).task_id : 0,
+                    allocated_display = work != null
+                        ? format_stamp(((!)work).started_at) : "",
                     unregister = action_registry.author_private<UnregisterSaddleAction, SaddleActionPrivate>(sealed),
                     refresh = action_registry.author_private<RefreshSaddleStatusAction, SaddleActionPrivate>(sealed),
                     attach_capability = action_registry.author_private<AttachSaddleCapabilityAction, SaddleActionPrivate>(sealed)
                 };
                 view.token = saddle.token ?? "";
-                var work = yield saddle_service.current_work_view(saddle);
                 if (work != null) {
                     view.current_task_title = ((!)work).task_title;
                     if (((!)work).goal_id > 0) {

+ 64 - 13
test/bundles/lifecycle.py

@@ -600,11 +600,20 @@ def run(ctx):
           "live-block task completes after answering")
 
     print("== R2: the one release path behind unregister ==")
-    # Arch 13: removing a saddle releases its held task through the
-    # same owner as every requeue — never an orphan open session on a
-    # deleted row. Route 1: a DEAD host — the abort delivery fails
-    # fast, the offline bookkeeping releases (queued + comment +
-    # session 'stopped'), the unregister then deletes the row.
+    # Arch 13 + plan 16 item 23: removing a saddle releases the task it
+    # is DERIVED to run (its open session) through the same owner as
+    # every requeue — never an orphan open session on a deleted row, and
+    # no saddle-row cache to key the release on. Route 1: a DEAD host —
+    # the abort delivery fails fast, the offline bookkeeping releases
+    # (queued + comment + session 'stopped'), the unregister then
+    # deletes the row.
+    conn = db()
+    no_cache_columns = conn.execute(
+        "select count(*) from pragma_table_info('saddles')"
+        " where name in ('current_task_id', 'allocated_at')").fetchone()[0]
+    conn.close()
+    check(no_cache_columns == 0,
+          "the saddle row carries no work cache (item 23: sessions are the truth)")
     saddles_state = slot_public(auth, "/saddles", "saddles")
     act(auth, saddles_state["register"], {"name": "dead-host",
                                           "host": "127.0.0.1:1"})
@@ -622,8 +631,6 @@ def run(ctx):
                  (dead_task,))
     conn.execute("insert into sessions (task_id, saddle_id, started_at)"
                  " values (?, ?, strftime('%s','now'))", (dead_task, dead_id))
-    conn.execute("update saddles set current_task_id = ? where id = ?",
-                 (dead_task, dead_id))
     conn.commit()
     dead_session = conn.execute(
         "select id from sessions where task_id = ? and ended_at is null",
@@ -646,9 +653,10 @@ def run(ctx):
           "the offline release closes the held task's session 'stopped'")
     check(dead_saddle_count == 0, "the dead-host saddle row is gone")
 
-    # Route 2: no open session on the saddle — the abort is a no-op
-    # and the unregister itself carries the release (the requeue flip
-    # through the one owner, the row deleted after).
+    # Route 2: a REACHABLE host holding a running session — the abort
+    # delivers (the daemon answers 200; its idle loop aborts nothing),
+    # and the unregister itself carries the derived release through the
+    # one owner (requeue flip + close + row deleted).
     saddles_state = slot_public(auth, "/saddles", "saddles")
     act(auth, saddles_state["register"], {"name": "quiet-host",
                                           "host": "127.0.0.1:%d" % fake.port})
@@ -664,17 +672,60 @@ def run(ctx):
     conn = db()
     conn.execute("update tasks set status = 'working' where id = ?",
                  (quiet_task,))
-    conn.execute("update saddles set current_task_id = ? where id = ?",
-                 (quiet_task, quiet_id))
+    conn.execute("insert into sessions (task_id, saddle_id, started_at)"
+                 " values (?, ?, strftime('%s','now'))", (quiet_task, quiet_id))
     conn.commit()
+    quiet_session = conn.execute(
+        "select id from sessions where task_id = ? and ended_at is null",
+        (quiet_task,)).fetchone()[0]
     conn.close()
     act(auth, quiet_card["unregister"], {})
     conn = db()
     quiet_row = conn.execute("select status from tasks where id = ?",
                              (quiet_task,)).fetchone()
+    quiet_session_row = conn.execute(
+        "select outcome, ended_at is not null from sessions where id = ?",
+        (quiet_session,)).fetchone()
     quiet_saddle_count = conn.execute(
         "select count(*) from saddles where id = ?", (quiet_id,)).fetchone()[0]
     conn.close()
     check(quiet_row is not None and quiet_row[0] == "queued",
-          "unregister's own release returns the held task to the queue")
+          "unregister's own release returns the derived held task to the queue")
+    check(quiet_session_row is not None and quiet_session_row[0] == "stopped"
+          and quiet_session_row[1],
+          "the derived release closes the held task's session 'stopped'")
     check(quiet_saddle_count == 0, "the quiet-host saddle row is gone")
+
+    # Route 3 (the derived-view pin): a 'working'-status task with NO
+    # session derives NO hold — with the cache gone there is nothing to
+    # key a release on, and the unregister must leave the drift row
+    # untouched (a shape only fixtures can fabricate now: every real
+    # path that flips a task running opens its session in the same
+    # breath).
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["register"], {"name": "empty-host",
+                                          "host": "127.0.0.1:1"})
+    empty_card = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                  if s["name"] == "empty-host"][0]
+    empty_id = int(empty_card["id"])
+    task_id_tmp = create_queued(auth, {
+        "project_id": "1",
+        "title": "Empty-host drift task",
+        "priority": "2",
+    })
+    empty_task = task_id_tmp
+    conn = db()
+    conn.execute("update tasks set status = 'working' where id = ?",
+                 (empty_task,))
+    conn.commit()
+    conn.close()
+    act(auth, empty_card["unregister"], {})
+    conn = db()
+    empty_row = conn.execute("select status from tasks where id = ?",
+                             (empty_task,)).fetchone()
+    empty_saddle_count = conn.execute(
+        "select count(*) from saddles where id = ?", (empty_id,)).fetchone()[0]
+    conn.close()
+    check(empty_row is not None and empty_row[0] == "working",
+          "no session means no derived hold — unregister releases nothing")
+    check(empty_saddle_count == 0, "the empty-host saddle row is gone")

+ 11 - 0
test/bundles/pages.py

@@ -654,6 +654,17 @@ def run(ctx):
           and "badge-yeehaw" in home_page,
           "the dashboard renders the badge on timeline task references")
 
+    # Close the yeehaw fixture's round (a plain done push under its
+    # session): under the derived view (item 23 — busy iff an open
+    # session on an assigned/working task) the fixture round would
+    # hold the fake's saddle busy forever; the goal section below
+    # needs it free.
+    status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
+                           {"session_id": yeehaw_session, "state": "done",
+                            "summary": "yeehaw fixture round closed"})
+    check(wait_task_done(auth, yeehaw_task, timeout=30) is not None,
+          "the yeehaw fixture round closes (the derived busy view frees the fake)")
+
     # (The old R10 scenario — branch_advanced riding /poll verdicts —
     # died with the poll channel; the answers bundle pins the hint on
     # its surviving surface, the resumed assignment payload.)

+ 3 - 4
test/integration_test.py

@@ -803,8 +803,6 @@ def main():
         db = sqlite3.connect(DB_PATH)
         db.execute("insert into sessions (task_id, saddle_id, started_at)"
                    " values (?, ?, strftime('%s','now'))", (api_task, api_saddle))
-        db.execute("update saddles set current_task_id = ?, status = 'idle' where id = ?",
-                   (api_task, api_saddle))
         db.execute("update tasks set status = 'working' where id = ?", (api_task,))
         db.commit()
         api_session = db.execute("select max(id) from sessions").fetchone()[0]
@@ -884,12 +882,13 @@ def main():
 
         print("== quiesce ==")
         check(wait_for(
-            lambda: (lambda rows: all(int(s["current_task_id"] or 0) == 0 and s["status"] == "idle"
+            lambda: (lambda rows: all(int(s["current_task_id"] or 0) == 0
+                                      and s["status"] == "idle"
                                       for s in rows))(
                 [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
                  if s["name"] == SADDLE_NAME]) or False,
             timeout=30, what="saddle idle with no current task") is not None,
-            "saddle freed and idle once the goal finished")
+            "saddle derived idle with no running work once the goal finished")
     finally:
         if mock is not None:
             mock.terminate()