ソースを参照

feat: capability tags — migration, service, saddle chips + catalogue on /saddles, required-capabilities on the task page, capability-matched dispatch (superset pairing, skip-and-continue with a wait log + queue-row flag)

clanker 1 ヶ月 前
親
コミット
8a2b5c00ca

+ 4 - 1
meson.build

@@ -111,6 +111,7 @@ app_sources = files(
     'src/Data/Migrations/M0003_Rules.vala',
     'src/Data/Migrations/M0004_ProjectNotes.vala',
     'src/Data/Migrations/M0005_RawLog.vala',
+    'src/Data/Migrations/M0006_Capabilities.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',
@@ -118,6 +119,7 @@ app_sources = files(
     'src/Services/StatePulse.vala',
     'src/Services/Dispatcher.vala',
     'src/Services/RuleService.vala',
+    'src/Services/CapabilityService.vala',
     'src/Api/SaddleApi.vala',
     'src/Api/TerminalSse.vala',
     'src/actions/Forms.vala',
@@ -127,6 +129,7 @@ app_sources = files(
     'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
+    'src/actions/CapabilityActions.vala',
     'src/entrypoints/HomeEntrypoint.vala',
     'src/entrypoints/LoginEntrypoint.vala',
     'src/entrypoints/ProjectsEntrypoint.vala',
@@ -182,5 +185,5 @@ clanker_saddle_exe = executable('clanker-saddle',
 python_test = import('python').find_installation('python3')
 test('web-stack', python_test,
     args: [files('test/web_stack_test.py'), clanker_saddle_exe],
-    timeout: 180,
+    timeout: 420,
 )

+ 48 - 0
src/Data/Entities.vala

@@ -361,6 +361,54 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * A named tag used for dispatch routing (`capabilities`): saddles
+     * declare the tags they carry, tasks the tags they require.
+     */
+    public class Capability : Object {
+
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Capability> cfg) {
+            cfg.table("capabilities")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("name", o => o.name, (o, v) => o.name = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /** A tag declaration on a saddle (`saddle_capabilities`). */
+    public class SaddleCapability : Object {
+
+        public int64 id { get; set; }
+        public int64 capability_id { get; set; }
+        public int64 saddle_id { get; set; }
+
+        public static void entity_mapping(EntityMapperBuilder<SaddleCapability> cfg) {
+            cfg.table("saddle_capabilities")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("capability_id", o => o.capability_id, (o, v) => o.capability_id = v)
+                .column<int64?>("saddle_id", o => o.saddle_id, (o, v) => o.saddle_id = v);
+        }
+    }
+
+    /** A required tag on a task (`task_capabilities`). */
+    public class TaskCapability : Object {
+
+        public int64 id { get; set; }
+        public int64 capability_id { get; set; }
+        public int64 task_id { get; set; }
+
+        public static void entity_mapping(EntityMapperBuilder<TaskCapability> cfg) {
+            cfg.table("task_capabilities")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("capability_id", o => o.capability_id, (o, v) => o.capability_id = v)
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v);
+        }
+    }
+
     /**
      * A bearer token authorising one saddle daemon's calls against the
      * `/api/saddle/*` endpoints, generated at registration.

+ 66 - 0
src/Data/Migrations/M0006_Capabilities.vala

@@ -0,0 +1,66 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Web-stack capabilities: tag-based routing. A capability is a named
+     * tag (e.g. "gpu"); saddles declare which tags they carry and tasks
+     * declare which they require. The dispatcher only pairs a task with
+     * a saddle whose tags are a superset of the task's (an empty task
+     * set matches any saddle).
+     */
+    public class M0006_Capabilities : Migration {
+
+        public override string name { get { return "Capabilities: capabilities, saddle_capabilities, task_capabilities"; } }
+        public override uint64 serial { get { return 6; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.create_table("capabilities", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("name")
+                    .not_null()
+                    .unique();
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("saddle_capabilities", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("capability_id")
+                    .not_null()
+                    .references("capabilities", "id")
+                    .indexed();
+                t.column<int64?>("saddle_id")
+                    .not_null()
+                    .references("saddles", "id")
+                    .indexed();
+                t.unique("uq_saddle_capabilities_pair", {"capability_id", "saddle_id"});
+            });
+
+            b.create_table("task_capabilities", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("capability_id")
+                    .not_null()
+                    .references("capabilities", "id")
+                    .indexed();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.unique("uq_task_capabilities_pair", {"capability_id", "task_id"});
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("task_capabilities");
+            b.drop_table("saddle_capabilities");
+            b.drop_table("capabilities");
+        }
+    }
+}

+ 214 - 0
src/Services/CapabilityService.vala

@@ -0,0 +1,214 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * Capability storage: CRUD over `capabilities` plus their saddle and
+     * task attachments, and the tag-matching predicate the dispatcher
+     * routes by — a saddle is eligible for a task when its capability
+     * set is a superset of the task's required set (an empty task set
+     * matches any saddle).
+     */
+    public class CapabilityService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /**
+         * Inserts a capability; names are unique. Returns the stored row
+         * (with its id).
+         */
+        public async Capability create_capability(string name) throws Error {
+            if (name.strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 40,
+                    "A capability name is required");
+            }
+            var existing = yield db.query<Capability>()
+                .where(expr("name == $0", elem(name.strip())))
+                .first_async();
+            if (existing != null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 41,
+                    @"A capability named '$(name)' already exists");
+            }
+            var capability = new Capability() {
+                name = name.strip(),
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<Capability>(capability);
+            return capability;
+        }
+
+        /** Loads the capability identified by `id`, or null. */
+        public async Capability? get_capability(int64 id) throws Error {
+            return yield db.query<Capability>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /** Every capability ordered by name. */
+        public async ImmutableLot<Capability> list_capabilities() throws Error {
+            return yield db.query<Capability>()
+                .order_by(expr("name"))
+                .materialise_async();
+        }
+
+        /**
+         * Deletes a capability and every attachment referencing it (a
+         * saddle stops carrying it, a task stops requiring it).
+         */
+        public async void delete_capability(int64 id) throws Error {
+            foreach (var link in yield db.query<TaskCapability>()
+                    .where(expr("capability_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<TaskCapability>(link);
+            }
+            foreach (var link in yield db.query<SaddleCapability>()
+                    .where(expr("capability_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<SaddleCapability>(link);
+            }
+            var capability = yield get_capability(id);
+            if (capability != null) {
+                yield db.delete_async<Capability>((!)capability);
+            }
+        }
+
+        /**
+         * Attaches `capability_id` to `saddle_id`; duplicates are a no-op
+         * (the pair is unique).
+         */
+        public async void attach_to_saddle(int64 capability_id, int64 saddle_id) throws Error {
+            var capability = yield get_capability(capability_id);
+            if (capability == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 42,
+                    @"No capability exists with id $(capability_id)");
+            }
+            var existing = yield db.query<SaddleCapability>()
+                .where(expr("capability_id == $0 && saddle_id == $1",
+                    elem<int64?>(capability_id), elem<int64?>(saddle_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                return;
+            }
+            yield db.insert_async<SaddleCapability>(new SaddleCapability() {
+                capability_id = capability_id,
+                saddle_id = saddle_id
+            });
+        }
+
+        /** Detaches the `saddle_capabilities` row identified by `id`. */
+        public async void detach_from_saddle(int64 link_id) throws Error {
+            var link = yield db.query<SaddleCapability>()
+                .where(expr("id == $0", elem<int64?>(link_id)))
+                .first_async();
+            if (link != null) {
+                yield db.delete_async<SaddleCapability>((!)link);
+            }
+        }
+
+        /** The `saddle_capabilities` attachments of `saddle_id`, oldest first. */
+        public async ImmutableLot<SaddleCapability> list_saddle_attachments(int64 saddle_id) throws Error {
+            return yield db.query<SaddleCapability>()
+                .where(expr("saddle_id == $0", elem<int64?>(saddle_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** The capabilities a saddle carries, attachment order. */
+        public async ImmutableLot<Capability> list_for_saddle(int64 saddle_id) throws Error {
+            var rows = new Vector<Capability>();
+            foreach (var link in yield list_saddle_attachments(saddle_id)) {
+                var capability = yield get_capability(link.capability_id);
+                if (capability != null) {
+                    rows.add((!)capability);
+                }
+            }
+            return rows.to_immutable_buffer();
+        }
+
+        /**
+         * Attaches `capability_id` to `task_id` (a required capability);
+         * duplicates are a no-op (the pair is unique).
+         */
+        public async void attach_to_task(int64 capability_id, int64 task_id) throws Error {
+            var capability = yield get_capability(capability_id);
+            if (capability == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 42,
+                    @"No capability exists with id $(capability_id)");
+            }
+            var existing = yield db.query<TaskCapability>()
+                .where(expr("capability_id == $0 && task_id == $1",
+                    elem<int64?>(capability_id), elem<int64?>(task_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                return;
+            }
+            yield db.insert_async<TaskCapability>(new TaskCapability() {
+                capability_id = capability_id,
+                task_id = task_id
+            });
+        }
+
+        /** Detaches the `task_capabilities` row identified by `id`. */
+        public async void detach_from_task(int64 link_id) throws Error {
+            var link = yield db.query<TaskCapability>()
+                .where(expr("id == $0", elem<int64?>(link_id)))
+                .first_async();
+            if (link != null) {
+                yield db.delete_async<TaskCapability>((!)link);
+            }
+        }
+
+        /** The `task_capabilities` attachments of `task_id`, oldest first. */
+        public async ImmutableLot<TaskCapability> list_task_attachments(int64 task_id) throws Error {
+            return yield db.query<TaskCapability>()
+                .where(expr("task_id == $0", elem<int64?>(task_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** The capabilities a task requires, attachment order. */
+        public async ImmutableLot<Capability> list_for_task(int64 task_id) throws Error {
+            var rows = new Vector<Capability>();
+            foreach (var link in yield list_task_attachments(task_id)) {
+                var capability = yield get_capability(link.capability_id);
+                if (capability != null) {
+                    rows.add((!)capability);
+                }
+            }
+            return rows.to_immutable_buffer();
+        }
+
+        /** Every `saddle_capabilities` row (for dispatch-time batching). */
+        public async ImmutableLot<SaddleCapability> all_saddle_attachments() throws Error {
+            return yield db.query<SaddleCapability>().materialise_async();
+        }
+
+        /** Every `task_capabilities` row (for usage counts and routing). */
+        public async ImmutableLot<TaskCapability> all_task_attachments() throws Error {
+            return yield db.query<TaskCapability>().materialise_async();
+        }
+
+        /**
+         * True when `saddle_id`'s capability set is a superset of
+         * `task_id`'s required set — the dispatcher's eligibility check.
+         */
+        public async bool saddle_covers_task(int64 saddle_id, int64 task_id) throws Error {
+            var required = new HashSet<string>();
+            foreach (var capability in yield list_for_task(task_id)) {
+                required.add(capability.name);
+            }
+            if (required.length == 0) {
+                return true;
+            }
+            foreach (var capability in yield list_for_saddle(saddle_id)) {
+                required.remove(capability.name);
+            }
+            return required.length == 0;
+        }
+    }
+}

+ 53 - 9
src/Services/Dispatcher.vala

@@ -21,6 +21,14 @@ namespace ClankerSaddle.Services {
         private bool ticking = false;
         private bool warned_no_llm = false;
 
+        /**
+         * Task ids that already logged a capability wait. The skip note
+         * prints once per task (cleared on dispatch) instead of on every
+         * five-second tick.
+         */
+        private Invercargill.DataStructures.HashSet<string> capability_wait_logged =
+            new Invercargill.DataStructures.HashSet<string>();
+
         /**
          * Consecutive communication failures per saddle id. Transient
          * errors (a pooled connection dropped mid-reuse, a momentary
@@ -61,19 +69,23 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * One dispatch cycle: for every ready task (priority order) grab an
-         * idle saddle, open a session, push the assignment, then resume
-         * answered-but-paused tasks on the saddle that holds their parked
-         * workspace, and poll busy saddles for output and status changes.
-         * Assignment pauses while {@link LlmEndpoint} is unconfigured
-         * (polling continues).
-         */
+          * One dispatch cycle: for every ready task (priority order) grab an
+          * idle saddle whose capability set is a superset of the task's
+          * required capabilities (tag-based routing — a task with no
+          * requirements matches any saddle; a task that cannot be matched
+          * is skipped with a log note and lower-priority tasks get their
+          * chance), then resume answered-but-paused tasks on the saddle
+          * that holds their parked workspace, and poll busy saddles for
+          * output and status changes. Assignment pauses while
+          * {@link LlmEndpoint} is unconfigured (polling continues).
+          */
         public async void tick() throws Error {
             var scope = container.create_scope();
             var orm = scope.resolve<OrmSession>();
             var tasks = scope.resolve<TaskService>();
             var saddles = scope.resolve<SaddleService>();
             var projects = scope.resolve<ProjectService>();
+            var capabilities = scope.resolve<CapabilityService>();
 
             if (LlmEndpoint.from_environment() == null) {
                 if (!warned_no_llm) {
@@ -84,10 +96,24 @@ namespace ClankerSaddle.Services {
                 warned_no_llm = false;
                 var ready = yield tasks.get_ready_tasks();
                 foreach (var task in ready) {
-                    var saddle = yield saddles.get_idle_saddle();
+                    var saddle = yield pick_idle_capable_saddle(saddles, capabilities, task);
                     if (saddle == null) {
-                        break;
+                        bool any_idle = (yield saddles.list_idle_saddles()).length > 0;
+                        if (!any_idle) {
+                            break;
+                        }
+                        // A capable saddle may free up later; meanwhile
+                        // lower-priority tasks still get their turn.
+                        if (capability_wait_logged.add(task.id.to_string())) {
+                            var required = new Invercargill.DataStructures.Vector<string>();
+                            foreach (var capability in yield capabilities.list_for_task(task.id)) {
+                                required.add(capability.name);
+                            }
+                            print(@"[ClankerSaddle] Task $(task.id) waits for a capable saddle (requires: $(string.joinv(", ", required.to_array())))\n");
+                        }
+                        continue;
                     }
+                    capability_wait_logged.remove(task.id.to_string());
 
                     var session = new Session() {
                         task_id = task.id,
@@ -136,6 +162,8 @@ namespace ClankerSaddle.Services {
          * Resumes blocked/review-requested tasks whose newest block has
          * been answered, on the saddle holding their open (parked)
          * session — the workspace-pause counterpart of the dispatch loop.
+         * Capabilities do not apply here: the parked workspace pins the
+         * task to the saddle that holds it.
          */
         private async void resume_answered_tasks(TaskService tasks, SaddleService saddles,
                 ProjectService projects) throws Error {
@@ -175,5 +203,21 @@ namespace ClankerSaddle.Services {
             record_strike(saddle_id, 0);
             yield saddles.mark_saddle_unreachable(saddle_id);
         }
+
+        /**
+         * The first idle saddle whose capability set covers `task`'s
+         * required capabilities (registration order), or null when every
+         * idle saddle is missing a tag — the tag-based routing half of
+         * the pairing loop.
+         */
+        private async Saddle? pick_idle_capable_saddle(SaddleService saddles,
+                CapabilityService capabilities, TaskEntity task) throws Error {
+            foreach (var saddle in yield saddles.list_idle_saddles()) {
+                if (yield capabilities.saddle_covers_task(saddle.id, task.id)) {
+                    return saddle;
+                }
+            }
+            return null;
+        }
     }
 }

+ 11 - 0
src/Services/SaddleService.vala

@@ -280,6 +280,17 @@ namespace ClankerSaddle.Services {
                 .first_async();
         }
 
+        /**
+         * Every saddle with status 'idle', registration order — the
+         * dispatcher's capability-matching candidate list.
+         */
+        public async ImmutableLot<Saddle> list_idle_saddles() throws Error {
+            return yield db.query<Saddle>()
+                .where(expr("status == $0", elem("idle")))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
         /**
          * Marks a saddle offline when its control server cannot be
          * reached, releasing any task it was holding back to 'queued' and

+ 214 - 0
src/actions/CapabilityActions.vala

@@ -0,0 +1,214 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed capability id carried by {@link DeleteCapabilityAction}. */
+    public class CapabilityActionPrivate : Object {
+        public int64 capability_id { get; set; }
+    }
+
+    /** Sealed saddle-capability attachment identity. */
+    public class SaddleCapabilityActionPrivate : Object {
+        public int64 saddle_id { get; set; }
+        public int64 link_id { get; set; }
+    }
+
+    /** Sealed task-capability attachment identity. */
+    public class TaskCapabilityActionPrivate : Object {
+        public int64 task_id { get; set; }
+        public int64 link_id { get; set; }
+    }
+
+    /**
+     * Creates a capability (tag) from the saddles page's capabilities
+     * section; names are unique.
+     */
+    public class CreateCapabilityAction : StatumAction {
+
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string name = Forms.field(request, "name");
+            if (name.length == 0) {
+                return directives().notify("error", "A capability name is required").navigate("/saddles");
+            }
+
+            try {
+                var capability = yield capabilities.create_capability(name);
+                return directives().notify("success", @"Capability '$(capability.name)' created")
+                    .navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not create the capability: $(e.message)")
+                    .navigate("/saddles");
+            }
+        }
+    }
+
+    /**
+     * Deletes a capability everywhere (saddles stop carrying it, tasks
+     * stop requiring it); the id is sealed.
+     */
+    public class DeleteCapabilityAction : TypedStatumAction<CapabilityActionPrivate> {
+
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(CapabilityActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/saddles");
+            }
+
+            try {
+                yield capabilities.delete_capability(((!)ctx).capability_id);
+                return directives().notify("success", "Capability deleted").navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the capability: $(e.message)")
+                    .navigate("/saddles");
+            }
+        }
+    }
+
+    /**
+     * Attaches the form-selected capability to the sealed saddle; the
+     * saddle card's capability chips.
+     */
+    public class AttachSaddleCapabilityAction : TypedStatumAction<SaddleActionPrivate> {
+
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(SaddleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 saddle_id = ctx != null ? ((!)ctx).saddle_id : Forms.integer(request, "saddle_id", 0);
+            int64 capability_id = Forms.integer(request, "capability_id", 0);
+            if (saddle_id <= 0 || capability_id <= 0) {
+                return directives().notify("error", "Pick a capability to attach").navigate("/saddles");
+            }
+
+            try {
+                yield capabilities.attach_to_saddle(capability_id, saddle_id);
+                return directives().notify("success", "Capability attached").navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not attach the capability: $(e.message)")
+                    .navigate("/saddles");
+            }
+        }
+    }
+
+    /** Detaches a saddle capability; both ids are sealed. */
+    public class DetachSaddleCapabilityAction : TypedStatumAction<SaddleCapabilityActionPrivate> {
+
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(SaddleCapabilityActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/saddles");
+            }
+
+            try {
+                yield capabilities.detach_from_saddle(((!)ctx).link_id);
+                return directives().notify("success", "Capability removed").navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not remove the capability: $(e.message)")
+                    .navigate("/saddles");
+            }
+        }
+    }
+
+    /**
+     * Attaches the form-selected capability to the sealed task as a
+     * required capability; the task page's capabilities section.
+     */
+    public class AttachTaskCapabilityAction : SealedTaskAction {
+
+        private CapabilityService capability_service = inject<CapabilityService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            int64 capability_id = Forms.integer(request, "capability_id", 0);
+            if (task_id <= 0 || capability_id <= 0) {
+                return directives().notify("error", "Pick a capability to require")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+
+            try {
+                yield capability_service.attach_to_task(capability_id, task_id);
+                return directives().notify("success", "Capability required")
+                    .navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not attach the capability: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /** Detaches a task's required capability; both ids are sealed. */
+    public class DetachTaskCapabilityAction : TypedStatumAction<TaskCapabilityActionPrivate> {
+
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(TaskCapabilityActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/tasks");
+            }
+
+            try {
+                yield capabilities.detach_from_task(((!)ctx).link_id);
+                return directives().notify("success", "Requirement removed")
+                    .navigate(@"/tasks/$(((!)ctx).task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not remove the requirement: $(e.message)")
+                    .navigate(@"/tasks/$(((!)ctx).task_id)");
+            }
+        }
+    }
+}

+ 3 - 1
src/actions/TaskActions.vala

@@ -375,6 +375,7 @@ namespace ClankerSaddle {
         private ProjectService project_service = inject<ProjectService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private RuleService rule_service = inject<RuleService>();
+        private CapabilityService capability_service = inject<CapabilityService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -389,7 +390,8 @@ namespace ClankerSaddle {
             }
 
             var state = yield TaskDetailState.build(state_service, task_service,
-                project_service, saddle_service, rule_service, action_registry, task_id);
+                project_service, saddle_service, rule_service, capability_service,
+                action_registry, task_id);
             return directives().set(state);
         }
     }

+ 106 - 8
src/entrypoints/SaddlesEntrypoint.vala

@@ -9,6 +9,13 @@ using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
+    /** A capability chip on a saddle card, with its remove action. */
+    public class SaddleCapabilityView : Object {
+        public int64 link_id { get; set; }
+        public string name { get; set; default = ""; }
+        public Statum.Model.ActionDto? detach { get; set; }
+    }
+
     /** A saddle card on the management page, with its current task and API token. */
     public class SaddleRowView : Object {
         public int64 id { get; set; }
@@ -21,21 +28,40 @@ namespace ClankerSaddle {
         public string token { get; set; default = ""; }
         public Statum.Model.ActionDto? unregister { get; set; }
         public Statum.Model.ActionDto? refresh { get; set; }
+        public Statum.Model.ActionDto? attach_capability { get; set; }
+    }
+
+    /** A capability row in the saddles page's capabilities section. */
+    public class CapabilityRowView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public int64 saddle_count { get; set; }
+        public int64 task_count { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /** An option in the attach selects (saddle cards and the task page). */
+    public class CapabilityOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
     }
 
     /** Public data of the `saddles` slot. */
     public class SaddlesPublic : Object {
         public Statum.Model.ActionDto register { get; set; }
+        public Statum.Model.ActionDto create_capability { get; set; }
     }
 
     /**
-     * Entrypoint for /saddles: the saddle inventory. Admin-only (the
-     * authoritative guard); each card carries its current task and the
-     * unregister/refresh actions.
+     * Entrypoint for /saddles: the saddle inventory plus the capability
+     * tag catalogue. Admin-only (the authoritative guard); each card
+     * carries its current task, capability chips (with remove actions),
+     * the attach-capability select, and the unregister/refresh actions.
      */
     public class SaddlesEntrypoint : StatumEntrypoint {
 
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
+        private CapabilityService capability_service = Inversion.inject<CapabilityService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
@@ -46,8 +72,46 @@ namespace ClankerSaddle {
                 return (!)guard;
             }
 
+            var capabilities = yield capability_service.list_capabilities();
+            var capability_names = new Dictionary<string, string>();
+            foreach (var capability in capabilities) {
+                capability_names.set(capability.id.to_string(), capability.name);
+            }
+            var saddle_links = yield capability_service.all_saddle_attachments();
+            var task_links = yield capability_service.all_task_attachments();
+
+            var options = new Vector<CapabilityOptionView>();
+            foreach (var capability in capabilities) {
+                options.add(new CapabilityOptionView() {
+                    id = capability.id,
+                    name = capability.name
+                });
+            }
+
+            // Per-saddle chips keyed by saddle id, attached to the row
+            // models via list_rich below (slot lists cannot nest).
+            var chips_by_saddle = new Dictionary<string, Vector<SaddleCapabilityView>>();
+            foreach (var link in saddle_links) {
+                string name = capability_names.get_or_default(link.capability_id.to_string()) ?? "?";
+                var sealed_link = new SaddleCapabilityActionPrivate() {
+                    saddle_id = link.saddle_id,
+                    link_id = link.id
+                };
+                var chips = chips_by_saddle.get_or_default(link.saddle_id.to_string());
+                if (chips == null) {
+                    chips = new Vector<SaddleCapabilityView>();
+                    chips_by_saddle.set(link.saddle_id.to_string(), chips);
+                }
+                ((!)chips).add(new SaddleCapabilityView() {
+                    link_id = link.id,
+                    name = name,
+                    detach = action_registry.author_private<DetachSaddleCapabilityAction, SaddleCapabilityActionPrivate>(sealed_link)
+                });
+            }
+
             var rows = new Vector<SaddleRowView>();
             foreach (var saddle in yield saddle_service.list_saddles()) {
+                var sealed = new SaddleActionPrivate() { saddle_id = saddle.id };
                 var view = new SaddleRowView() {
                     id = saddle.id,
                     name = saddle.name,
@@ -55,7 +119,10 @@ namespace ClankerSaddle {
                     status = saddle.status,
                     current_task_id = saddle.current_task_id,
                     allocated_display = saddle.allocated_at != null
-                        ? ((!)saddle.allocated_at).format("%Y-%m-%d %H:%M") : ""
+                        ? ((!)saddle.allocated_at).format("%Y-%m-%d %H:%M") : "",
+                    unregister = action_registry.author_private<UnregisterSaddleAction, SaddleActionPrivate>(sealed),
+                    refresh = action_registry.author_private<RefreshSaddleStatusAction, SaddleActionPrivate>(sealed),
+                    attach_capability = action_registry.author_private<AttachSaddleCapabilityAction, SaddleActionPrivate>(sealed)
                 };
                 string? token = yield saddle_service.token_for_saddle(saddle.id);
                 view.token = token ?? "";
@@ -65,17 +132,48 @@ namespace ClankerSaddle {
                         .first_async();
                     view.current_task_title = task != null ? ((!)task).title : "";
                 }
-                var sealed = new SaddleActionPrivate() { saddle_id = saddle.id };
-                view.unregister = action_registry.author_private<UnregisterSaddleAction, SaddleActionPrivate>(sealed);
-                view.refresh = action_registry.author_private<RefreshSaddleStatusAction, SaddleActionPrivate>(sealed);
                 rows.add(view);
             }
 
+            var capability_rows = new Vector<CapabilityRowView>();
+            foreach (var capability in capabilities) {
+                int64 saddle_count = 0;
+                foreach (var link in saddle_links) {
+                    if (link.capability_id == capability.id) {
+                        saddle_count++;
+                    }
+                }
+                int64 task_count = 0;
+                foreach (var link in task_links) {
+                    if (link.capability_id == capability.id) {
+                        task_count++;
+                    }
+                }
+                var sealed_capability = new CapabilityActionPrivate() { capability_id = capability.id };
+                capability_rows.add(new CapabilityRowView() {
+                    id = capability.id,
+                    name = capability.name,
+                    saddle_count = saddle_count,
+                    task_count = task_count,
+                    delete = action_registry.author_private<DeleteCapabilityAction, CapabilityActionPrivate>(sealed_capability)
+                });
+            }
+
             var pub = new SaddlesPublic();
             pub.register = action_registry.author<RegisterSaddleAction>();
+            pub.create_capability = action_registry.author<CreateCapabilityAction>();
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
-            props["saddles"] = StateJson.list<SaddleRowView>(rows.to_immutable_buffer());
+            props["saddles"] = StateJson.list_rich<SaddleRowView>(rows.to_immutable_buffer(),
+                (item, dict) => {
+                    var chips = chips_by_saddle.get_or_default(((SaddleRowView) item).id.to_string());
+                    dict["capabilities"] = chips != null
+                        ? StateJson.list<SaddleCapabilityView>(((!)chips).to_immutable_buffer())
+                        : StateJson.list<SaddleCapabilityView>(
+                            new Vector<SaddleCapabilityView>().to_immutable_buffer());
+                });
+            props["capabilities"] = StateJson.list<CapabilityRowView>(capability_rows.to_immutable_buffer());
+            props["capability_options"] = StateJson.list<CapabilityOptionView>(options.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "saddles",

+ 40 - 2
src/entrypoints/TaskDetailEntrypoint.vala

@@ -73,6 +73,13 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? detach { get; set; }
     }
 
+    /** A capability required by the task, with its detach action. */
+    public class TaskCapabilityRowView : Object {
+        public int64 link_id { get; set; }
+        public string name { get; set; default = ""; }
+        public Statum.Model.ActionDto? detach { get; set; }
+    }
+
     /** A session row in the task's history, linking to the terminal view. */
     public class SessionView : Object {
         public int64 id { get; set; }
@@ -116,6 +123,7 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? cancel { get; set; }
         public Statum.Model.ActionDto? refresh { get; set; }
         public Statum.Model.ActionDto? attach_rule { get; set; }
+        public Statum.Model.ActionDto? attach_capability { get; set; }
     }
 
     /**
@@ -134,7 +142,8 @@ namespace ClankerSaddle {
          */
         public static async Slot build(StateService state_service, TaskService tasks,
                 ProjectService projects, SaddleService saddles, RuleService rules,
-                ActionRegistry actions, int64 task_id) throws GLib.Error {
+                CapabilityService capabilities, ActionRegistry actions,
+                int64 task_id) throws GLib.Error {
             var detail = yield tasks.get_task(task_id);
             if (detail == null) {
                 return slot_for(state_service, new TaskDetailPublic() { exists = false },
@@ -176,6 +185,7 @@ namespace ClankerSaddle {
             pub.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
             pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
             pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
+            pub.attach_capability = actions.author_private<AttachTaskCapabilityAction, TaskActionPrivate>(sealed);
 
             var comments = new Vector<CommentView>();
             foreach (var comment in ((!)detail).comments) {
@@ -280,6 +290,30 @@ namespace ClankerSaddle {
                 });
             }
 
+            var task_capabilities = new Vector<TaskCapabilityRowView>();
+            foreach (var link in yield capabilities.list_task_attachments(task.id)) {
+                var capability = yield capabilities.get_capability(link.capability_id);
+                if (capability == null) {
+                    continue;
+                }
+                var capability_private = new TaskCapabilityActionPrivate() {
+                    task_id = task.id,
+                    link_id = link.id
+                };
+                task_capabilities.add(new TaskCapabilityRowView() {
+                    link_id = link.id,
+                    name = ((!)capability).name,
+                    detach = actions.author_private<DetachTaskCapabilityAction, TaskCapabilityActionPrivate>(capability_private)
+                });
+            }
+            var capability_options = new Vector<CapabilityOptionView>();
+            foreach (var capability in yield capabilities.list_capabilities()) {
+                capability_options.add(new CapabilityOptionView() {
+                    id = capability.id,
+                    name = capability.name
+                });
+            }
+
             var sessions = new Vector<SessionView>();
             foreach (var session in ((!)detail).sessions) {
                 var saddle = yield saddles.get_saddle(session.saddle_id);
@@ -305,6 +339,8 @@ namespace ClankerSaddle {
             props["candidates"] = StateJson.list<DependencyView>(candidates.to_immutable_buffer());
             props["rules"] = StateJson.list<TaskRuleRowView>(task_rules.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
+            props["capabilities"] = StateJson.list<TaskCapabilityRowView>(task_capabilities.to_immutable_buffer());
+            props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
             props["sessions"] = StateJson.list<SessionView>(sessions.to_immutable_buffer());
             return slot_for(state_service, pub, props);
         }
@@ -373,6 +409,7 @@ namespace ClankerSaddle {
         private ProjectService project_service = Inversion.inject<ProjectService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
+        private CapabilityService capability_service = Inversion.inject<CapabilityService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -383,7 +420,8 @@ namespace ClankerSaddle {
 
             int64 task_id = int64.parse(request.route("task_id") ?? "0");
             var state = yield TaskDetailState.build(state_service, task_service,
-                project_service, saddle_service, rule_service, action_registry, task_id);
+                project_service, saddle_service, rule_service, capability_service,
+                action_registry, task_id);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 54 - 1
src/entrypoints/TasksEntrypoint.vala

@@ -20,6 +20,8 @@ namespace ClankerSaddle {
         public string dependency_summary { get; set; default = ""; }
         public bool has_dependencies { get; set; }
         public bool ready { get; set; }
+        public string[] capabilities { get; set; }
+        public bool waiting_for_saddle { get; set; }
     }
 
     /**
@@ -50,6 +52,8 @@ namespace ClankerSaddle {
 
         private TaskService task_service = Inversion.inject<TaskService>();
         private ProjectService project_service = Inversion.inject<ProjectService>();
+        private CapabilityService capability_service = Inversion.inject<CapabilityService>();
+        private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -87,6 +91,32 @@ namespace ClankerSaddle {
             var all_dependencies = yield task_service.list_all_dependencies();
             var ready = yield task_service.get_ready_tasks();
 
+            // Capability routing context: required names per task, carried
+            // names per idle saddle — a queued task waits for a capable
+            // saddle when no idle saddle's set covers its requirements.
+            var capability_names = new Dictionary<string, string>();
+            foreach (var capability in yield capability_service.list_capabilities()) {
+                capability_names.set(capability.id.to_string(), capability.name);
+            }
+            var required_by_task = new Dictionary<string, Vector<string>>();
+            foreach (var link in yield capability_service.all_task_attachments()) {
+                string name = capability_names.get_or_default(link.capability_id.to_string()) ?? "?";
+                var names = required_by_task.get_or_default(link.task_id.to_string());
+                if (names == null) {
+                    names = new Vector<string>();
+                    required_by_task.set(link.task_id.to_string(), names);
+                }
+                ((!)names).add(name);
+            }
+            var idle_capability_sets = new Vector<Invercargill.DataStructures.HashSet<string>>();
+            foreach (var saddle in yield saddle_service.list_idle_saddles()) {
+                var carried = new Invercargill.DataStructures.HashSet<string>();
+                foreach (var link in yield capability_service.list_saddle_attachments(saddle.id)) {
+                    carried.add(capability_names.get_or_default(link.capability_id.to_string()) ?? "?");
+                }
+                idle_capability_sets.add(carried);
+            }
+
             var rows = new Vector<TaskRowView>();
             foreach (var task in yield task_service.list_tasks(status, project_id)) {
                 bool is_ready = false;
@@ -119,6 +149,25 @@ namespace ClankerSaddle {
                 }
                 view.has_dependencies = parts.len > 0;
                 view.dependency_summary = parts.str;
+                var required = required_by_task.get_or_default(task.id.to_string());
+                view.capabilities = required != null ? ((!)required).to_array() : new string[0];
+                if (view.capabilities.length > 0 && task.status == STATUS_QUEUED) {
+                    bool covered = false;
+                    foreach (var carried in idle_capability_sets) {
+                        bool missing = false;
+                        foreach (var name in view.capabilities) {
+                            if (!carried.has(name)) {
+                                missing = true;
+                                break;
+                            }
+                        }
+                        if (!missing) {
+                            covered = true;
+                            break;
+                        }
+                    }
+                    view.waiting_for_saddle = !covered && idle_capability_sets.length > 0;
+                }
                 rows.add(view);
             }
 
@@ -138,7 +187,11 @@ namespace ClankerSaddle {
             };
 
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
-            props["tasks"] = StateJson.list<TaskRowView>(rows.to_immutable_buffer());
+            props["tasks"] = StateJson.list_rich<TaskRowView>(rows.to_immutable_buffer(),
+                (item, dict) => {
+                    dict["capabilities"] = StateJson.strings(
+                        ((TaskRowView) item).capabilities ?? new string[0]);
+                });
             props["projects"] = StateJson.list<TaskProjectOptionView>(options.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {

+ 11 - 0
src/main.vala

@@ -31,6 +31,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0003_Rules>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0004_ProjectNotes>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0005_RawLog>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0006_Capabilities>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -47,12 +48,16 @@ int main(string[] args) {
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
         db.add_entity<ProjectNote>(ProjectNote.entity_mapping);
         db.add_entity<SessionRawLog>(SessionRawLog.entity_mapping);
+        db.add_entity<Capability>(Capability.entity_mapping);
+        db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
+        db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
         application.add_scoped<TaskService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<RuleService>();
+        application.add_scoped<CapabilityService>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<StatePulse>();
         application.add_singleton<Dispatcher>();
@@ -102,6 +107,12 @@ int main(string[] args) {
         statum.action<AttachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
         statum.action<DeleteProjectNoteAction>();
+        statum.action<CreateCapabilityAction>();
+        statum.action<DeleteCapabilityAction>();
+        statum.action<AttachSaddleCapabilityAction>();
+        statum.action<DetachSaddleCapabilityAction>();
+        statum.action<AttachTaskCapabilityAction>();
+        statum.action<DetachTaskCapabilityAction>();
         // spry:actions-end
 
         // spry:resources-begin

+ 12 - 0
src/pages/app.css

@@ -150,6 +150,18 @@ summary { cursor: pointer; font-weight: 600; }
     vertical-align: middle;
 }
 
+/* The × inside a capability chip (badge) */
+.chip-remove {
+    background: none;
+    border: none;
+    color: inherit;
+    cursor: pointer;
+    font-size: 0.85rem;
+    line-height: 1;
+    margin-left: 0.25rem;
+    padding: 0;
+}
+
 .badge-queued { background: #88843333; color: #4d4d33; }
 .badge-assigned { background: #ddf4ff; color: #0969da; }
 .badge-working { background: #fff8c5; color: var(--warning); }

+ 44 - 0
src/pages/saddles.html

@@ -32,6 +32,23 @@
                     <p stm-if="saddle.current_task_id == 0" class="muted">No current task</p>
                     <p stm-if="saddle.token.length > 0" class="muted">API token:
                         <code stm-text="saddle.token">token</code></p>
+                    <p stm-if="saddle.capabilities.length > 0">Capabilities:
+                        <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
+                            <span class="badge"><span stm-text="capability.name">tag</span>
+                                <button type="button" class="inline chip-remove"
+                                        stm-action="capability.detach" stm-confirm="Remove this capability?">×</button></span>
+                        </span>
+                    </p>
+                    <p stm-if="saddle.capabilities.length == 0" class="muted">No capabilities — matches only tasks with none required.</p>
+                    <form class="inline" stm-if="saddles.capability_options.length > 0"
+                          stm-action="saddle.attach_capability">
+                        <label stm-attribute.for="'cap-' + saddle.id">Add capability</label>
+                        <select stm-attribute.id="'cap-' + saddle.id" name="capability_id" required>
+                            <option stm-for-option-in="saddles.capability_options" stm-attribute.value="option.id"
+                                     stm-text="option.name">capability</option>
+                        </select>
+                        <button type="submit">Add</button>
+                    </form>
                     <form class="inline">
                         <button type="button" stm-action="saddle.refresh">Refresh status</button>
                         <button type="button" stm-action="saddle.unregister" stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
@@ -41,6 +58,33 @@
                     <p class="muted">No saddles registered. The dispatcher pairs queued tasks with idle saddles automatically.</p>
                 </section>
             </div>
+
+            <h3>Capabilities</h3>
+            <p class="muted">Tags the dispatcher routes by: a task is only assigned to a saddle carrying every
+                tag the task requires. Manage a saddle's tags on its card above; require tags on a task's page.</p>
+            <details>
+                <summary><strong>New capability</strong></summary>
+                <form stm-action="saddles.create_capability" class="inline">
+                    <label for="new-capability">Name</label>
+                    <input type="text" id="new-capability" name="name" required placeholder="gpu">
+                    <button type="submit">Create capability</button>
+                </form>
+            </details>
+            <table>
+                <thead><tr><th>Tag</th><th>Saddles carrying it</th><th>Tasks requiring it</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-capability-in="saddles.capabilities">
+                        <td><span class="badge" stm-text="capability.name">tag</span></td>
+                        <td stm-text="capability.saddle_count">0</td>
+                        <td stm-text="capability.task_count">0</td>
+                        <td><button type="button" stm-action="capability.delete"
+                                    stm-confirm="Delete this capability everywhere?">Delete</button></td>
+                    </tr>
+                    <tr stm-if="saddles.capabilities.length == 0">
+                        <td colspan="4" class="muted">No capabilities defined yet.</td>
+                    </tr>
+                </tbody>
+            </table>
         </div>
     </section>
 </body>

+ 19 - 0
src/pages/task_detail.html

@@ -456,6 +456,25 @@
                 <button type="submit">Attach</button>
             </form>
 
+            <h3>Required capabilities</h3>
+            <p class="muted">The dispatcher only assigns this task to a saddle carrying all of these tags.</p>
+            <p stm-if="task.capabilities.length > 0">
+                <span stm-for-capability-in="task.capabilities" stm-key="capability.link_id">
+                    <span class="badge"><span stm-text="capability.name">tag</span>
+                        <button type="button" class="inline chip-remove"
+                                stm-action="capability.detach" stm-confirm="Remove this requirement?">×</button></span>
+                </span>
+            </p>
+            <p stm-if="task.capabilities.length == 0" class="muted">No requirements — any idle saddle may run it.</p>
+            <form class="inline" stm-action="task.attach_capability" stm-if="task.capability_options.length > 0">
+                <label for="task-capability-target">Require capability</label>
+                <select id="task-capability-target" name="capability_id" required>
+                    <option stm-for-option-in="task.capability_options" stm-attribute.value="option.id"
+                             stm-text="option.name">capability</option>
+                </select>
+                <button type="submit">Require</button>
+            </form>
+
             <h3>Sessions</h3>
             <table>
                 <thead><tr><th>#</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th><th></th></tr></thead>

+ 7 - 1
src/pages/tasks.html

@@ -78,7 +78,13 @@
                             <span class="muted" stm-if="task.is_design_task"> · plan review</span>
                             <span class="muted" stm-if="task.parent_task_id > 0"> · subtask of
                                 <a stm-attribute.href="'/tasks/' + task.parent_task_id" stm-text="'#' + task.parent_task_id">#parent</a></span>
-                            <span class="muted" stm-if="task.ready"> · ready</span></td>
+                            <span class="muted" stm-if="task.ready"> · ready</span>
+                            <span stm-if="task.capabilities.length > 0">
+                                <span stm-for-capability-in="task.capabilities" stm-key="capability">
+                                    <span class="badge" stm-text="capability">tag</span>
+                                </span>
+                                <span class="muted" stm-if="task.waiting_for_saddle"> · waiting for a capable saddle</span>
+                            </span></td>
                         <td stm-text="task.project_name">project</td>
                         <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.status">status</span></td>
                         <td stm-text="task.priority">priority</td>

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


+ 109 - 0
test/web_stack_test.py

@@ -632,6 +632,115 @@ def main():
             check(len(raw_events) >= 3 and raw_events[0]["text"] == fake.PROMPT
                   and raw_events[0]["seq"] < raw_events[1]["seq"],
                   "terminal stream serves raw chunks in seq order (prompt first)")
+
+        print("== capabilities: tag-based routing ==")
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        act(auth, saddles_state["create_capability"], {"name": "gpu"})
+        act(auth, saddles_state["create_capability"], {"name": "bigmem"})
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        caps = {c["name"]: c for c in saddles_state["capabilities"]}
+        check(len(caps) == 2 and all(int(c["saddle_count"]) == 0 and int(c["task_count"]) == 0
+                                     for c in caps.values()),
+              "capabilities created and listed with zero usage")
+
+        def saddle_card(name):
+            rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                    if s["name"] == name]
+            return rows[0]
+
+        fake_card = saddle_card("fake-saddle")
+        check(fake_card["capabilities"] == [],
+              "saddle card starts with no capability chips")
+
+        # A task requiring 'gpu' waits while the only idle saddle lacks it;
+        # a later task without requirements still dispatches (skip-and-continue).
+        tasks_state = slot_public(auth, "/tasks", "tasks")
+        act(auth, tasks_state["create"], {
+            "project_id": "1",
+            "title": "GPU task",
+            "priority": "2",
+        })
+        gpu_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                        if t["title"] == "GPU task"][0])
+        detail = task_state(auth, gpu_task)
+        act(auth, detail["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
+        detail = task_state(auth, gpu_task)
+        check([c["name"] for c in detail["capabilities"]] == ["gpu"],
+              "task page lists the required capability")
+        # The only idle saddle lacks the tag, so the queue flags the wait
+        # (read before any other task claims the idle saddle).
+        queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                 if int(t["id"]) == gpu_task][0]
+        check(queue["status"] == "queued" and queue["capabilities"] == ["gpu"]
+              and queue.get("waiting_for_saddle") is True,
+              "queue row shows the waiting task with its tag and wait flag")
+
+        act(auth, tasks_state["create"], {
+            "project_id": "1",
+            "title": "Any-saddle task",
+            "priority": "3",
+        })
+        any_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                        if t["title"] == "Any-saddle task"][0])
+
+        check(wait_for(lambda: task_state(auth, any_task).get("status") != "queued" or False,
+                       timeout=30, what="uncapable task dispatches") is not None,
+              "task without requirements dispatches past the waiting task")
+        with open(os.path.join(WORK, "app.log")) as handle:
+            app_log = handle.read()
+        check("waits for a capable saddle" in app_log and "requires: gpu" in app_log,
+              "dispatcher logs the capability wait")
+
+        # Attaching the tag to the saddle releases the task.
+        fake_card = saddle_card("fake-saddle")
+        act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
+        fake_card = saddle_card("fake-saddle")
+        check([c["name"] for c in fake_card["capabilities"]] == ["gpu"],
+              "saddle card shows the attached chip")
+        check(wait_task_done(auth, gpu_task, timeout=60) is not None,
+              "task dispatches once the saddle carries the required tag")
+        queue = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                 if int(t["id"]) == gpu_task][0]
+        check(queue.get("waiting_for_saddle") is not True,
+              "wait flag clears once dispatched")
+
+        # Multi-tag: gpu + bigmem required, only gpu carried → waits; adding
+        # bigmem completes the superset.
+        act(auth, tasks_state["create"], {
+            "project_id": "1",
+            "title": "Big task",
+            "priority": "2",
+        })
+        big_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                        if t["title"] == "Big task"][0])
+        detail = task_state(auth, big_task)
+        act(auth, detail["attach_capability"], {"capability_id": str(int(caps["gpu"]["id"]))})
+        act(auth, detail["attach_capability"], {"capability_id": str(int(caps["bigmem"]["id"]))})
+        check(wait_for(lambda: ([t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                                 if int(t["id"]) == big_task][0].get("waiting_for_saddle")
+                                is True) or False, timeout=30, what="big task waits") is not None,
+              "task waits when the saddle's tags are not a superset")
+        fake_card = saddle_card("fake-saddle")
+        act(auth, fake_card["attach_capability"], {"capability_id": str(int(caps["bigmem"]["id"]))})
+        check(wait_task_done(auth, big_task, timeout=60) is not None,
+              "task dispatches once the superset is complete")
+
+        # Detach a requirement; delete a capability everywhere.
+        detail = task_state(auth, big_task)
+        act(auth, [c for c in detail["capabilities"] if c["name"] == "bigmem"][0]["detach"], {})
+        check([c["name"] for c in task_state(auth, big_task)["capabilities"]] == ["gpu"],
+              "detached requirement disappears from the task")
+        bigmem_row = [c for c in slot_public(auth, "/saddles", "saddles")["capabilities"]
+                      if c["name"] == "bigmem"][0]
+        act(auth, bigmem_row["delete"], {})
+        saddles_state = slot_public(auth, "/saddles", "saddles")
+        check(all(c["name"] != "bigmem" for c in saddles_state["capabilities"]),
+              "deleted capability gone from the catalogue")
+        check([c["name"] for c in task_state(auth, big_task)["capabilities"]] == ["gpu"],
+              "deleting a capability drops its task requirements")
+        check(all(c["name"] != "bigmem"
+                  for c in saddle_card("fake-saddle")["capabilities"]),
+              "deleting a capability drops it from saddles")
     finally:
         app.terminate()
         try: