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

Merge branch 'task/8-37' into goal/toto-batch-1

saddle-Clanker-Saddle 1 месяц назад
Родитель
Сommit
0c0414d46c

+ 37 - 21
README.md

@@ -63,7 +63,7 @@ Orchestration behaviour to know:
 | `SADDLE_LLM_API_KEY` / `SADDLE_LLM_MODEL` | Optional credentials/model for the above |
 | `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_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_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` | Checkouts are addressed as `root/<project-name>` inside the saddle container (default `/workspace`) |
+| `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* |
 
 
 ## Quick start (local)
 ## Quick start (local)
 
 
@@ -131,7 +131,6 @@ podman run -d --name clanker-web --userns=keep-id --network clanker-net \
     -p 8080:8080 -v "$PWD/data:/var/lib/clanker-saddle:Z" \
     -p 8080:8080 -v "$PWD/data:/var/lib/clanker-saddle:Z" \
     -e SPRY_SEED_ADMIN_USERNAME=admin -e SPRY_SEED_ADMIN_PASSWORD=change-me \
     -e SPRY_SEED_ADMIN_USERNAME=admin -e SPRY_SEED_ADMIN_PASSWORD=change-me \
     -e SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 \
     -e SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 \
-    -e SADDLE_PROJECTS_ROOT=/workspace \
     -e SADDLE_CALLBACK_BASE_URL=http://clanker-web:8080 \
     -e SADDLE_CALLBACK_BASE_URL=http://clanker-web:8080 \
     localhost/clanker-saddle:0.1
     localhost/clanker-saddle:0.1
 podman run -d --name clanker-saddle --userns=keep-id --network clanker-net \
 podman run -d --name clanker-saddle --userns=keep-id --network clanker-net \
@@ -147,8 +146,11 @@ saddle container via `podman exec -d clanker-saddle …`, reachable at
 `web-config.json` `saddle_callback_base_url` key) must be the app's
 `web-config.json` `saddle_callback_base_url` key) must be the app's
 address **as the daemon dials it** — every registered daemon pushes
 address **as the daemon dials it** — every registered daemon pushes
 telemetry, task callbacks and notes there. Put the SQLite store on a volume
 telemetry, task callbacks and notes there. Put the SQLite store on a volume
-(`SPRY_DB_PATH` defaults to `/var/lib/clanker-saddle/clanker-saddle.sqlite`)
-and mount git checkouts under `/workspace`. On SELinux hosts the bind
+(`SPRY_DB_PATH` defaults to `/var/lib/clanker-saddle/clanker-saddle.sqlite`).
+The saddle daemon clones into its own `/workspace` volume and reports
+that root at registration, so the web container needs NO workspace mount;
+`SADDLE_PROJECTS_ROOT` on the web side is only the fallback for daemons
+that do not report one. On SELinux hosts the bind
 mounts need `:Z`, and rootless podman needs `--userns=keep-id` for the
 mounts need `:Z`, and rootless podman needs `--userns=keep-id` for the
 mounts to be writable. For durable sessions mount `web-config.json`
 mounts to be writable. For durable sessions mount `web-config.json`
 read-only and point `ASTRALIS_CONFIG_PATH` at it; otherwise the app runs
 read-only and point `ASTRALIS_CONFIG_PATH` at it; otherwise the app runs
@@ -163,15 +165,17 @@ for the full guide): a two-line Containerfile `FROM` the loaded base
 plus `dnf install` of your tools (C, Vala, GTK, Node, .NET, Android,
 plus `dnf install` of your tools (C, Vala, GTK, Node, .NET, Android,
 QEMU — anything), then tag the capability names you gave those tools in
 QEMU — anything), then tag the capability names you gave those tools in
 the web UI's saddle page so only tasks requiring them route there.
 the web UI's saddle page so only tasks requiring them route there.
-Goal task worktrees need every container that may run a goal's tasks to
-share the `/workspace` volume.
+Each saddle mounts its OWN workspace volume: goal task worktrees are cut
+from `origin/<goal branch>` and merges travel through origin, so no two
+saddles ever need to share a filesystem (see *Deployment assumptions*).
 
 
 ## Creating your first project and task
 ## Creating your first project and task
 
 
 1. **Project** — `/projects` → name, git URL, default branch. The
 1. **Project** — `/projects` → name, git URL, default branch. The
-   checkout must exist at `$SADDLE_PROJECTS_ROOT/<name>` on the saddle's
-   filesystem (clone it into the workspace volume; the agent does not
-   clone). Link related projects as *co-developed* (agent may modify) or
+   saddle daemon clones the repo itself into its own workspace; the
+   paths the web app puts in assignments are ADVISORY and are rewritten
+   onto the asking saddle's filesystem (the daemon owns its volumes —
+   the web needs no access to them). Link related projects as *co-developed* (agent may modify) or
    *reference* (read-only) from the project page.
    *reference* (read-only) from the project page.
 2. **Task** — `/tasks` → project, title, **mandate** (the description),
 2. **Task** — `/tasks` → project, title, **mandate** (the description),
    mode (implement / investigate / review / test) and priority (1 is
    mode (implement / investigate / review / test) and priority (1 is
@@ -440,9 +444,9 @@ truth. The harness-prepared `demo` repo and its bare origin both live
 on branch `main` (the project registration's default branch matches).
 on branch `main` (the project registration's default branch matches).
 
 
 The daemon's `SADDLE_WORKSPACE_ROOT` and the app's
 The daemon's `SADDLE_WORKSPACE_ROOT` and the app's
-`SADDLE_PROJECTS_ROOT` point at the SAME directory (the goal clone the
-daemon cuts must anchor the worker worktrees — the shared-volume
-deployment assumption). For a container run point `WEB`/`SADDLE` at the
+`SADDLE_PROJECTS_ROOT` may point at the same directory for a local run
+(the daemon reports its root at registration, and the app's value is
+only the fallback for daemons that do not). For a container run point `WEB`/`SADDLE` at the
 published ports, pass `SADDLE_HOST=clanker-saddle:7100`, the container's
 published ports, pass `SADDLE_HOST=clanker-saddle:7100`, the container's
 SQLite file as `DB_PATH`, and start the in-container mock via
 SQLite file as `DB_PATH`, and start the in-container mock via
 `MOCK_CMD='podman exec -d clanker-saddle python3
 `MOCK_CMD='podman exec -d clanker-saddle python3
@@ -491,15 +495,27 @@ HTTP/2 removes the cap entirely.
 
 
 ## Deployment assumptions (goal era)
 ## Deployment assumptions (goal era)
 
 
-- **Shared workspace volume across saddles.** A goal's clone workspace
-  (`<root>/goal-<id>`, one clone per involved repo on the goal branch)
-  anchors every task worktree (`<root>/task-<task-id>`, ephemeral
-  branch `task/<goal-id>-<task-id>`). Whichever saddle runs a goal task
-  must see the same `SADDLE_WORKSPACE_ROOT` volume as the saddle that
-  runs the goal agent — today the fleet is single-saddle per goal by
-  construction (one goal agent, one worker at a time on the one
-  registered saddle); multi-saddle fleets must mount the same volume
-  everywhere before fanning goal work out.
+- **Per-saddle workspace volumes; git is the coordination medium.** A
+  goal's clone workspace (`<root>/goal-<id>`, one clone per involved
+  repo on the goal branch) lives on whichever saddle runs the goal
+  agent; goal task worktrees (`<root>/task-<task-id>`, ephemeral branch
+  `task/<goal-id>-<task-id>`) are cut from `origin/<goal branch>` — not
+  the local clone — so a worker on ANOTHER saddle (its own volume)
+  starts from the fleet's pushed tip. At every workspace adoption the
+  daemon fetches origin and surfaces a loud *unpulled changes* notice
+  per repo left behind, instructing `git pull --rebase`. The goal agent
+  merges finished task branches via origin (`git fetch origin
+  task/<id>` + `git merge origin/task/<id>`), so a branch that only
+  exists on another saddle merges exactly like a local one. Consequence:
+  every project involved in a goal needs a pushable origin (deploy
+  keys), and saddles mount their own `SADDLE_WORKSPACE_ROOT` — no
+  shared `/workspace` anywhere.
+- **The web app has NO workspace access.** Assignment project paths are
+  advisory: they are built from the ASKING saddle's registered
+  `workspace_root` (its daemon reports `SADDLE_WORKSPACE_ROOT` at
+  `POST /register`; the saddles page shows it), falling back to
+  `SADDLE_PROJECTS_ROOT`, then the historical `/workspace`. The daemon
+  owns its filesystem and rewrites paths onto its own root.
 - **Database tear-down-and-recreate.** The schema is a single flattened
 - **Database tear-down-and-recreate.** The schema is a single flattened
   `M0001_Initial` (goals with links/comments, the flattened tasks, and
   `M0001_Initial` (goals with links/comments, the flattened tasks, and
   the plan-16 deletions already applied: no `api_tokens`, no
   the plan-16 deletions already applied: no `api_tokens`, no

+ 9 - 1
src/Data/Entities.vala

@@ -284,6 +284,13 @@ namespace ClankerSaddle.Data {
         public string? prompt_note { get; set; default = null; }
         public string? prompt_note { get; set; default = null; }
         public string? ssh_public_key { get; set; default = null; }
         public string? ssh_public_key { get; set; default = null; }
         public string? token { get; set; default = null; }
         public string? token { get; set; default = null; }
+        /**
+         * The workspace root this saddle's daemon reported at
+         * /register (its SADDLE_WORKSPACE_ROOT), or null for daemons
+         * that predate the field — assignment paths fall back to
+         * SADDLE_PROJECTS_ROOT, then "/workspace".
+         */
+        public string? workspace_root { get; set; default = null; }
 
 
         public static void entity_mapping(EntityMapperBuilder<Saddle> cfg) {
         public static void entity_mapping(EntityMapperBuilder<Saddle> cfg) {
             cfg.table("saddles")
             cfg.table("saddles")
@@ -295,7 +302,8 @@ namespace ClankerSaddle.Data {
                 .column<string>("git_email", o => o.git_email, (o, v) => o.git_email = v)
                 .column<string>("git_email", o => o.git_email, (o, v) => o.git_email = v)
                 .column<string>("prompt_note", o => o.prompt_note, (o, v) => o.prompt_note = v)
                 .column<string>("prompt_note", o => o.prompt_note, (o, v) => o.prompt_note = v)
                 .column<string>("ssh_public_key", o => o.ssh_public_key, (o, v) => o.ssh_public_key = v)
                 .column<string>("ssh_public_key", o => o.ssh_public_key, (o, v) => o.ssh_public_key = v)
-                .column<string>("token", o => o.token, (o, v) => o.token = v);
+                .column<string>("token", o => o.token, (o, v) => o.token = v)
+                .column<string>("workspace_root", o => o.workspace_root, (o, v) => o.workspace_root = v);
         }
         }
     }
     }
 
 

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

@@ -234,6 +234,10 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("prompt_note");
                 t.column<string>("prompt_note");
                 t.column<string>("ssh_public_key");
                 t.column<string>("ssh_public_key");
                 t.column<string>("token");
                 t.column<string>("token");
+                // goal 8 task 6: the workspace root the daemon reported at
+                // /register — assignment paths are addressed on the ASKING
+                // saddle's filesystem, not a shared /workspace default
+                t.column<string>("workspace_root");
             });
             });
 
 
             b.create_table("sessions", (t) => {
             b.create_table("sessions", (t) => {

+ 35 - 7
src/Services/SaddleService.vala

@@ -198,8 +198,17 @@ namespace ClankerSaddle.Services {
                 var response = yield request_saddle_json(saddle.host, "POST", "/register",
                 var response = yield request_saddle_json(saddle.host, "POST", "/register",
                     (owned) payload);
                     (owned) payload);
                 string reported_key = EntryJson.string_member(response, "ssh_public_key");
                 string reported_key = EntryJson.string_member(response, "ssh_public_key");
-                if (reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? "")) {
-                    saddle.ssh_public_key = reported_key;
+                // goal 8 task 6: the daemon reports the workspace root it
+                // mounts so assignment paths address the ASKING saddle's
+                // filesystem; absent on older daemons (kept null → the
+                // SADDLE_PROJECTS_ROOT fallback chain)
+                string reported_root = EntryJson.string_member(response, "workspace_root");
+                if ((reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? ""))
+                        || reported_root != (saddle.workspace_root ?? "")) {
+                    if (reported_key.length > 0) {
+                        saddle.ssh_public_key = reported_key;
+                    }
+                    saddle.workspace_root = reported_root.length > 0 ? reported_root : null;
                     yield db.update_async<Saddle>(saddle);
                     yield db.update_async<Saddle>(saddle);
                 }
                 }
                 print(@"[ClankerSaddle] Registered with saddle '$(saddle.name)'s daemon (token delivered)\n");
                 print(@"[ClankerSaddle] Registered with saddle '$(saddle.name)'s daemon (token delivered)\n");
@@ -887,16 +896,16 @@ namespace ClankerSaddle.Services {
             payload.set_member_name("co_developed");
             payload.set_member_name("co_developed");
             payload.begin_array();
             payload.begin_array();
             if (project != null) {
             if (project != null) {
-                payload.add_value(project_node(((!)project), task.id));
+                payload.add_value(project_node(((!)project), task.id, false, saddle));
             }
             }
             foreach (var linked in co_developed) {
             foreach (var linked in co_developed) {
-                payload.add_value(project_node(linked, task.id));
+                payload.add_value(project_node(linked, task.id, false, saddle));
             }
             }
             payload.end_array();
             payload.end_array();
             payload.set_member_name("reference");
             payload.set_member_name("reference");
             payload.begin_array();
             payload.begin_array();
             foreach (var linked in references) {
             foreach (var linked in references) {
-                payload.add_value(project_node(linked, task.id, true));
+                payload.add_value(project_node(linked, task.id, true, saddle));
             }
             }
             payload.end_array();
             payload.end_array();
 
 
@@ -1684,9 +1693,28 @@ namespace ClankerSaddle.Services {
          * saddle's container — one workspace per task, so concurrent
          * saddle's container — one workspace per task, so concurrent
          * tasks never share a checkout.
          * tasks never share a checkout.
          */
          */
-        private static Json.Node project_node(Project project, int64 task_id, bool is_reference = false) {
+        /**
+         * The projects root assignment paths are addressed against: the
+         * ASKING saddle's registered `workspace_root` (the daemon's own
+         * SADDLE_WORKSPACE_ROOT, reported at /register — goal 8 task 6,
+         * so a fleet no longer assumes one shared /workspace mount),
+         * else the `SADDLE_PROJECTS_ROOT` environment override, else
+         * the historical "/workspace" default. Paths are ADVISORY: the
+         * daemon owns its filesystem and rewrites them onto its own
+         * workspace root.
+         */
+        private static string projects_root_for_saddle(Saddle? saddle) {
+            var registered = saddle != null ? ((!)saddle).workspace_root : null;
+            if (registered != null && ((!)registered).strip().length > 0) {
+                return ((!)registered).strip();
+            }
             var root_var = (Environment.get_variable("SADDLE_PROJECTS_ROOT") ?? "").strip();
             var root_var = (Environment.get_variable("SADDLE_PROJECTS_ROOT") ?? "").strip();
-            string projects_root = root_var.length > 0 ? root_var : "/workspace";
+            return root_var.length > 0 ? root_var : "/workspace";
+        }
+
+        private static Json.Node project_node(Project project, int64 task_id, bool is_reference = false,
+                Saddle? saddle = null) {
+            string projects_root = projects_root_for_saddle(saddle);
             var builder = new Json.Builder();
             var builder = new Json.Builder();
             builder.begin_object();
             builder.begin_object();
             builder.set_member_name("name");
             builder.set_member_name("name");

+ 3 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -41,6 +41,8 @@ namespace ClankerSaddle {
         public string git_name { get; set; default = ""; }
         public string git_name { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string ssh_public_key { get; set; default = ""; }
         public string ssh_public_key { get; set; default = ""; }
+        /** The workspace root the daemon reported at /register (advisory). */
+        public string workspace_root { get; set; default = ""; }
         public Statum.Model.ActionDto? update_identity { get; set; }
         public Statum.Model.ActionDto? update_identity { get; set; }
         /** The admin's standing prompt note about THIS machine (goal 8 task 5). */
         /** The admin's standing prompt note about THIS machine (goal 8 task 5). */
         public string prompt_note { get; set; default = ""; }
         public string prompt_note { get; set; default = ""; }
@@ -184,6 +186,7 @@ namespace ClankerSaddle {
                     git_name = saddle.git_name ?? "",
                     git_name = saddle.git_name ?? "",
                     git_email = saddle.git_email ?? "",
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
+                    workspace_root = saddle.workspace_root ?? "",
                     update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
                     update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
                     prompt_note = saddle.prompt_note ?? "",
                     prompt_note = saddle.prompt_note ?? "",
                     update_prompt_note = action_registry.author_private<UpdateSaddlePromptNoteAction, SaddleActionPrivate>(sealed),
                     update_prompt_note = action_registry.author_private<UpdateSaddlePromptNoteAction, SaddleActionPrivate>(sealed),

+ 3 - 2
test/bundles/__init__.py

@@ -4,8 +4,8 @@ the modules' star-imported runtime globals are the shard's own). The
 order here is the parallel runner's shard order."""
 order here is the parallel runner's shard order."""
 
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
-               lifecycle, notes, pages, push, replay, resurrect, stacks,
-               subtasks, users)
+               lifecycle, notes, origins, pages, push, replay, resurrect,
+               stacks, subtasks, users)
 
 
 BUNDLES = {
 BUNDLES = {
     "pages": pages,
     "pages": pages,
@@ -21,6 +21,7 @@ BUNDLES = {
     "goalui": goalui,
     "goalui": goalui,
     "users": users,
     "users": users,
     "stacks": stacks,
     "stacks": stacks,
+    "origins": origins,
     "notes": notes,
     "notes": notes,
     "resurrect": resurrect,
     "resurrect": resurrect,
 }
 }

+ 142 - 0
test/bundles/origins.py

@@ -0,0 +1,142 @@
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+"""Goal 8 task 6 (web side): the register handshake stores the daemon's
+reported `workspace_root`, and assignment project paths are addressed on
+the ASKING saddle's filesystem — the saddle row's root first, then the
+SADDLE_PROJECTS_ROOT environment override, then the historical
+"/workspace" default. The web app needs NO access to saddle workspaces:
+paths are advisory and multi-saddle goals coordinate through origin."""
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    print("== origin decoupling: the handshake stores the reported root ==")
+    # The primary fake reports /workspace by default; the row carries it
+    # once registration completes (the stacks bundle's demo project is
+    # project 1 in every shard).
+    def saddle_row(name):
+        return next((s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                     if s["name"] == name), None)
+
+    primary = saddle_row("fake-saddle")
+    check(primary is not None, "the primary fake saddle is registered")
+    if primary is not None:
+        check(primary.get("workspace_root") == "/workspace",
+              "the register handshake stores the daemon-reported workspace_root (%r)"
+              % primary.get("workspace_root"))
+
+    print("== origin decoupling: assignment paths follow the ASKING saddle ==")
+    # A second saddle on its own volume: its daemon reports a DIFFERENT
+    # workspace root — the fleet's separate-mount deployment shape.
+    other = FakeSaddle()
+    other.workspace_root = "/volumes/saddle-b"
+    other.serve(port=free_port())
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["register"], {"name": "volume-b",
+                                          "host": "127.0.0.1:%d" % other.port})
+    check(wait_for(lambda: len(other.register_log) > 0 or False,
+                   timeout=15, what="volume-b /register delivery") is not None,
+          "the second saddle received /register")
+    row_b = saddle_row("volume-b")
+    check(row_b is not None and row_b.get("workspace_root") == "/volumes/saddle-b",
+          "the second saddle's reported root lands on its row (%r)"
+          % ((row_b or {}).get("workspace_root")))
+
+    # One task on the demo project, dispatched to EACH saddle in turn:
+    # the assignment's project `path` must sit under the ASKING saddle's
+    # root, not a shared default.
+    def path_of(saddle_fake, title):
+        payloads = [p for p in saddle_fake.start_payloads
+                    if (p.get("task") or {}).get("title") == title]
+        if not payloads:
+            return None
+        co = payloads[-1]["task"].get("co_developed") or []
+        return co[0].get("path") if co else None
+
+    # deterministic routing: a capability each saddle alone carries
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["create_capability"], {"name": "primary-vol"})
+    act(auth, slot_public(auth, "/saddles", "saddles")["create_capability"],
+        {"name": "volume-b-vol"})
+    caps = {c["name"]: int(c["id"])
+            for c in slot_public(auth, "/saddles", "saddles")["capabilities"]}
+    act(auth, saddle_card(auth, "fake-saddle")["attach_capability"],
+        {"capability_id": str(caps["primary-vol"])})
+    act(auth, saddle_card(auth, "volume-b")["attach_capability"],
+        {"capability_id": str(caps["volume-b-vol"])})
+
+    def create_routed(title, cap_name, description):
+        task_id = create_queued(auth, {"project_id": "1", "title": title,
+                                       "description": description, "priority": "2"})
+        act(auth, task_state(auth, task_id)["attach_capability"],
+            {"capability_id": str(caps[cap_name])})
+        wait_for(lambda: [c["name"] for c in
+                          task_state(auth, task_id)["capabilities"]] == [cap_name]
+                 or False, timeout=30, what="%s attached" % cap_name)
+        return task_id
+
+    first = create_routed("Path probe A", "primary-vol",
+                          "Dispatch to the primary volume.")
+    check(wait_for(lambda: path_of(fake, "Path probe A") is not None,
+                   timeout=40, what="primary-saddle assignment") is not None,
+          "the primary saddle receives the task")
+    path_a = path_of(fake, "Path probe A")
+    check(path_a is not None and path_a.startswith("/workspace/task-"),
+          "the primary saddle's paths sit under its reported /workspace (%r)" % path_a)
+
+    second = create_routed("Path probe B", "volume-b-vol",
+                           "Dispatch to the second volume.")
+    check(wait_for(lambda: path_of(other, "Path probe B") is not None,
+                   timeout=40, what="volume-b assignment") is not None,
+          "the volume-b saddle receives the task")
+    path_b = path_of(other, "Path probe B")
+    check(path_b is not None and path_b.startswith("/volumes/saddle-b/task-"),
+          "the volume-b saddle's paths sit under ITS OWN reported root (%r)" % path_b)
+    check(path_a is not None and path_b is not None
+          and path_a.split("/task-")[0] != path_b.split("/task-")[0],
+          "the two saddles' assignments address different volumes")
+
+    print("== origin decoupling: the compat fallback chain ==")
+    # A daemon that does NOT report a workspace_root (pre-task-6 daemon)
+    # keeps a null root; its paths fall back to SADDLE_PROJECTS_ROOT
+    # (the harness runs the app with it pointed at the scratch dir).
+    # The probe task REQUIRES a capability only this saddle carries, so
+    # the dispatcher deterministically targets it (tag routing).
+    legacy = FakeSaddle()
+    legacy.workspace_root = ""
+    legacy.serve(port=free_port())
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["register"],
+        {"name": "legacy-daemon", "host": "127.0.0.1:%d" % legacy.port})
+    check(wait_for(lambda: len(legacy.register_log) > 0 or False,
+                   timeout=15, what="legacy /register delivery") is not None,
+          "the legacy daemon received /register")
+    row_legacy = saddle_row("legacy-daemon")
+    check(row_legacy is not None and not row_legacy.get("workspace_root"),
+          "a daemon reporting no root keeps the column empty (%r)"
+          % ((row_legacy or {}).get("workspace_root")))
+    act(auth, slot_public(auth, "/saddles", "saddles")["create_capability"],
+        {"name": "legacy-only"})
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    legacy_cap = int([c["id"] for c in saddles_state["capabilities"]
+                      if c["name"] == "legacy-only"][0])
+    legacy_card = saddle_card(auth, "legacy-daemon")
+    act(auth, legacy_card["attach_capability"], {"capability_id": str(legacy_cap)})
+    third = create_queued(auth, {"project_id": "1", "title": "Path probe C",
+                                 "description": "Dispatch to the legacy daemon.",
+                                 "priority": "4"})
+    detail = task_state(auth, third)
+    act(auth, detail["attach_capability"], {"capability_id": str(legacy_cap)})
+    check(wait_for(lambda: [c["name"] for c in
+                            task_state(auth, third)["capabilities"]] == ["legacy-only"]
+                   or False, timeout=30, what="capability attached") is not None,
+          "the probe task requires the legacy-only capability")
+    check(wait_for(lambda: path_of(legacy, "Path probe C") is not None,
+                   timeout=60, what="legacy-saddle assignment") is not None,
+          "the legacy saddle receives the task")
+    path_c = path_of(legacy, "Path probe C")
+    harness_root = os.path.join(WORK, "workspace")
+    check(path_c is not None and path_c.startswith(harness_root + "/task-"),
+          "a null root falls back to SADDLE_PROJECTS_ROOT, not /workspace (%r)" % path_c)

+ 4 - 2
test/harness.py

@@ -518,6 +518,7 @@ class FakeSaddle:
         self.session_id = None           # the web-side run row of the running task
         self.session_id = None           # the web-side run row of the running task
         self.hold_state = False          # freeze lifecycle + heartbeat pushes
         self.hold_state = False          # freeze lifecycle + heartbeat pushes
         self.ssh_public_key = ""         # reported on /status + /register
         self.ssh_public_key = ""         # reported on /status + /register
+        self.workspace_root = "/workspace"  # goal 8 task 6: reported on /register
         self.started_mono = time.monotonic()
         self.started_mono = time.monotonic()
         self.freeze_activity = False     # hold last_activity_epoch still
         self.freeze_activity = False     # hold last_activity_epoch still
         self.silent = False              # suppress pushes (staleness tests)
         self.silent = False              # suppress pushes (staleness tests)
@@ -811,7 +812,8 @@ class FakeSaddle:
                     self.daemon.token = payload["saddle_token"]
                     self.daemon.token = payload["saddle_token"]
                 self._send(200, {"ok": True,
                 self._send(200, {"ok": True,
                                  "ssh_public_key": self.daemon.ssh_public_key,
                                  "ssh_public_key": self.daemon.ssh_public_key,
-                                 "version": "0.1-fake"})
+                                 "version": "0.1-fake",
+                                 "workspace_root": self.daemon.workspace_root})
                 if self.daemon.ask_on_register:
                 if self.daemon.ask_on_register:
                     self.daemon.maybe_trigger_ask("register")
                     self.daemon.maybe_trigger_ask("register")
                 return
                 return
@@ -1315,7 +1317,7 @@ def start_bundle(name):
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
-                 "resurrect")
+                 "resurrect", "origins")
 
 
 
 
 def run_bundle(name):
 def run_bundle(name):