Quellcode durchsuchen

Turn-cap checkpoint-park web half; model configuration moves to an Admin page

The turn-cap park (goal 28 task D2): a suspended transition carrying
reason turn_cap parks the task with the one-shot wrap-up notice
(immediately resume-offerable — the pending_resume_notice lift) and
stamps tasks.turn_cap_parked (M0009); the flag rides every later
assignment so the daemon's next cap hit is the final hard kill. The
results endpoint threads the reason member through and the turncappark
bundle pins the whole arc.

The model configuration (base URL, API key, model, plus the advisor's
optional overrides) now lives in the model_config row (M0010) behind
the admin-only Admin → Model page — the environment no longer
configures it. The first read on an empty table seeds the row from the
legacy SADDLE_LLM_*/ADVISOR_LLM_* environment once, so a pre-page
deployment keeps dispatching through its upgrade; afterwards the page
owns the configuration and the dispatcher, the assignment payload and
the advisor all read the database. The advisor bundles write the row
directly (set_model_config) instead of exporting env for an app
restart, the pages bundle pins serials 1-10, the chat/home notices
point at the page instead of variable names, and the executable
carries an install_rpath so meson-install output loads libspry outside
the build tree (the canon_deploy deployed-boot check).
clanker vor 4 Wochen
Ursprung
Commit
9b3cf45952

+ 35 - 12
README.md

@@ -59,12 +59,20 @@ Orchestration behaviour to know:
 
 | Setting | Meaning |
 |---|---|
-| `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_STALE_S` | Running-liveness window (default 90s): no chunks or results for this long → one exceptional `GET /status` probe; failure requeues the task and marks the saddle offline |
 | `SADDLE_PROJECTS_ROOT` | Fallback for advisory checkout paths when a saddle's daemon did not report a `workspace_root` at registration (itself falling back to `/workspace`); see *Deployment assumptions* |
 
