Bug A + Bug B: spawn-worker default groups + list-members surface
Bug A — spawn-worker default groups not applied. The tool description claimed 'defaults to APIARYWORKERGROUP server-side', but apiary sent NO groups: segment when the caller omitted the arg, so the new bot landed in zero groups. Move default-resolution apiary-side: an omitted (#f) or empty-string groups arg falls back to APIARYWORKERGROUP. Empty string is treated as 'not provided' so an MCP caller passing groups: '' still hits the env fallback. If the env var is also unset/empty, no groups: segment is sent (documented in the tool description).
Bug B — list-members queries the wrong surface. The v0.1.0 implementation issued bot list against EnclaveServ, which is filtered by ownership: a leader bot like Quinn doesn't own its workers (David does), so the response was always 'You own no bots.' even with active subordinates. Switch to bot list-reports-to <self-nick> (added in enclave v0.2.2), which returns every bot whose reports-to matches the caller. Update the tool description and main.sgl's leader-mode instructions table to match. Also swap the result-formatter from (apply string-append ...) to a string-join fold so long subordinate lists don't re-trip the apply-arity ceiling that bit markdown-irc in v0.1.3.
Bug B is now load-bearing for the leader-worker transport cutover (see topics/leader-worker-apiary-transport § Update 2026-05-05) — the per-domain counter derivation reads list-members output and falls back to a hash-based nick if the call returns no subordinates.
New helpers env-or-false and or-empty-env exported from (apiary tools) for direct unit-test exercise. tools.sgl picks up (sigil process) for getenv (was missing — (sigil env) doesn't re-export it). All 90 unit tests green.
src/apiary/main.sgl | 3 ++-
src/apiary/tools.sgl | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
test/test-tools.sgl | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 113 insertions(+), 14 deletions(-)src/apiary/main.sglmodified
"one-time token). Pass it to the worker as APIARY_TOKEN.\n" " revoke-worker tear down a worker bot\n" " rotate-token rotate a bot's SASL token\n" " list-members list bots known to EnclaveServ\n" " list-members list this leader's subordinates " "(bots whose reports-to = your nick)\n" " send-channel post into the channel; pass mention: " "<nick> to address. Multi-line text becomes one grouped " "draft/multiline batch — phones see one notification, not N.\n"src/apiary/tools.sglmodified
(sigil string) (sigil struct) (sigil async) (sigil process) (sigil log) (sigil irc message) (sigil mcp server) parse-mention-prefix format-mention trusted-sender? env-or-false or-empty-env enclave-bridge-start! enclave-bridge-send-channel! (let ((entry (assoc key alist))) (if entry (cdr entry) default))) ;; Read an env var and return its value when non-empty, else #f. ;; Matches main.sgl's `env-or-false`; duplicated here so tool ;; callbacks (which run after main has already read its env) can ;; resolve their own per-call defaults. (define (env-or-false name) (let ((v (getenv name))) (if (and (string? v) (> (string-length v) 0)) v #f))) ;; Resolve a "use the caller's value, else fall back to env" pair: ;; treats #f and the empty string identically as "not provided" ;; (the latter so an MCP client passing `groups: ""` still gets ;; the env default rather than an explicit no-group request). (define (or-empty-env val env-name) (cond ((and (string? val) (> (string-length val) 0)) val) (else (env-or-false env-name)))) (define (nick-char? c) (or (char-alphabetic? c) (char-numeric? c) (expires-in . ((type . "string") (description . "Token lifetime, e.g. '4h', '12h', '30m'. Default '12h' (operator ceiling 24h)."))) (groups . ((type . "string") (description . "Comma-separated Enclave groups. Defaults to APIARY_WORKER_GROUP server-side."))) (description . "Comma-separated Enclave groups. Omitted or empty falls back to apiary's APIARY_WORKER_GROUP env var; if that is also unset, no groups: is sent and the new bot has no group memberships."))) (reports-to . ((type . "string") (description . "Nick the new bot reports up to. Default: this leader's nick."))))) (required . ("nick"))) (let* ((nick (dict-ref args nick: #f)) (auto-join (dict-ref args auto-join: #f)) (expires-in (or (dict-ref args expires-in: #f) "12h")) (groups (dict-ref args groups: #f)) ;; Resolution order for groups: explicit arg → env ;; APIARY_WORKER_GROUP → no groups: segment at all. ;; Empty string is treated the same as unset so an ;; MCP caller that passes `groups: ""` still gets ;; the env default. (groups (or-empty-env (dict-ref args groups: #f) "APIARY_WORKER_GROUP")) (reports-to-arg (dict-ref args reports-to: #f)) (conn (enclave-bridge-state-conn state)) (default-reports-to (and conn ;; ---- list-members (leader only) ---- ;; ;; v0.1.0 implementation: issues `bot list` against EnclaveServ ;; and returns the body. Channel-roster proper (NAMES + group ;; membership) is a future enhancement when the apiary event loop ;; grows numeric-reply capture. ;; Surfaces this leader's subordinates by querying EnclaveServ's ;; `bot list-reports-to <self-nick>`. The earlier implementation ;; called `bot list` which is filtered by ownership — and a ;; leader bot like Quinn doesn't *own* its workers (David does), ;; so the response was always "You own no bots." even when the ;; leader had active subordinates. `bot list-reports-to` ;; (added in enclave v0.2.2) is the right surface for the ;; leader/worker hierarchy: it returns every bot whose ;; `reports-to` matches the caller, regardless of ownership. (define (register-list-members-tool! server state) (mcp-server-register-tool! server "list-members" "List the bots known to EnclaveServ. v0.1.0 returns the EnclaveServ `bot list` response; full channel roster + group membership is a future enhancement." "List this leader's subordinates — every bot whose `reports-to` is set to this leader's nick. Backed by EnclaveServ's `bot list-reports-to <self-nick>` (added in enclave v0.2.2). Returns just-spawned workers regardless of who owns them, so a leader sees its hive even when the workers were registered under the human's account." '((type . "object") (properties . ()) (required . ())) (lambda (args) (let ((conn (enclave-bridge-state-conn state))) (let* ((conn (enclave-bridge-state-conn state)) (self-nick (and conn (enclave-conn-current-nick conn)))) (cond ((not conn) "Error: enclave bridge not connected") ((not self-nick) "Error: own nick not yet known") (else (let* ((result (enclave-services-call conn "bot list")) (let* ((cmd (string-append "bot list-reports-to " self-nick)) (result (enclave-services-call conn cmd)) (status (assoc-or "status" result "?")) (body (assoc-or "body" result "")) (lines (assoc-or "body-lines" result '()))) ((string=? status "ok") (cond ((and (list? lines) (not (null? lines))) (apply string-append (map (lambda (l) (string-append l "\n")) lines))) (else (or body "(no members)")))) ;; Use string-join for variadic concatenation — ;; `apply string-append` over a long list of ;; lines would re-trip the Sigil-VM apply-arity ;; ceiling that bit markdown-irc in v0.1.3. (string-join (map (lambda (l) (string-append l "\n")) lines) "")) (else (or body "(no subordinates)")))) (else (format-simple-result result body))))))))))test/test-tools.sglmodified
(import (sigil test) (sigil string) (sigil env) (sigil process) (sigil mcp server) (sigil mcp protocol) (apiary tools)) (assert-true (string? result)) (assert-true (string-contains? result "not connected"))))));; ============================================================;; Bug A — spawn-worker groups defaulting;;;; or-empty-env (caller-arg env-name) is the resolution shape used;; by spawn-worker for its `groups` argument: explicit non-empty;; arg wins, else the env var, else #f. Empty string is treated;; as "not provided" (the same as #f) so an MCP caller passing;; `groups: ""` falls back to the env default rather than;; sending an explicit no-group request.;; ============================================================(test-group "or-empty-env (groups defaulting helper)" (test "non-empty arg wins over env" (setenv! "APIARY_TEST_GROUP_AB" "from-env") (assert-equal "from-arg" (or-empty-env "from-arg" "APIARY_TEST_GROUP_AB")) (setenv! "APIARY_TEST_GROUP_AB" "")) (test "missing arg falls back to env" (setenv! "APIARY_TEST_GROUP_AC" "ops-workers") (assert-equal "ops-workers" (or-empty-env #f "APIARY_TEST_GROUP_AC")) (setenv! "APIARY_TEST_GROUP_AC" "")) (test "empty-string arg falls back to env (NOT treated as explicit)" (setenv! "APIARY_TEST_GROUP_AD" "ops-workers") (assert-equal "ops-workers" (or-empty-env "" "APIARY_TEST_GROUP_AD")) (setenv! "APIARY_TEST_GROUP_AD" "")) (test "missing arg + unset env returns #f" ;; Use a never-touched name so it's reliably unset. (assert-false (or-empty-env #f "APIARY_TEST_GROUP_NEVER_SET"))) (test "missing arg + empty-string env returns #f" (setenv! "APIARY_TEST_GROUP_AF" "") (assert-false (or-empty-env #f "APIARY_TEST_GROUP_AF"))) (test "non-string arg falls back to env" (setenv! "APIARY_TEST_GROUP_AG" "fallback") (assert-equal "fallback" (or-empty-env 42 "APIARY_TEST_GROUP_AG")) (setenv! "APIARY_TEST_GROUP_AG" "")))(test-group "env-or-false" (test "returns env value when set and non-empty" (setenv! "APIARY_TEST_EOB" "yes") (assert-equal "yes" (env-or-false "APIARY_TEST_EOB")) (setenv! "APIARY_TEST_EOB" "")) (test "returns #f when unset" (assert-false (env-or-false "APIARY_TEST_EOB_NEVER_SET"))) (test "returns #f when set to empty string" (setenv! "APIARY_TEST_EOB_EMPTY" "") (assert-false (env-or-false "APIARY_TEST_EOB_EMPTY"))))(test-group "trusted-sender?" (test "owner is trusted (case-insensitive)" (let ((s (make-enclave-bridge-state)))