+The **model configuration** (the LLM endpoint every assignment is sent
+to, its API key and model, plus the advisor's optional overrides) is
+NOT environment-driven: it lives in the database and is edited on the
+**Admin → Model** page (`/models`, admin-only). A pre-page deployment
+exports `SADDLE_LLM_BASE_URL` / `SADDLE_LLM_API_KEY` / `SADDLE_LLM_MODEL`
+(and the `ADVISOR_LLM_*` equivalents) exactly once — the first
+configuration read seeds the `model_config` row from them and the
+environment is ignored from then on. An empty base URL pauses task
+assignment (the dispatcher logs it; the page shows a `paused` badge).
+
 ## Quick start (local)
 
 ```bash
@@ -81,11 +89,15 @@ port must be reachable from the daemon; project checkouts live under
 `SADDLE_PROJECTS_ROOT`):
 
 ```bash
-export SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 SADDLE_LLM_MODEL=mock
 export SADDLE_PROJECTS_ROOT=/tmp/clanker-e2e/workspace
 ./builddir/clanker-saddle 8080
 ```
 
+Then log in as the admin and set the model endpoint on the **Admin →
+Model** page to `http://127.0.0.1:9999` (mock, model `mock`) — or export
+`SADDLE_LLM_BASE_URL` before the very first boot and let the one-time
+seed import it.
+
 `SPRY_SEED_ADMIN_USERNAME`/`SPRY_SEED_ADMIN_PASSWORD` default to
 `admin`/`change-me` when unset; export them to seed different credentials.
 
@@ -142,6 +154,10 @@ podman run -d --name clanker-saddle --userns=keep-id --network clanker-net \
     localhost/saddle:0.1
 ```
 
+(The `SADDLE_LLM_BASE_URL` export above is the ONE-TIME seed: the first
+boot imports it into the database's model configuration, which the
+**Admin → Model** page owns from then on.)
+
 Register the saddle with its **network** address (`clanker-saddle:7100`)
 — the web app talks to the daemon container-to-container, while the LLM
 base URL is dialled by the daemon itself (a mock can run inside the
@@ -468,17 +484,21 @@ to react — report the result back, suggest follow-ups — so the
 conversation continues from real outcomes.
 
 **Configuration.** The advisor uses its own OpenAI-compatible
-chat-completions endpoint, falling back to the daemon-facing one:
-
-```bash
-export ADVISOR_LLM_BASE_URL=http://127.0.0.1:9999
-export ADVISOR_LLM_API_KEY=…        # optional
-export ADVISOR_LLM_MODEL=mock       # optional
+chat-completions endpoint — the advisor-specific override fields on the
+**Admin → Model** page (`/models`), falling back to the main endpoint's
+fields when the override base URL is empty:
+
+```text
+Admin → Model → Advisor endpoint (optional):
+    base URL   http://127.0.0.1:9999     (empty = use the main endpoint)
+    API key    …                         (empty = use the main key)
+    model      mock                      (empty = use the main model)
 ```
 
-When neither `ADVISOR_LLM_BASE_URL` nor `SADDLE_LLM_BASE_URL` resolves,
-the composer disables itself and the chat pages show a "not configured"
-notice naming the variable to set — nothing else changes.
+When no endpoint resolves at all (neither the override nor the main
+base URL), the composer disables itself and the chat pages show a
+"not configured" notice pointing at the Admin → Model page — nothing
+else changes.
 
 ## The canon and the ethos (single source of truth)
 
@@ -541,6 +561,9 @@ export SPRY_DB_PATH=/tmp/clanker-e2e/web/clanker-saddle.sqlite
 export SADDLE_GOAL_RESUME_S=2   # shrink the coalescing window for tests
 ./builddir/clanker-saddle 8180 &   # 8180 — 8080 is the production port
 
+# SADDLE_LLM_BASE_URL above seeds the model configuration once (the
+# database must be FRESH — the harness refuses a used one)
+
 SADDLE_WORKSPACE_ROOT=/tmp/clanker-e2e/workspace \
     ../saddle/builddir/src/saddle --port 7100 &
 

+ 23 - 1
meson.build

@@ -17,6 +17,15 @@ inversion_dep = dependency('inversion-0.1')
 astralis_dep = dependency('astralis-0.1')
 statum_dep = dependency('statum-0.1')
 spry_dep = dependency('spry-0.2')
+
+# The dev stack (spry 0.2 and co) lives in a non-standard prefix on
+# development machines (~/.local): meson's automatic build rpath makes
+# the BUILD-tree binary run, but `meson install` strips it, leaving the
+# installed binary unable to load libspry outside the build tree (the
+# canon_deploy bundle's deployed-boot check). Carry the dependency's
+# pkg-config libdir as the INSTALL rpath — inside a usm container the
+# same expression resolves the (harmless, existing) system libdir.
+devstack_libdir = spry_dep.get_variable(pkgconfig: 'libdir', default_value: '')
 # Web Push crypto primitives (P-256 ECDH/ECDSA + AES-128-GCM for VAPID
 # and RFC 8291 payload encryption — plan 11 task E). Found via the C
 # compiler (no Vala vapi exists for libcrypto; the shim is C-only).
@@ -146,6 +155,12 @@ users_page = custom_target('users-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'UsersPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+model_settings_page = custom_target('model-settings-page',
+    input: 'src/pages/model_settings.html',
+    output: 'ModelSettingsPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ModelSettingsPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 profile_page = custom_target('profile-page',
     input: 'src/pages/profile.html',
     output: 'ProfilePage.vala',
@@ -208,6 +223,8 @@ app_sources = files(
     'src/Data/Migrations/M0006_Advisor.vala',
     'src/Data/Migrations/M0007_ParkingGuard.vala',
     'src/Data/Migrations/M0008_AnswerWake.vala',
+    'src/Data/Migrations/M0009_TurnCapPark.vala',
+    'src/Data/Migrations/M0010_ModelConfig.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -228,6 +245,7 @@ app_sources = files(
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
     'src/Services/ChatService.vala',
+    'src/Services/ModelConfigService.vala',
     'src/Services/CanonService.vala',
     'src/Services/AdvisorService.vala',
     'src/Services/AdvisorCallback.vala',
@@ -249,6 +267,7 @@ app_sources = files(
     'src/actions/NoteActions.vala',
     'src/actions/CapabilityActions.vala',
     'src/actions/UserActions.vala',
+    'src/actions/ModelActions.vala',
     'src/actions/ProfileActions.vala',
     'src/actions/PushActions.vala',
     'src/entrypoints/HomeEntrypoint.vala',
@@ -265,6 +284,7 @@ app_sources = files(
     'src/entrypoints/ChatDetailEntrypoint.vala',
     'src/entrypoints/StacksEntrypoint.vala',
     'src/entrypoints/UsersEntrypoint.vala',
+    'src/entrypoints/ModelSettingsEntrypoint.vala',
     'src/entrypoints/ProfileEntrypoint.vala',
     # spry:sources-end
 )
@@ -292,6 +312,7 @@ app_generated = [
     stacks_page,
     stack_detail_page,
     users_page,
+    model_settings_page,
     profile_page,
     # spry:generated-end
 ]
@@ -318,7 +339,8 @@ clanker_saddle_exe = executable('clanker-saddle',
         glib_dep,
         libcrypto_dep,
     ],
-    install: true
+    install: true,
+    install_rpath: devstack_libdir
 )
 
 # The canonical texts ride the install into

+ 6 - 1
src/Api/SaddleApi.vala

@@ -1264,11 +1264,16 @@ namespace ClankerSaddle.Api {
                 && body.get_member("block").get_node_type() == Json.NodeType.OBJECT
                 ? body.get_object_member("block") : null;
             string? summary = EntryJson.get_string_or(body, "summary", null);
+            // Goal 28 task D2: the optional `reason` names a SPECIAL
+            // park on a suspended transition (`turn_cap` — the
+            // daemon's checkpoint-park at the turn cap); absent on
+            // every ordinary transition.
+            string reason = EntryJson.get_string_or(body, "reason", "") ?? "";
 
             poll_state.note_push(session_id);
             try {
                 yield saddles.apply_result_state((!)session, state, block,
-                    summary, tasks);
+                    summary, tasks, reason);
                 return SaddleWire.json_ok(new Json.Object());
             } catch (Error e) {
                 return SaddleWire.json_error(400, e.message);

+ 51 - 0
src/Data/Entities.vala

@@ -91,6 +91,15 @@ namespace ClankerSaddle.Data {
          * resume_notice, the force-wake discipline).
          */
         public string? pending_resume_notice { get; set; default = null; }
+        /**
+         * Goal 28 task D2: this task's lineage already spent its
+         * turn-cap checkpoint-park (the daemon's first cap hit parks
+         * with reason `turn_cap` instead of failing). The flag rides
+         * EVERY later assignment (`turn_cap_parked`) so the daemon
+         * hard-kills the next cap hit; it is never cleared — the
+         * wrap-up chance is once per task.
+         */
+        public bool turn_cap_parked { get; set; default = false; }
         /** The goal this task belongs to (0 = standalone). */
         public int64 goal_id { get; set; default = 0; }
         /** "task" (a worker) or "goal_run" (the goal agent's own run). */
@@ -119,6 +128,7 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("suspended_at", o => o.suspended_at, (o, v) => o.suspended_at = v)
                 .column<bool>("force_wake", o => o.force_wake, (o, v) => o.force_wake = v)
                 .column<string>("pending_resume_notice", o => o.pending_resume_notice, (o, v) => o.pending_resume_notice = v)
+                .column<bool>("turn_cap_parked", o => o.turn_cap_parked, (o, v) => o.turn_cap_parked = v)
                 .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v ?? 0)
                 .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
                 .column<string>("mode", o => o.mode, (o, v) => o.mode = v)
@@ -818,4 +828,45 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * The deployment's model configuration (the Admin → Model page):
+     * the OpenAI-compatible endpoint every saddle assignment is sent
+     * to, plus OPTIONAL advisor-specific overrides (empty falls back
+     * to the main fields). The SINGLETON row (id 1) of the
+     * `model_config` table — created by M0010 and seeded ONCE from
+     * the legacy SADDLE_LLM_ and ADVISOR_LLM_-prefixed environment of
+     * a pre-page deployment by {@link Services.ModelConfigService};
+     * afterwards the database is the only source (environment changes
+     * are ignored).
+     */
+    public class ModelConfig : Object {
+
+        public int64 id { get; set; default = 1; }
+        /** The saddle assignments' endpoint base URL ("" = dispatch paused). */
+        public string base_url { get; set; default = ""; }
+        /** Its API key ("" = none; some proxies run keyless). */
+        public string api_key { get; set; default = ""; }
+        /** Its model name ("" = the provider default). */
+        public string model { get; set; default = ""; }
+        /** The advisor's override endpoint ("" = fall back to the main fields). */
+        public string advisor_base_url { get; set; default = ""; }
+        /** The advisor's override API key ("" = fall back). */
+        public string advisor_api_key { get; set; default = ""; }
+        /** The advisor's override model ("" = fall back). */
+        public string advisor_model { get; set; default = ""; }
+        public DateTime? updated_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<ModelConfig> cfg) {
+            cfg.table("model_config")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v ?? 1)
+                .column<string>("base_url", o => o.base_url, (o, v) => o.base_url = v)
+                .column<string>("api_key", o => o.api_key, (o, v) => o.api_key = v)
+                .column<string>("model", o => o.model, (o, v) => o.model = v)
+                .column<string>("advisor_base_url", o => o.advisor_base_url, (o, v) => o.advisor_base_url = v)
+                .column<string>("advisor_api_key", o => o.advisor_api_key, (o, v) => o.advisor_api_key = v)
+                .column<string>("advisor_model", o => o.advisor_model, (o, v) => o.advisor_model = v)
+                .column<DateTime?>("updated_at", o => o.updated_at, (o, v) => o.updated_at = v);
+        }
+    }
+
 }

+ 72 - 0
src/Data/Migrations/M0009_TurnCapPark.vala

@@ -0,0 +1,72 @@
+using InvercargillSql.Migrations;
+using Sqlite;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 28 task D2 — the turn-cap checkpoint-park. The daemon's
+     * FIRST turn-cap hit pushes a `suspended` transition with reason
+     * `turn_cap`; the web parks the task (the one-shot wrap-up
+     * `pending_resume_notice` makes it immediately resume-offerable)
+     * and stamps `turn_cap_parked` so every later assignment of the
+     * task carries the flag — a run that reaches the cap again under
+     * it is the final hard kill, once per task lineage.
+     *
+     * tasks.turn_cap_parked is that lineage flag. Non-null boolean,
+     * false when the task never parked at the cap; it is never
+     * cleared (the wrap-up chance is once).
+     *
+     * IDEMPOTENCE: same contract as M0002–M0008 — up() runs before the
+     * apply transaction opens (the builder only queues), so it
+     * introspects the schema through a separate read-only handle
+     * and adds the column only when it is still missing.
+     */
+    public class M0009_TurnCapPark : Migration {
+
+        public override string name { get { return "Turn cap park"; } }
+        public override uint64 serial { get { return 9; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        // The runner never hands up() a connection, so the schema is
+        // read through a read-only handle on the SPRY_DB_PATH main.vala
+        // connects to.
+        private string db_path() {
+            var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+            if (path.strip().length == 0) {
+                return "./clanker-saddle.sqlite";
+            }
+            return path;
+        }
+
+        private bool column_exists() {
+            Database db;
+            int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            Statement statement;
+            rc = db.prepare_v2(
+                "select 1 from pragma_table_info('tasks') where name = 'turn_cap_parked'",
+                -1, out statement);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            return statement.step() == Sqlite.ROW;
+        }
+
+        public override void up(MigrationBuilder b) {
+            if (column_exists()) {
+                return;
+            }
+            b.alter_table("tasks", (t) => {
+                t.add_column<bool>("turn_cap_parked");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            // SQLite cannot drop a column through the builder's
+            // alter_table path; the raw statement is the honest form.
+            b.execute_sql("alter table tasks drop column turn_cap_parked");
+        }
+    }
+}

+ 85 - 0
src/Data/Migrations/M0010_ModelConfig.vala

@@ -0,0 +1,85 @@
+using InvercargillSql.Migrations;
+using Sqlite;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * The Admin → Model page's storage: the `model_config` singleton
+     * (row id 1) holding the OpenAI-compatible endpoint every saddle
+     * assignment is sent to plus optional advisor-specific overrides.
+     * The service seeds the row ONCE from the legacy SADDLE_LLM_* /
+     * ADVISOR_LLM_* environment of a pre-page deployment; afterwards
+     * the database is the only configuration source.
+     *
+     * IDEMPOTENCE: same contract as M0002–M0009 — up() runs before the
+     * apply transaction opens (the builder only queues), so it
+     * introspects the schema through a separate read-only handle and
+     * creates the table only when it is still missing.
+     */
+    public class M0010_ModelConfig : Migration {
+
+        public override string name { get { return "Model config"; } }
+        public override uint64 serial { get { return 10; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        // The runner never hands up() a connection, so the schema is
+        // read through a read-only handle on the SPRY_DB_PATH main.vala
+        // connects to.
+        private string db_path() {
+            var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+            if (path.strip().length == 0) {
+                return "./clanker-saddle.sqlite";
+            }
+            return path;
+        }
+
+        private bool table_exists() {
+            Database db;
+            int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            Statement statement;
+            rc = db.prepare_v2(
+                "select 1 from sqlite_master where type = 'table' and name = 'model_config'",
+                -1, out statement);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            return statement.step() == Sqlite.ROW;
+        }
+
+        public override void up(MigrationBuilder b) {
+            if (table_exists()) {
+                return;
+            }
+            b.create_table("model_config", (t) => {
+                t.column<int64?>("id")
+                    .primary_key();
+                t.column<string>("base_url")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<string>("api_key")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<string>("model")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<string>("advisor_base_url")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<string>("advisor_api_key")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<string>("advisor_model")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<DateTime?>("updated_at");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.execute_sql("drop table if exists model_config");
+        }
+    }
+}

+ 31 - 26
src/Services/AdvisorService.vala

@@ -10,9 +10,11 @@ namespace ClankerSaddle.Services {
 
     /**
      * The advisor's OpenAI-compatible endpoint (goal 27): the same
-     * shape as {@link SaddleService.LlmEndpoint} but resolved from
-     * ADVISOR_LLM_* first, falling back to SADDLE_LLM_* so an
-     * operator with one provider configures nothing extra.
+     * shape as {@link SaddleService.LlmEndpoint}, resolved from the
+     * deployment's model configuration ({@link ModelConfigService}) —
+     * the advisor-specific override fields first, falling back to the
+     * main fields so an operator with one provider configures nothing
+     * extra.
      */
     public class AdvisorLlmEndpoint : Object {
 
@@ -20,24 +22,16 @@ namespace ClankerSaddle.Services {
         public string api_key { get; set; default = ""; }
         public string model { get; set; default = ""; }
 
-        public static string first_non_empty(string primary, string fallback) {
-            var value = (Environment.get_variable(primary) ?? "").strip();
-            if (value.length > 0) {
-                return value;
-            }
-            return (Environment.get_variable(fallback) ?? "").strip();
-        }
-
         /** The configured endpoint, or null when no base URL resolves. */
-        public static AdvisorLlmEndpoint? from_environment() {
-            var base_url = first_non_empty("ADVISOR_LLM_BASE_URL", "SADDLE_LLM_BASE_URL");
-            if (base_url.length == 0) {
+        public static AdvisorLlmEndpoint? from_config(string base_url, string api_key, string model) {
+            var trimmed = base_url.strip();
+            if (trimmed.length == 0) {
                 return null;
             }
             return new AdvisorLlmEndpoint() {
-                base_url = base_url,
-                api_key = first_non_empty("ADVISOR_LLM_API_KEY", "SADDLE_LLM_API_KEY"),
-                model = first_non_empty("ADVISOR_LLM_MODEL", "SADDLE_LLM_MODEL")
+                base_url = trimmed,
+                api_key = api_key.strip(),
+                model = model.strip()
             };
         }
     }
@@ -96,6 +90,7 @@ namespace ClankerSaddle.Services {
 
         private Container container = inject<Container>();
         private StatePulse pulse = inject<StatePulse>();
+        private ModelConfigService model_config = inject<ModelConfigService>();
         private Soup.Session http = new Soup.Session() {
             timeout = 120,
             idle_timeout = 15
@@ -105,11 +100,9 @@ namespace ClankerSaddle.Services {
         private Invercargill.DataStructures.HashSet<string> running =
             new Invercargill.DataStructures.HashSet<string>();
 
-        /** True when an LLM endpoint resolves from the environment. */
-        public bool configured {
-            get {
-                return AdvisorLlmEndpoint.from_environment() != null;
-            }
+        /** True when an LLM endpoint resolves from the model configuration. */
+        public async bool configured_async() throws Error {
+            return (yield model_config.advisor_endpoint()) != null;
         }
 
         // ------------------------------------------------------------------
@@ -190,12 +183,18 @@ namespace ClankerSaddle.Services {
          * terminal status.
          */
         private async void turn_locked(int64 chat_id, int64 user_id) {
-            var endpoint = AdvisorLlmEndpoint.from_environment();
+            AdvisorLlmEndpoint? endpoint = null;
+            try {
+                endpoint = yield model_config.advisor_endpoint();
+            } catch (Error config_error) {
+                warning("[ClankerSaddle] advisor model configuration read failed: %s",
+                    config_error.message);
+            }
             try {
                 if (endpoint == null) {
                     yield append(chat_id, "error",
-                        "The advisor is not configured — set ADVISOR_LLM_BASE_URL"
-                        + " (or SADDLE_LLM_BASE_URL) to enable it.", null);
+                        "The advisor is not configured — set the model endpoint"
+                        + " on the Admin → Model page to enable it.", null);
                     yield set_status(chat_id, "idle");
                     return;
                 }
@@ -1354,7 +1353,13 @@ namespace ClankerSaddle.Services {
                     + " of " + clamped.length.to_string() + " chars shown] ...\n"
                     + clamped.substring(clamped.length - head);
             }
-            var endpoint = AdvisorLlmEndpoint.from_environment();
+            AdvisorLlmEndpoint? endpoint = null;
+            try {
+                endpoint = yield model_config.advisor_endpoint();
+            } catch (Error config_error) {
+                warning("[ClankerSaddle] advisor model configuration read failed: %s",
+                    config_error.message);
+            }
             if (endpoint == null) {
                 return tool_error_json("no LLM endpoint configured — cannot answer a canon query");
             }

+ 2 - 1
src/Services/AllocationService.vala

@@ -82,6 +82,7 @@ namespace ClankerSaddle.Services {
         private PushService push = inject<PushService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private EventService events = inject<EventService>();
+        private ModelConfigService model_config = inject<ModelConfigService>();
 
         /**
          * Allocates the best offerable task to `saddle`, or null when
@@ -93,7 +94,7 @@ namespace ClankerSaddle.Services {
          */
         public async Json.Object? allocate_next(SaddleService saddles, ProjectService projects,
                 Saddle saddle) throws Error {
-            if (LlmEndpoint.from_environment() == null) {
+            if ((yield model_config.saddle_endpoint()) == null) {
                 // No LLM endpoint configured: assignment stays paused
                 // (the dispatcher logs it once); the daemon sleeps.
                 return null;

+ 4 - 3
src/Services/Dispatcher.vala

@@ -104,7 +104,7 @@ namespace ClankerSaddle.Services {
           * resume_notice). Pending goal events and human comments are
           * delivered to RUNNING daemons via `POST /task-update`, and
           * silent runners get their one exceptional staleness probe.
-          * Wake computation pauses while {@link LlmEndpoint} is
+          * Wake computation pauses while the model endpoint is
           * unconfigured (deliveries and probes continue).
           */
         public async void tick() throws Error {
@@ -114,10 +114,11 @@ namespace ClankerSaddle.Services {
                 var saddles = scope.resolve<SaddleService>();
                 var capabilities = scope.resolve<CapabilityService>();
                 var allocation = scope.resolve<AllocationService>();
+                var model_config = scope.resolve<ModelConfigService>();
 
-                if (LlmEndpoint.from_environment() == null) {
+                if ((yield model_config.saddle_endpoint()) == null) {
                     if (!warned_no_llm) {
-                        warning("[ClankerSaddle] SADDLE_LLM_BASE_URL is not set; task assignment is paused");
+                        warning("[ClankerSaddle] no model endpoint configured; task assignment is paused (Admin → Model page)");
                         warned_no_llm = true;
                     }
                 } else {

+ 126 - 0
src/Services/ModelConfigService.vala

@@ -0,0 +1,126 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The deployment's model configuration (the Admin → Model page):
+     * the ONE source for the OpenAI-compatible endpoint every saddle
+     * assignment is sent to and the advisor's (optionally overridden)
+     * endpoint. Stored as the `model_config` singleton row.
+     *
+     * A SINGLETON (the advisor service — itself a singleton — and the
+     * dispatcher both read it outside request scopes), so every
+     * operation opens its own container scope for the ORM session
+     * (the Dispatcher's create_scope discipline).
+     *
+     * BOOTSTRAP: the very first read on an empty table seeds the row
+     * from the legacy environment (SADDLE_LLM_BASE_URL / _API_KEY /
+     * _MODEL, ADVISOR_LLM_* the same way) so a pre-page deployment
+     * keeps dispatching through its upgrade — once, ever. From then on
+     * the database is the only source: environment changes are
+     * ignored, and the page is where configuration changes land.
+     */
+    public class ModelConfigService : Object {
+
+        private Container container = inject<Container>();
+
+        /**
+         * The configuration row, creating and seeding it from the
+         * legacy environment when the table is still empty (the
+         * one-time upgrade bridge — never again afterwards).
+         */
+        public async ModelConfig current() throws Error {
+            var scope = container.create_scope();
+            try {
+                var db = scope.resolve<OrmSession>();
+                var row = yield db.query<ModelConfig>()
+                    .where(expr("id == $0", elem<int64?>(1)))
+                    .first_async();
+                if (row != null) {
+                    return (!)row;
+                }
+                var seeded = new ModelConfig() {
+                    id = 1,
+                    base_url = legacy_env("SADDLE_LLM_BASE_URL"),
+                    api_key = legacy_env("SADDLE_LLM_API_KEY"),
+                    model = legacy_env("SADDLE_LLM_MODEL"),
+                    advisor_base_url = legacy_env("ADVISOR_LLM_BASE_URL"),
+                    advisor_api_key = legacy_env("ADVISOR_LLM_API_KEY"),
+                    advisor_model = legacy_env("ADVISOR_LLM_MODEL"),
+                    updated_at = new DateTime.now_local()
+                };
+                yield db.insert_async<ModelConfig>(seeded);
+                return seeded;
+            } finally {
+                scope.release();
+            }
+        }
+
+        /** One legacy environment value, trimmed ("" when unset). */
+        private static string legacy_env(string name) {
+            return (Environment.get_variable(name) ?? "").strip();
+        }
+
+        /**
+         * Persists the page's form fields. The two API keys use the
+         * keep-when-blank contract (the page never echoes a key back):
+         * a blank field keeps the stored value unless its explicit
+         * clear checkbox is set.
+         */
+        public async ModelConfig save(string base_url, string api_key, string model,
+                string advisor_base_url, string advisor_api_key, string advisor_model,
+                bool clear_api_key, bool clear_advisor_api_key) throws Error {
+            var row = yield current();
+            row.base_url = base_url.strip();
+            if (api_key.strip().length > 0 || clear_api_key) {
+                row.api_key = api_key.strip();
+            }
+            row.model = model.strip();
+            row.advisor_base_url = advisor_base_url.strip();
+            if (advisor_api_key.strip().length > 0 || clear_advisor_api_key) {
+                row.advisor_api_key = advisor_api_key.strip();
+            }
+            row.advisor_model = advisor_model.strip();
+            row.updated_at = new DateTime.now_local();
+            var scope = container.create_scope();
+            try {
+                var db = scope.resolve<OrmSession>();
+                yield db.update_async<ModelConfig>(row);
+            } finally {
+                scope.release();
+            }
+            return row;
+        }
+
+        /**
+         * The saddle assignments' endpoint, or null while no base URL
+         * is configured (dispatch pauses — the Admin → Model page is
+         * where it gets set).
+         */
+        public async LlmEndpoint? saddle_endpoint() throws Error {
+            var row = yield current();
+            return LlmEndpoint.from_config(
+                row.base_url, row.api_key, row.model);
+        }
+
+        /**
+         * The advisor's endpoint: the advisor-specific override when
+         * its base URL is set, else the main fields; null while
+         * neither carries a base URL (the advisor reports itself
+         * unconfigured).
+         */
+        public async AdvisorLlmEndpoint? advisor_endpoint() throws Error {
+            var row = yield current();
+            if (row.advisor_base_url.length > 0) {
+                return AdvisorLlmEndpoint.from_config(row.advisor_base_url,
+                    row.advisor_api_key, row.advisor_model);
+            }
+            return AdvisorLlmEndpoint.from_config(row.base_url, row.api_key, row.model);
+        }
+    }
+}

+ 91 - 14
src/Services/SaddleService.vala

@@ -94,9 +94,10 @@ namespace ClankerSaddle.Services {
     }
 
     /**
-     * The OpenAI-compatible endpoint every saddle assignment is sent to,
-     * read from the environment: `SADDLE_LLM_BASE_URL` (required — dispatch
-     * pauses without it), `SADDLE_LLM_API_KEY` and `SADDLE_LLM_MODEL`.
+     * The OpenAI-compatible endpoint every saddle assignment is sent
+     * to, read from the deployment's model configuration (the Admin →
+     * Model page's `model_config` row — {@link ModelConfigService});
+     * dispatch pauses while no base URL is configured.
      */
     public class LlmEndpoint : Object {
 
@@ -105,15 +106,15 @@ namespace ClankerSaddle.Services {
         public string model { get; set; default = ""; }
 
         /** The configured endpoint, or null when the base URL is unset. */
-        public static LlmEndpoint? from_environment() {
-            var base_url = (Environment.get_variable("SADDLE_LLM_BASE_URL") ?? "").strip();
-            if (base_url.length == 0) {
+        public static LlmEndpoint? from_config(string base_url, string api_key, string model) {
+            var trimmed = base_url.strip();
+            if (trimmed.length == 0) {
                 return null;
             }
             return new LlmEndpoint() {
-                base_url = base_url,
-                api_key = (Environment.get_variable("SADDLE_LLM_API_KEY") ?? "").strip(),
-                model = (Environment.get_variable("SADDLE_LLM_MODEL") ?? "").strip()
+                base_url = trimmed,
+                api_key = api_key.strip(),
+                model = model.strip()
             };
         }
     }
@@ -167,6 +168,7 @@ namespace ClankerSaddle.Services {
         private UnderstandingService understandings = inject<UnderstandingService>();
         private DiscoveryService discoveries = inject<DiscoveryService>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
+        private ModelConfigService model_config = inject<ModelConfigService>();
 
         /**
          * Registers a saddle container: inserts the row with a freshly
@@ -822,17 +824,17 @@ namespace ClankerSaddle.Services {
          * 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.
+         * `git_url`; the LLM endpoint comes from the deployment's
+         * model configuration ({@link ModelConfigService}).
          */
         public async Json.Object build_assignment_payload(Saddle saddle, TaskEntity task,
                 int64 session_id, ProjectService projects, string? resume_notice = null,
                 bool? branch_advanced = null,
                 string? context_update = null) throws Error {
-            var endpoint = LlmEndpoint.from_environment();
+            var endpoint = yield model_config.saddle_endpoint();
             if (endpoint == null) {
                 throw new Error(Quark.from_string("clanker-saddle"), 24,
-                    "No LLM endpoint configured (set SADDLE_LLM_BASE_URL)");
+                    "No LLM endpoint configured (set it on the Admin → Model page)");
             }
 
             var project = task.project_id > 0
@@ -1086,6 +1088,16 @@ namespace ClankerSaddle.Services {
                 payload.add_boolean_value(true);
             }
 
+            // Goal 28 task D2: `turn_cap_parked` — this task's lineage
+            // already spent its turn-cap checkpoint-park, so a cap hit
+            // in the assigned run is the final hard kill (the daemon
+            // reads the flag into the loop's cap arm). Served on EVERY
+            // assignment of the task, fresh or resumed.
+            if (task.turn_cap_parked) {
+                payload.set_member_name("turn_cap_parked");
+                payload.add_boolean_value(true);
+            }
+
             // The wire's `branch_advanced` (resume pushes): whether the
             // shared branch moved while this task was parked — false or
             // absent means the daemon omits the pull instruction entirely.
@@ -1500,6 +1512,56 @@ namespace ClankerSaddle.Services {
             yield pulse.bump(task_id, "park refused (nothing to wait on)");
         }
 
+        /**
+         * Goal 28 task D2: the wrap-up instruction riding the
+         * turn-cap park's resuming assignment (the one-shot
+         * `pending_resume_notice` channel). Factual, not shaming, and
+         * it MUST instruct wrap-up — the resumed run gets no second
+         * checkpoint-park.
+         */
+        internal const string TURN_CAP_WRAP_UP_NOTICE =
+            "Turn cap park: your previous run hit the harness turn cap without finishing, "
+            + "so it was parked instead of failed. WRAP UP NOW — finish the task: complete "
+            + "remaining commits, push your branch, submit your report with what is done "
+            + "and what remains. Do not start new exploratory work. If this run reaches "
+            + "the turn cap again without finishing, it will be stopped for good.";
+
+        /**
+         * Goal 28 task D2: the checkpoint-park bookkeeping for a
+         * `suspended` push carrying reason `turn_cap` — {@link
+         * TaskService.set_parked} flips the row (status suspended,
+         * sessions closed 'parked', suspended_at stamped), the
+         * wrap-up notice makes it IMMEDIATELY resume-offerable (the
+         * pending_resume_notice lift), and `turn_cap_parked` stamps
+         * the lineage so every later assignment flags the daemon that
+         * the next cap hit is final. The state_change event carries
+         * from/to/text naming the turn cap so the friction miner's
+         * checkpoint-park counter sees it.
+         */
+        private async void park_for_turn_cap(int64 task_id, string summary) throws Error {
+            yield task_service.set_parked(task_id);
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task != null) {
+                ((!)task).turn_cap_parked = true;
+                if (((!)task).pending_resume_notice == null) {
+                    ((!)task).pending_resume_notice = TURN_CAP_WRAP_UP_NOTICE;
+                }
+                yield db.update_async<TaskEntity>((!)task);
+            }
+            var parked = new Json.Object();
+            parked.set_string_member("from", STATUS_WORKING);
+            parked.set_string_member("to", STATUS_SUSPENDED);
+            parked.set_string_member("text", "Turn cap park — "
+                + (summary.length > 0 ? summary : "the run hit the harness turn cap"));
+            yield event_service.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM,
+                EVENT_SCOPE_TASK, task_id, parked);
+            yield task_service.add_system_comment(task_id,
+                "turn cap park: the run parked at the harness turn cap — the wrap-up notice rides the resume");
+            yield pulse.bump(task_id, "turn cap park");
+        }
+
         private async int64 goal_id_of_run(int64 task_id) throws Error {
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(task_id)))
@@ -1601,7 +1663,8 @@ namespace ClankerSaddle.Services {
          * once, ever.
          */
         public async void apply_result_state(ClankerSaddle.Data.Session session,
-                string state, Json.Object? block, string? summary, TaskService tasks) throws Error {
+                string state, Json.Object? block, string? summary, TaskService tasks,
+                string reason = "") throws Error {
             int64 task_id = session.task_id;
             int64 goal_id = yield goal_id_of_run(task_id);
             bool terminal = yield task_is_terminal(task_id);
@@ -1698,6 +1761,20 @@ namespace ClankerSaddle.Services {
                     // remains; busy/idle derive from the sessions).
                     break;
                 case "suspended":
+                    // Goal 28 task D2: the turn-cap checkpoint-park —
+                    // the daemon's FIRST cap hit pushes `suspended`
+                    // with reason `turn_cap` and a summary naming the
+                    // cap. The park mechanics run (status suspended,
+                    // session closed 'parked') PLUS the one-shot
+                    // wrap-up notice makes the task immediately
+                    // resume-offerable (no wake source needed — the
+                    // wrap-up must happen NOW) and the lineage flag
+                    // `turn_cap_parked` rides every later assignment
+                    // so the next cap hit is the final hard kill.
+                    if (reason == "turn_cap") {
+                        yield park_for_turn_cap(task_id, summary ?? "");
+                        break;
+                    }
                     // `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

+ 2 - 1
src/actions/DashboardActions.vala

@@ -20,6 +20,7 @@ namespace ClankerSaddle {
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = inject<PushService>();
         private UserService users = inject<UserService>();
+        private ModelConfigService model_config = inject<ModelConfigService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -30,7 +31,7 @@ namespace ClankerSaddle {
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
                 saddle_service, timeline_service, db, note_service, action_registry,
-                push_service, me != null ? ((!)me).user_id : 0);
+                push_service, me != null ? ((!)me).user_id : 0, model_config);
             return directives().set(state);
         }
     }

+ 68 - 0
src/actions/ModelActions.vala

@@ -0,0 +1,68 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /**
+     * Saves the Admin → Model page's form (the deployment's model
+     * configuration): the main endpoint (base URL, model, API key) and
+     * the advisor's optional overrides. Admin-only. The two API keys
+     * are write-only — a blank field KEEPS the stored value unless its
+     * explicit clear checkbox is set (the page never echoes a key).
+     */
+    public class SaveModelConfigAction : StatumAction {
+
+        private UserService users = inject<UserService>();
+        private ModelConfigService model_config = inject<ModelConfigService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string base_url = Forms.field(request, "base_url");
+            string model = Forms.field(request, "model");
+            string api_key = Forms.field_raw(request, "api_key");
+            string advisor_base_url = Forms.field(request, "advisor_base_url");
+            string advisor_model = Forms.field(request, "advisor_model");
+            string advisor_api_key = Forms.field_raw(request, "advisor_api_key");
+            bool clear_api_key = Forms.field(request, "clear_api_key").length > 0;
+            bool clear_advisor_api_key = Forms.field(request, "clear_advisor_api_key").length > 0;
+
+            if (base_url.length > 0
+                    && !base_url.has_prefix("http://") && !base_url.has_prefix("https://")) {
+                return directives().notify("error",
+                    "The base URL must start with http:// or https:// (or be empty to pause dispatch)")
+                    .navigate("/models");
+            }
+            if (advisor_base_url.length > 0
+                    && !advisor_base_url.has_prefix("http://")
+                    && !advisor_base_url.has_prefix("https://")) {
+                return directives().notify("error",
+                    "The advisor base URL must start with http:// or https:// (or be empty to use the main endpoint)")
+                    .navigate("/models");
+            }
+
+            try {
+                var saved = yield model_config.save(base_url, api_key, model,
+                    advisor_base_url, advisor_api_key, advisor_model,
+                    clear_api_key, clear_advisor_api_key);
+                if (saved.base_url.length == 0) {
+                    return directives().notify("success",
+                        "Model configuration saved — no base URL set, so task assignment stays paused")
+                        .navigate("/models");
+                }
+                return directives().notify("success", "Model configuration saved")
+                    .navigate("/models");
+            } catch (Error e) {
+                return directives().notify("error",
+                    @"Could not save the model configuration: $(e.message)").navigate("/models");
+            }
+        }
+    }
+}

+ 4 - 2
src/actions/NoteActions.vala

@@ -44,6 +44,7 @@ namespace ClankerSaddle {
         protected PushService push_service = inject<PushService>();
         protected NoteService note_service = inject<NoteService>();
         protected UserService users = inject<UserService>();
+        protected ModelConfigService model_config = inject<ModelConfigService>();
 
         /**
          * Rebuilds the dashboard slot from current data — the same
@@ -53,7 +54,7 @@ namespace ClankerSaddle {
             var me = SpryAuth.current(request);
             return yield DashboardState.build(state_service, task_service,
                 saddle_service, timeline_service, db, note_service, action_registry,
-                push_service, me != null ? ((!)me).user_id : 0);
+                push_service, me != null ? ((!)me).user_id : 0, model_config);
         }
     }
 
@@ -73,6 +74,7 @@ namespace ClankerSaddle {
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = inject<PushService>();
         private UserService users = inject<UserService>();
+        private ModelConfigService model_config = inject<ModelConfigService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -90,7 +92,7 @@ namespace ClankerSaddle {
                 var me = SpryAuth.current(request);
                 var state = yield DashboardState.build(state_service, task_service,
                     saddle_service, timeline_service, db, note_service, action_registry,
-                    push_service, me != null ? ((!)me).user_id : 0);
+                    push_service, me != null ? ((!)me).user_id : 0, model_config);
                 return directives().set(state);
             } catch (Error e) {
                 return directives().notify("error", @"Could not add the note: $(e.message)");

+ 2 - 1
src/actions/PushActions.vala

@@ -104,6 +104,7 @@ namespace ClankerSaddle {
         private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
         private PushService push = inject<PushService>();
         private UserService users = inject<UserService>();
+        private ModelConfigService model_config = inject<ModelConfigService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -140,7 +141,7 @@ namespace ClankerSaddle {
                 }
                 var state = yield DashboardState.build(state_service, task_service,
                     saddle_service, timeline_service, db, note_service, action_registry,
-                    push, me.user_id);
+                    push, me.user_id, model_config);
                 return directives().notify(kind, message).set(state);
             } catch (Error e) {
                 return directives().notify("error",

+ 1 - 1
src/entrypoints/ChatDetailEntrypoint.vala

@@ -122,7 +122,7 @@ namespace ClankerSaddle {
                 ActionRegistry? actions, int64 viewer_id, AdvisorChat? chat)
                 throws GLib.Error {
             var pub = new ChatPublic() {
-                advisor_configured = advisor.configured
+                advisor_configured = yield advisor.configured_async()
             };
             PropertyDictionary props;
 

+ 1 - 1
src/entrypoints/ChatsEntrypoint.vala

@@ -68,7 +68,7 @@ namespace ClankerSaddle {
             }
 
             var pub = new ChatsPublic() {
-                advisor_configured = advisor_service.configured
+                advisor_configured = yield advisor_service.configured_async()
             };
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["chats"] = StateJson.list<ChatRowView>(rows.to_immutable_buffer());

+ 19 - 12
src/entrypoints/HomeEntrypoint.vala

@@ -183,7 +183,7 @@ namespace ClankerSaddle {
                 SaddleService saddles, TimelineService timeline,
                 InvercargillSql.Orm.OrmSession db, NoteService notes,
                 ActionRegistry? actions = null, PushService? push = null,
-                int64 viewer_id = 0) throws GLib.Error {
+                int64 viewer_id = 0, ModelConfigService? model_config = null) throws GLib.Error {
             var pub = new DashboardPublic();
             var titles = new Dictionary<string, string>();
             var all_tasks = yield tasks.list_tasks(null, null);
@@ -360,17 +360,23 @@ namespace ClankerSaddle {
                 pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
                 pub.push_test = ((!)actions).author<PushTestAction>();
                 pub.add_note = ((!)actions).author<AddNoteAction>();
-                // The advisor composer (goal 27): an unsealed action —
-                // the chat it creates belongs to the signed-in user
-                // by construction.
-                pub.start_advisor = ((!)actions).author<StartAdvisorChatAction>();
+            // The advisor composer (goal 27): an unsealed action —
+            // the chat it creates belongs to the signed-in user
+            // by construction.
+            pub.start_advisor = ((!)actions).author<StartAdvisorChatAction>();
+            }
+            // Mirrors AdvisorService.configured_async (the same model
+            // configuration resolution) so the composer can disable
+            // itself with a notice when no endpoint is set.
+            if (model_config != null) {
+                pub.advisor_configured =
+                    (yield ((!)model_config).advisor_endpoint()) != null;
+            } else {
+                // A caller that owns no service scope (none today —
+                // every caller passes one) reads it as unconfigured
+                // rather than crashing an empty injection context.
+                pub.advisor_configured = false;
             }
-            // Mirrors AdvisorService.configured (the same environment
-            // resolution, resolved statically so the shared build
-            // needs no new injected dependency) so the composer can
-            // disable itself with a notice when no endpoint is set.
-            pub.advisor_configured =
-                Services.AdvisorLlmEndpoint.from_environment() != null;
             // The canon shelf count (same static resolution shape):
             // a fresh Canon per build is fine — its scan cache is
             // per-instance and the endpoint objects do the same.
@@ -521,6 +527,7 @@ namespace ClankerSaddle {
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = Inversion.inject<PushService>();
         private UserService users = Inversion.inject<UserService>();
+        private ModelConfigService model_config = Inversion.inject<ModelConfigService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -531,7 +538,7 @@ namespace ClankerSaddle {
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
                 saddle_service, timeline_service, db, note_service, action_registry,
-                push_service, me != null ? ((!)me).user_id : 0);
+                push_service, me != null ? ((!)me).user_id : 0, model_config);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 84 - 0
src/entrypoints/ModelSettingsEntrypoint.vala

@@ -0,0 +1,84 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Public data of the `model` slot (the Admin → Model page). */
+    public class ModelSettingsPublic : Object {
+        public string base_url { get; set; default = ""; }
+        public string model { get; set; default = ""; }
+        /** True when an API key is stored (it is never echoed back). */
+        public bool api_key_set { get; set; }
+        /** A masked hint (the last 4 characters) for visual confirmation. */
+        public string api_key_hint { get; set; default = ""; }
+        public string advisor_base_url { get; set; default = ""; }
+        public string advisor_model { get; set; default = ""; }
+        public bool advisor_api_key_set { get; set; }
+        public string advisor_api_key_hint { get; set; default = ""; }
+        /** False while no base URL is configured — dispatch is paused. */
+        public bool dispatch_ready { get; set; }
+        public string updated_display { get; set; default = ""; }
+        public Statum.Model.ActionDto save { get; set; }
+    }
+
+    /**
+     * Entrypoint for /models: the deployment's model configuration —
+     * the OpenAI-compatible endpoint every saddle assignment is sent
+     * to plus the advisor's optional overrides. Admin-only (the
+     * authoritative guard; the nav link is hidden for non-admins but
+     * the guard is what keeps the page closed). The API keys are
+     * write-only: the state carries only whether one is set and a
+     * masked hint, and the save action keeps a blank field's stored
+     * value unless its explicit clear checkbox is set.
+     */
+    public class ModelSettingsEntrypoint : StatumEntrypoint {
+
+        private UserService users = Inversion.inject<UserService>();
+        private ModelConfigService model_config = Inversion.inject<ModelConfigService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var row = yield model_config.current();
+            var pub = new ModelSettingsPublic() {
+                base_url = row.base_url,
+                model = row.model,
+                api_key_set = row.api_key.length > 0,
+                api_key_hint = ModelSettingsEntrypoint.key_hint(row.api_key),
+                advisor_base_url = row.advisor_base_url,
+                advisor_model = row.advisor_model,
+                advisor_api_key_set = row.advisor_api_key.length > 0,
+                advisor_api_key_hint = ModelSettingsEntrypoint.key_hint(row.advisor_api_key),
+                dispatch_ready = row.base_url.strip().length > 0,
+                updated_display = row.updated_at != null
+                    ? format_stamp((!)row.updated_at) : "",
+                save = action_registry.author<SaveModelConfigAction>()
+            };
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "model",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+
+        /** The masked hint: the key's last characters, never the body. */
+        private static string key_hint(string key) {
+            if (key.length <= 4) {
+                return "";
+            }
+            return "…" + key.substring(key.length - 4);
+        }
+    }
+}

+ 6 - 0
src/main.vala

@@ -50,6 +50,8 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0006_Advisor>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0007_ParkingGuard>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0008_AnswerWake>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0009_TurnCapPark>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0010_ModelConfig>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -77,6 +79,7 @@ int main(string[] args) {
         db.add_entity<Stack>(Stack.entity_mapping);
         db.add_entity<StackProject>(StackProject.entity_mapping);
         db.add_entity<Note>(Note.entity_mapping);
+        db.add_entity<ModelConfig>(ModelConfig.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
@@ -99,6 +102,7 @@ int main(string[] args) {
         application.add_singleton<StatePulse>();
         application.add_singleton<PushService>();
         application.add_singleton<Dispatcher>();
+        application.add_singleton<ModelConfigService>();
         application.add_singleton<AdvisorService>();
         application.add_singleton<AdvisorCallback>();
 
@@ -132,6 +136,7 @@ int main(string[] args) {
         statum.add_page<StacksPage, StacksEntrypoint>();
         statum.add_page<StackDetailPage, StackDetailEntrypoint>();
         statum.add_page<UsersPage, UsersEntrypoint>();
+        statum.add_page<ModelSettingsPage, ModelSettingsEntrypoint>();
         statum.add_page<ProfilePage, ProfileEntrypoint>();
         // spry:pages-end
 
@@ -219,6 +224,7 @@ int main(string[] args) {
         statum.action<RevokeUserAdminAction>();
         statum.action<DeleteManagedUserAction>();
         statum.action<ChangeMyPasswordAction>();
+        statum.action<SaveModelConfigAction>();
         // spry:actions-end
 
         // spry:resources-begin

+ 2 - 2
src/pages/chat_detail.html

@@ -141,8 +141,8 @@
                                 stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
                     </form></p>
                 <p class="notice" stm-if="!chat.advisor_configured">The advisor is not
-                    configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
-                    <code>SADDLE_LLM_BASE_URL</code>) to enable it. Your messages wait
+                    configured - set the model endpoint on the <a href="/models">Admin
+                    → Model</a> page to enable it. Your messages wait
                     until then.</p>
             </div>
 

+ 2 - 2
src/pages/chats.html

@@ -12,8 +12,8 @@
             state, ask you questions and suggest tasks or goals, but never runs
             commands itself.</p>
         <p class="notice" stm-if="chats != null && !chats.advisor_configured">The
-            advisor is not configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
-            <code>SADDLE_LLM_BASE_URL</code>) to enable it. Chats you start now
+            advisor is not configured - set the model endpoint on the <a href="/models">Admin
+            → Model</a> page to enable it. Chats you start now
             will wait until then.</p>
 
         <p stm-if="chats == null">Loading…</p>

+ 2 - 2
src/pages/home.html

@@ -272,8 +272,8 @@
                 tasks, goals, rules and discoveries - asks you questions and suggests
                 work. <a href="/chats">Your chats</a></p>
             <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
-                configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
-                <code>SADDLE_LLM_BASE_URL</code>) to enable it.</p>
+                configured - set the model endpoint on the <a href="/models">Admin →
+                Model</a> page to enable it.</p>
             <p class="notice" stm-if="dashboard.canon_texts == 0">Canon unavailable -
                 no canonical texts found. Set <code>CLANKER_CANON_DIR</code> or install
                 the <code>share/clanker-saddle/canon</code> package files.</p>

+ 6 - 3
src/pages/main.html

@@ -39,10 +39,13 @@
                 <summary>Admin</summary>
                 <div class="nav-dropdown">
                     <a href="/saddles">Saddles</a>
-                    <!-- The users link is display-guarded (the page's entrypoint
-                         guard is authoritative); auth.permissions rides the
-                         signed auth slot, so an admin sees it everywhere. -->
+                    <!-- The users and model links are display-guarded
+                         (the pages' entrypoint guards are
+                         authoritative); auth.permissions rides the
+                         signed auth slot, so an admin sees them
+                         everywhere. -->
                     <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/users">Users</a>
+                    <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/models">Model</a>
                 </div>
             </details>
             <!-- spry:nav-end -->

+ 60 - 0
src/pages/model_settings.html

@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/models</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Model</h2>
+        <p class="muted">The deployment's model configuration: the OpenAI-compatible endpoint
+            every saddle assignment is sent to, and the advisor's optional overrides. Stored
+            in the database — environment variables no longer configure it (a pre-page
+            deployment's SADDLE_LLM_* environment is imported once, on first read).</p>
+        <p stm-if="model == null">Loading…</p>
+
+        <div stm-if="model != null">
+            <p stm-if="!model.dispatch_ready"><span class="badge badge-offline">paused</span>
+                <span class="muted">No base URL is configured — task assignment is paused and
+                    the advisor is disabled until one is saved.</span></p>
+            <p stm-if="model.dispatch_ready"><span class="badge badge-done">ready</span>
+                <span class="muted">Updated <span stm-text="model.updated_display">when</span>.</span></p>
+
+            <form stm-action="model.save">
+                <h3>Main endpoint</h3>
+                <p class="muted">Used by every saddle assignment (the harness's LLM).</p>
+                <div class="form-row"><label>Base URL</label>
+                    <input type="text" name="base_url" placeholder="http://127.0.0.1:9999/v1"
+                           stm-attribute.value="model.base_url"></div>
+                <div class="form-row"><label>Model</label>
+                    <input type="text" name="model" placeholder="(the provider's default)"
+                           stm-attribute.value="model.model"></div>
+                <div class="form-row"><label>API key</label>
+                    <input type="password" name="api_key" autocomplete="off"
+                           placeholder="Leave empty to keep the stored key">
+                    <span class="muted" stm-if="model.api_key_set">A key is stored
+                        (<span stm-text="model.api_key_hint">hint</span>)</span>
+                    <label class="inline"><input type="checkbox" name="clear_api_key" value="1"> Clear the stored key</label></div>
+
+                <h3>Advisor endpoint (optional)</h3>
+                <p class="muted">Overrides for the AI Chat advisor. Leave the base URL empty to
+                    use the main endpoint's fields.</p>
+                <div class="form-row"><label>Base URL</label>
+                    <input type="text" name="advisor_base_url" placeholder="(use the main endpoint)"
+                           stm-attribute.value="model.advisor_base_url"></div>
+                <div class="form-row"><label>Model</label>
+                    <input type="text" name="advisor_model" placeholder="(use the main endpoint's model)"
+                           stm-attribute.value="model.advisor_model"></div>
+                <div class="form-row"><label>API key</label>
+                    <input type="password" name="advisor_api_key" autocomplete="off"
+                           placeholder="Leave empty to keep the stored key">
+                    <span class="muted" stm-if="model.advisor_api_key_set">A key is stored
+                        (<span stm-text="model.advisor_api_key_hint">hint</span>)</span>
+                    <label class="inline"><input type="checkbox" name="clear_advisor_api_key" value="1"> Clear the stored key</label></div>
+
+                <button type="submit">Save configuration</button>
+            </form>
+        </div>
+    </section>
+</body>
+</html>

+ 11 - 11
test/bundles/advisor.py

@@ -188,9 +188,10 @@ def run(ctx):
     ])
     llm.serve()
     # The advisor endpoint must be pointed at the scripted LLM: the
-    # app restart below re-reads ADVISOR_LLM_BASE_URL.
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
-    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    # model configuration lives in the model_config row now, so the
+    # restart below re-reads the DATABASE (set_model_config writes it).
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
+                     advisor_model="scripted")
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -299,7 +300,7 @@ def run(ctx):
         {"content": "Noted."},
     ])
     llm2.serve()
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm2.port
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm2.port)
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -339,7 +340,7 @@ def run(ctx):
         {"content": "Two suggestions staged for your approval."},
     ])
     llm3.serve()
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm3.port
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm3.port)
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -391,7 +392,7 @@ def run(ctx):
     llm4 = ScriptedLlm([{"content": "never"}])
     llm4.serve()
     llm4.fail_next = 1
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm4.port
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm4.port)
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -422,7 +423,7 @@ def run(ctx):
                     "arguments": "{}"}]} for index in range(50)
     ])
     llm5.serve()
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm5.port
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm5.port)
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -497,7 +498,6 @@ def run(ctx):
                   (doomed,))[0][0] == 0,
         "delete cascades the messages")
 
-    # Restore the daemon-facing LLM env the other checks may rely on
-    # (the advisor env is test-local).
-    os.environ.pop("ADVISOR_LLM_BASE_URL", None)
-    os.environ.pop("ADVISOR_LLM_MODEL", None)
+    # Restore the daemon-facing model configuration the other checks
+    # may rely on (the advisor override is test-local).
+    set_model_config(advisor_base_url="", advisor_model="")

+ 3 - 4
test/bundles/advisor_arc.py

@@ -45,8 +45,8 @@ def wait_status(chat_id, status, timeout=30):
 
 def restart_with_llm(ctx, llm):
     """Points the app at `llm` and restarts it (env re-read)."""
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
-    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
+                     advisor_model="scripted")
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -389,5 +389,4 @@ def run(ctx):
     # (the advisor env is test-local).
     for killer in (llm, llm2, llm3, llm4, llm5):
         killer.kill()
-    os.environ.pop("ADVISOR_LLM_BASE_URL", None)
-    os.environ.pop("ADVISOR_LLM_MODEL", None)
+    set_model_config(advisor_base_url="", advisor_model="")

+ 14 - 14
test/bundles/advisor_fix.py

@@ -44,9 +44,10 @@ def wait_status(chat_id, status, timeout=30):
 
 
 def restart_with_llm(ctx, llm):
-    """Points the app at `llm` and restarts it (env re-read)."""
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
-    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    """Points the app at `llm` and restarts it (the model_config row
+    is the configuration source — set_model_config writes it)."""
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
+                     advisor_model="scripted")
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -184,11 +185,11 @@ def run(ctx):
     server400 = ThreadingHTTPServer(("127.0.0.1", 0), Handler400)
     threading.Thread(target=server400.serve_forever, daemon=True).start()
     auth = restart_with_llm(ctx, llm400)
-    # Point the env at the raw 400 server AFTER the restart read the
-    # (valid) llm400 for the configured check; the restart below binds
-    # the app to the 400 endpoint.
-    os.environ["ADVISOR_LLM_BASE_URL"] = \
-        "http://127.0.0.1:%d" % server400.server_address[1]
+    # Point the configuration at the raw 400 server AFTER the restart
+    # read the (valid) llm400 for the configured check; the restart
+    # below binds the app to the 400 endpoint.
+    set_model_config(advisor_base_url=
+        "http://127.0.0.1:%d" % server400.server_address[1])
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -342,8 +343,8 @@ def run(ctx):
 
         serverv = ThreadingHTTPServer(("127.0.0.1", 0), HandlerVariant)
         threading.Thread(target=serverv.serve_forever, daemon=True).start()
-        os.environ["ADVISOR_LLM_BASE_URL"] = \
-            "http://127.0.0.1:%d" % serverv.server_address[1]
+        set_model_config(advisor_base_url=
+            "http://127.0.0.1:%d" % serverv.server_address[1])
         ctx.app.terminate()
         try:
             ctx.app.wait(timeout=10)
@@ -424,7 +425,6 @@ def run(ctx):
     llm.kill()
     llmr.kill()
 
-    # Restore the daemon-facing LLM env the other checks may rely on
-    # (the advisor env is test-local).
-    os.environ.pop("ADVISOR_LLM_BASE_URL", None)
-    os.environ.pop("ADVISOR_LLM_MODEL", None)
+    # Restore the daemon-facing model configuration the other checks
+    # may rely on (the advisor override is test-local).
+    set_model_config(advisor_base_url="", advisor_model="")

+ 2 - 2
test/bundles/advisor_refine.py

@@ -31,8 +31,8 @@ from bundles.advisor import (ScriptedLlm, advisor_api, chat_rows,
 
 
 def restart_with_llm(ctx, llm):
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
-    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
+                     advisor_model="scripted")
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)

+ 6 - 6
test/bundles/canon.py

@@ -148,8 +148,8 @@ def run(ctx):
         {"content": "The four freedoms are use, study, share and improve."},
     ])
     llm.serve()
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
-    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
+                     advisor_model="scripted")
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -167,7 +167,7 @@ def run(ctx):
                              "message": "Ground your advice in the canon."})
     check(status == 200, "the turn is accepted")
 
-    check(wait_for(lambda: len(llm.requests) >= 1 or False, timeout=15,
+    check(wait_for(lambda: len(llm.requests) >= 1 or False, timeout=30,
                    what="first advisor LLM call") is not None,
           "the advisor calls the LLM")
     first_request = llm.requests[0]
@@ -183,7 +183,7 @@ def run(ctx):
           "the advisor system prompt carries the ethos section")
 
     # The list tool result rides back to the next request.
-    check(wait_for(lambda: len(llm.requests) >= 2 or False, timeout=15,
+    check(wait_for(lambda: len(llm.requests) >= 2 or False, timeout=30,
                    what="the list result round") is not None,
           "list_canonical_texts triggers the next round")
     second_request = llm.requests[1]
@@ -196,7 +196,7 @@ def run(ctx):
           "list_canonical_texts returns the 15-text library")
 
     # The read tool result: a numbered window with the title riding along.
-    check(wait_for(lambda: len(llm.requests) >= 3 or False, timeout=15,
+    check(wait_for(lambda: len(llm.requests) >= 3 or False, timeout=30,
                    what="the read result round") is not None,
           "read_canonical_text triggers the next round")
     third_request = llm.requests[2]
@@ -218,7 +218,7 @@ def run(ctx):
     # only the answer enters the conversation. Tool calls execute in
     # order, so the request sequence is: list round, read round, query
     # round, the SIDE single-turn call, then the final round.
-    check(wait_for(lambda: len(llm.requests) >= 5 or False, timeout=15,
+    check(wait_for(lambda: len(llm.requests) >= 5 or False, timeout=30,
                    what="the query side call") is not None,
           "query_canonical_text's side call reaches the endpoint")
     side_call = llm.requests[3]

+ 11 - 12
test/bundles/chatui.py

@@ -31,9 +31,10 @@ from bundles.advisor import ScriptedLlm
 
 
 def restart_with_llm(ctx, llm):
-    """Points the app at `llm` and restarts it (env re-read)."""
-    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
-    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    """Points the app at `llm` and restarts it (the model_config row
+    is the configuration source — set_model_config writes it)."""
+    set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
+                     advisor_model="scripted")
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -250,14 +251,12 @@ def run(ctx):
           "the deleted chat left the list")
 
     print("== chatui: the unconfigured hero ==")
-    # The advisor falls back to SADDLE_LLM_*, which start_app always
-    # points at the fake daemon — the honest unconfigured scene pins
-    # BOTH to an unreachable-but-set... no: `configured` only checks
-    # that a base URL RESOLVES. So the real unconfigured state needs
-    # both variables empty, which start_app's env.update forbids.
-    # Drive the last scene with a hand-spawned app instead.
-    llm_env = {name: os.environ.pop(name, None)
-               for name in ("ADVISOR_LLM_BASE_URL", "SADDLE_LLM_BASE_URL")}
+    # The configuration lives in the model_config row, so the honest
+    # unconfigured scene empties it (both the main and the advisor
+    # override) — the hand-spawned app below then reads nothing.
+    set_model_config(base_url="", model="", advisor_base_url="",
+                     advisor_model="")
+    llm_env = {}
     ctx.app.terminate()
     try:
         ctx.app.wait(timeout=10)
@@ -289,7 +288,7 @@ def run(ctx):
           "the dashboard reports the advisor unconfigured")
     home_html = http_get_page(ctx.auth, "/")
     check("advisor is not" in home_html.lower()
-          or "ADVISOR_LLM_BASE_URL" in home_html,
+          or "/models" in home_html,
           "the unconfigured hero renders its notice")
     unconfigured_chats = slot_public(ctx.auth, "/chats", "chats")
     check(unconfigured_chats.get("advisor_configured") is False,

+ 125 - 0
test/bundles/modelconfig.py

@@ -0,0 +1,125 @@
+"""The model configuration bundle (Admin → Model page): the OpenAI
+endpoint every saddle assignment is sent to lives in the model_config
+row — environment variables no longer configure it (the harness's
+SADDLE_LLM_BASE_URL seeds the row once, on the app's first read; the
+page's save action owns it afterwards).
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    print("== model configuration: the Admin → Model page ==")
+    fake.ask_on_register = False
+    fake.hold_state = True
+
+    # The bootstrap seed: the app's first configuration read imported
+    # the harness's SADDLE_LLM_BASE_URL environment into the row.
+    model = slot_public(auth, "/models", "model")
+    check(model.get("dispatch_ready") is True,
+          "the page reports dispatch ready (the env bootstrap seeded the row)")
+    check(model.get("base_url", "").startswith("http://"),
+          "the seeded base URL is visible on the page")
+
+    # The save action rewrites the row: the next assignment carries the
+    # DATABASE's endpoint, not the environment's.
+    task_id = create_queued(auth, {
+        "project_id": "1", "title": "Model config round trip",
+        "priority": "2"})
+    set_model_config(base_url="http://127.0.0.1:9", model="db-model",
+                     api_key="db-key-12345678")
+    ask_status, assignment = fake.ask_now()
+    check(ask_status == 200 and int(assignment["task"]["id"]) == task_id,
+          "the ask claims the task under the database configuration")
+    llm = assignment.get("llm_config") or {}
+    check(llm.get("base_url") == "http://127.0.0.1:9"
+          and llm.get("model") == "db-model"
+          and llm.get("api_key") == "db-key-12345678",
+          "the assignment's llm_config carries the database endpoint")
+    # close the round so the fake saddle is not left busy (a busy
+    # saddle's ask 204s for its OWN open session, which would mask the
+    # configuration gate the later checks isolate)
+    fake.state = "done"
+    fake.push_results("done", summary="# done",
+                      session_id=assignment.get("session_id"))
+    wait_for(lambda: task_state(auth, task_id).get("status") == "done"
+             or False, timeout=30, what="model config task done")
+
+    # The page's own state reflects the row (the key is never echoed —
+    # only that one is set plus a masked hint).
+    model = slot_public(auth, "/models", "model")
+    check(model.get("base_url") == "http://127.0.0.1:9"
+          and model.get("model") == "db-model",
+          "the page shows the saved endpoint fields")
+    check(model.get("api_key_set") is True
+          and "db-key-12345678" not in json.dumps(model),
+          "the page never echoes the stored key (set flag + masked hint only)")
+    check(model.get("api_key_hint", "").endswith("5678")
+          and len(model.get("api_key_hint", "")) <= 5,
+          "the masked hint carries only the key's tail")
+
+    # A save with a BLANK api key keeps the stored one (the page's
+    # write-only contract); a new base URL and model still land.
+    model = slot_public(auth, "/models", "model")
+    act(auth, model["save"], {
+        "base_url": "http://127.0.0.1:10", "model": "db-model-2",
+        "api_key": "", "advisor_base_url": "", "advisor_model": "",
+        "advisor_api_key": ""})
+    conn = db()
+    row = conn.execute(
+        "select base_url, model, api_key from model_config where id = 1").fetchone()
+    conn.close()
+    check(row is not None and row[0] == "http://127.0.0.1:10" and row[1] == "db-model-2",
+          "the save action persists the new endpoint fields")
+    check(row is not None and row[2] == "db-key-12345678",
+          "a blank key field KEEPS the stored key")
+
+    # The advisor override resolves independently: an advisor base URL
+    # set through the page overrides, empty falls back to the main
+    # fields (observable through the advisor-configured flags).
+    set_model_config(advisor_base_url="http://127.0.0.1:11", advisor_model="adv")
+    model = slot_public(auth, "/models", "model")
+    check(model.get("advisor_base_url") == "http://127.0.0.1:11"
+          and model.get("advisor_model") == "adv",
+          "the advisor override shows on the page")
+    set_model_config(advisor_base_url="")
+    model = slot_public(auth, "/models", "model")
+    check(model.get("advisor_base_url") == "",
+          "clearing the advisor override falls back to the main endpoint")
+
+    # Validation: a non-http base URL is refused and keeps the row.
+    model = slot_public(auth, "/models", "model")
+    directives = act(auth, model["save"], {
+        "base_url": "ftp://nope", "model": "", "api_key": "",
+        "advisor_base_url": "", "advisor_model": "", "advisor_api_key": ""})
+    notified = json.dumps(directives)
+    check("http://" in notified or "error" in notified,
+          "a non-http base URL is rejected with a notice")
+    conn = db()
+    row = conn.execute("select base_url from model_config where id = 1").fetchone()
+    conn.close()
+    check(row is not None and row[0] == "http://127.0.0.1:10",
+          "the rejected save left the stored configuration untouched")
+
+    # Emptying the base URL pauses dispatch: a fresh queued task finds
+    # no taker while no model endpoint is configured.
+    set_model_config(base_url="")
+    model = slot_public(auth, "/models", "model")
+    check(model.get("dispatch_ready") is False,
+          "the page reports dispatch paused with no base URL")
+    paused_task = create_queued(auth, {
+        "project_id": "1", "title": "Paused until an endpoint exists",
+        "priority": "1"})
+    ask_status, assignment = fake.ask_now()
+    check(ask_status == 204,
+          "the ask returns 204 while no model endpoint is configured")
+    # ...and saving one through the PAGE lifts the pause.
+    model = slot_public(auth, "/models", "model")
+    act(auth, model["save"], {
+        "base_url": "http://127.0.0.1:12", "model": "db-model-3",
+        "api_key": "", "advisor_base_url": "", "advisor_model": "",
+        "advisor_api_key": ""})
+    ask_status, assignment = fake.ask_now()
+    check(ask_status == 200 and int(assignment["task"]["id"]) == paused_task,
+          "saving an endpoint through the page lifts the dispatch pause")

+ 6 - 5
test/bundles/pages.py

@@ -920,12 +920,13 @@ def run(ctx):
         "select name from sqlite_master"
         " where type = 'index' and tbl_name = 'events'").fetchall()}
     conn.close()
-    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7],
-          "the clanker-saddle namespace carries exactly serials 1 through 7"
+    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
+          "the clanker-saddle namespace carries exactly serials 1 through 10"
           " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
-          " M0004 understandings, M0005 discoveries, M0006 advisor and"
-          " M0007 the parking guard)")
-    check(len(serials) == 7,
+          " M0004 understandings, M0005 discoveries, M0006 advisor,"
+          " M0007 the parking guard, M0008 answer-wake, M0009 the"
+          " turn-cap park and M0010 the model config)")
+    check(len(serials) == 10,
           "no duplicate migration rows (each guarded up() applied once)")
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
           "M0003's event reader indexes exist (fresh boot)")

+ 88 - 0
test/bundles/turncappark.py

@@ -0,0 +1,88 @@
+"""The turn-cap checkpoint-park bundle (goal 28 task D2).
+
+The daemon's FIRST turn-cap hit pushes `suspended` with reason
+`turn_cap`; the web parks the task with the one-shot wrap-up notice
+(immediately resume-offerable — no wake source needed) and stamps
+`turn_cap_parked` so the resuming assignment carries the flag. The
+next cap hit arrives as the ordinary failed runaway push and ends the
+task for good.
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    print("== goal 28 D2: the turn-cap checkpoint-park ==")
+    fake.ask_on_register = False
+    fake.hold_state = True
+
+    task_id = create_queued(auth, {
+        "project_id": "1", "title": "Runaway: parks at the first turn cap",
+        "priority": "2"})
+    ask_status, assignment = fake.ask_now()
+    check(ask_status == 200 and int(assignment["task"]["id"]) == task_id,
+          "the ask claims the queued task")
+    fake.state = "working"
+    check(fake.push_results("working") == 200, "the working push opens the round")
+
+    # The first cap hit: a suspended transition carrying reason turn_cap
+    # (the summary names the cap the way the daemon words it).
+    fake.state = "suspended"
+    check(fake.push_results("suspended",
+                            summary="turn cap park: 400 assistant turns without "
+                                    "reaching a terminal state (cap 400) — parked "
+                                    "for wrap-up, not failed",
+                            reason="turn_cap") == 200,
+          "the turn-cap park transition lands")
+
+    conn = db()
+    row = conn.execute(
+        "select status, pending_resume_notice, turn_cap_parked from tasks where id = ?",
+        (task_id,)).fetchone()
+    check(row is not None and row[0] == "suspended",
+          "the task row parks suspended (not failed)")
+    check(row is not None and row[1] is not None and "WRAP UP NOW" in row[1]
+          and "stopped for good" in row[1],
+          "the one-shot wrap-up notice is set on the row")
+    check(row is not None and row[2] in (1, True),
+          "the lineage flag turn_cap_parked is stamped")
+
+    # The park is IMMEDIATELY resume-offerable (the notice is the lift):
+    # the very next ask hands the SAME task back with the wrap-up as its
+    # resume_notice and the turn_cap_parked flag riding the assignment.
+    resume_status, resumed = fake.ask_now()
+    check(resume_status == 200 and int(resumed["task"]["id"]) == task_id,
+          "the parked task is immediately offerable again")
+    check("Turn cap park" in (resumed.get("resume_notice") or ""),
+          "the wrap-up notice rides the resuming assignment")
+    check(resumed.get("turn_cap_parked") is True,
+          "the assignment carries turn_cap_parked")
+
+    # The notice is one-shot: consumed by the allocating assignment.
+    conn = db()
+    row = conn.execute(
+        "select pending_resume_notice from tasks where id = ?", (task_id,)).fetchone()
+    check(row is not None and row[0] is None,
+          "the one-shot notice is consumed by the allocation")
+    # The lineage flag SURVIVES the resume (a second cap hit must still
+    # be final), and rides the fresh working round's next assignment.
+    conn = db()
+    row = conn.execute(
+        "select turn_cap_parked from tasks where id = ?", (task_id,)).fetchone()
+    check(row is not None and row[0] in (1, True),
+          "the lineage flag survives the resume")
+
+    # The resumed run reaches the cap again: the ordinary failed runaway
+    # push — with turn_cap_parked on the row, this is the final kill.
+    fake.state = "working"
+    check(fake.push_results("working") == 200, "the resumed round opens")
+    fake.state = "failed"
+    check(fake.push_results("failed",
+                            summary="runaway session: a resumed run reached the turn "
+                                    "cap again — 400 assistant turns without a terminal "
+                                    "state, cap 400 (SADDLE_TURN_CAP overrides it), "
+                                    "aborting like the degeneration path") == 200,
+          "the second cap hit's failed push lands")
+    wait_for(lambda: task_state(auth, task_id).get("status") == "failed"
+             or False, timeout=30, what="the runaway task failed for good")

+ 28 - 1
test/harness.py

@@ -414,6 +414,25 @@ def db():
     return sqlite3.connect(DB_PATH)
 
 
+def set_model_config(**fields):
+    """Writes fields of the model_config singleton row (the Admin →
+    Model page's storage) — the env-var config is GONE, so the bundles
+    that used to point the advisor at a scripted LLM via
+    ADVISOR_LLM_BASE_URL + an app restart now write the row instead.
+    Only touches the named fields; the row is created first when the
+    shard's app has not read its configuration yet."""
+    conn = db()
+    try:
+        row = conn.execute("select 1 from model_config where id = 1").fetchone()
+        if row is None:
+            conn.execute("insert into model_config (id) values (1)")
+        for name, value in fields.items():
+            conn.execute("update model_config set %s = ? where id = 1" % name, (value,))
+        conn.commit()
+    finally:
+        conn.close()
+
+
 def http_get_page(auth, uri):
     """The compiled page HTML for a statum route (auth via the slot
     header), for asserting page-level embeds the slot state cannot
@@ -612,7 +631,8 @@ class FakeSaddle:
         self._push_seq = getattr(self, "_push_seq", 0) + 1
         return self._push_seq
 
-    def push_results(self, state, entries=None, block=None, summary=None, session_id=None):
+    def push_results(self, state, entries=None, block=None, summary=None, session_id=None,
+                     reason=None):
         """Pushes one round-results payload home (state transition plus
         its entries). Returns the HTTP status when it went out, None
         when suppressed."""
@@ -632,6 +652,8 @@ class FakeSaddle:
                 self.pending_block = dict(block)
         if summary is not None:
             payload["summary"] = summary
+        if reason is not None:
+            payload["reason"] = reason
         try:
             status, _ = saddle_api("POST", "/api/saddle/results", self.token, payload)
             if status != 200:
@@ -1046,6 +1068,10 @@ def start_app():
         "SPRY_DB_PATH": DB_PATH,
         "SPRY_SEED_ADMIN_USERNAME": "admin",
         "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
+        # The one-time model-config bootstrap: the app seeds the
+        # model_config row from this env on its FIRST read (the row
+        # is the live configuration afterwards — the Admin → Model
+        # page owns it).
         "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
         "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
         "SADDLE_HTTP_TIMEOUT_S": "2",
@@ -1335,6 +1361,7 @@ def start_bundle(name):
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "answerwake", "ghostworking",
+                 "turncappark", "modelconfig",
                  "push",
                  "goals", "replay", "goalui", "forcewake", "users", "stacks",
                  "notes", "resurrect", "origins", "parity", "understanding",

+ 3 - 1
test/integration_test.py

@@ -47,7 +47,9 @@ call, questions ride `saddle block` through run_command):
 Prerequisites (see README.md "Integration testing"): the web app
 running with SPRY_SEED_ADMIN_USERNAME=admin,
 SPRY_SEED_ADMIN_PASSWORD=adminpass123, SADDLE_LLM_BASE_URL pointing
-at the mock, SADDLE_PROJECTS_ROOT=$WORKSPACE and a FRESH database
+at the mock (the one-time model-config bootstrap seed — the database
+row it creates is the live configuration), SADDLE_PROJECTS_ROOT=$WORKSPACE
+and a FRESH database
 (the harness asserts an empty tasks/goals state); a saddle daemon
 with SADDLE_WORKSPACE_ROOT=$WORKSPACE (the shared-volume deployment
 assumption — the goal clone anchors the worker worktrees); a git