Commitde4f1530Recorded5 May 2026Repositoryapiary

Bug A + Bug B: spawn-worker default groups + list-members surface

Message

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.

Changed
 src/apiary/main.sgl  |  3 ++-
 src/apiary/tools.sgl | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
 test/test-tools.sgl  | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 113 insertions(+), 14 deletions(-)
Diff
src/apiary/main.sglmodified
@@ -110,7 +110,8 @@
110
"one-time token). Pass it to the worker as APIARY_TOKEN.\n"
111
" revoke-worker tear down a worker bot\n"
112
" rotate-token rotate a bot's SASL token\n"
113
" list-members list bots known to EnclaveServ\n"
+113
" list-members list this leader's subordinates "
+114
"(bots whose reports-to = your nick)\n"
115
" send-channel post into the channel; pass mention: "
116
"<nick> to address. Multi-line text becomes one grouped "
117
"draft/multiline batch — phones see one notification, not N.\n"
src/apiary/tools.sglmodified
@@ -28,6 +28,7 @@
28
(sigil string)
29
(sigil struct)
30
(sigil async)
+31
(sigil process)
32
(sigil log)
33
(sigil irc message)
34
(sigil mcp server)
@@ -55,6 +56,8 @@
56
parse-mention-prefix
57
format-mention
58
trusted-sender?
+59
env-or-false
+60
or-empty-env
61
62
enclave-bridge-start!
63
enclave-bridge-send-channel!
@@ -151,6 +154,23 @@
154
(let ((entry (assoc key alist)))
155
(if entry (cdr entry) default)))
156
+157
;; Read an env var and return its value when non-empty, else #f.
+158
;; Matches main.sgl's `env-or-false`; duplicated here so tool
+159
;; callbacks (which run after main has already read its env) can
+160
;; resolve their own per-call defaults.
+161
(define (env-or-false name)
+162
(let ((v (getenv name)))
+163
(if (and (string? v) (> (string-length v) 0)) v #f)))
+164
+165
;; Resolve a "use the caller's value, else fall back to env" pair:
+166
;; treats #f and the empty string identically as "not provided"
+167
;; (the latter so an MCP client passing `groups: ""` still gets
+168
;; the env default rather than an explicit no-group request).
+169
(define (or-empty-env val env-name)
+170
(cond
+171
((and (string? val) (> (string-length val) 0)) val)
+172
(else (env-or-false env-name))))
+173
174
(define (nick-char? c)
175
(or (char-alphabetic? c)
176
(char-numeric? c)
@@ -714,7 +734,7 @@
734
(expires-in . ((type . "string")
735
(description . "Token lifetime, e.g. '4h', '12h', '30m'. Default '12h' (operator ceiling 24h).")))
736
(groups . ((type . "string")
717
(description . "Comma-separated Enclave groups. Defaults to APIARY_WORKER_GROUP server-side.")))
+737
(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.")))
738
(reports-to . ((type . "string")
739
(description . "Nick the new bot reports up to. Default: this leader's nick.")))))
740
(required . ("nick")))
@@ -722,7 +742,13 @@
742
(let* ((nick (dict-ref args nick: #f))
743
(auto-join (dict-ref args auto-join: #f))
744
(expires-in (or (dict-ref args expires-in: #f) "12h"))
725
(groups (dict-ref args groups: #f))
+745
;; Resolution order for groups: explicit arg → env
+746
;; APIARY_WORKER_GROUP → no groups: segment at all.
+747
;; Empty string is treated the same as unset so an
+748
;; MCP caller that passes `groups: ""` still gets
+749
;; the env default.
+750
(groups (or-empty-env (dict-ref args groups: #f)
+751
"APIARY_WORKER_GROUP"))
752
(reports-to-arg (dict-ref args reports-to: #f))
753
(conn (enclave-bridge-state-conn state))
754
(default-reports-to (and conn
@@ -794,23 +820,31 @@
820
821
;; ---- list-members (leader only) ----
822
;;
797
;; v0.1.0 implementation: issues `bot list` against EnclaveServ
798
;; and returns the body. Channel-roster proper (NAMES + group
799
;; membership) is a future enhancement when the apiary event loop
800
;; grows numeric-reply capture.
+823
;; Surfaces this leader's subordinates by querying EnclaveServ's
+824
;; `bot list-reports-to <self-nick>`. The earlier implementation
+825
;; called `bot list` which is filtered by ownership — and a
+826
;; leader bot like Quinn doesn't *own* its workers (David does),
+827
;; so the response was always "You own no bots." even when the
+828
;; leader had active subordinates. `bot list-reports-to`
+829
;; (added in enclave v0.2.2) is the right surface for the
+830
;; leader/worker hierarchy: it returns every bot whose
+831
;; `reports-to` matches the caller, regardless of ownership.
832
(define (register-list-members-tool! server state)
833
(mcp-server-register-tool! server
834
"list-members"
804
"List the bots known to EnclaveServ. v0.1.0 returns the EnclaveServ `bot list` response; full channel roster + group membership is a future enhancement."
+835
"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."
836
'((type . "object")
837
(properties . ())
838
(required . ()))
839
(lambda (args)
809
(let ((conn (enclave-bridge-state-conn state)))
+840
(let* ((conn (enclave-bridge-state-conn state))
+841
(self-nick (and conn (enclave-conn-current-nick conn))))
842
(cond
843
((not conn) "Error: enclave bridge not connected")
+844
((not self-nick) "Error: own nick not yet known")
845
(else
813
(let* ((result (enclave-services-call conn "bot list"))
+846
(let* ((cmd (string-append "bot list-reports-to " self-nick))
+847
(result (enclave-services-call conn cmd))
848
(status (assoc-or "status" result "?"))
849
(body (assoc-or "body" result ""))
850
(lines (assoc-or "body-lines" result '())))
@@ -818,10 +852,15 @@
852
((string=? status "ok")
853
(cond
854
((and (list? lines) (not (null? lines)))
821
(apply string-append
822
(map (lambda (l) (string-append l "\n"))
823
lines)))
824
(else (or body "(no members)"))))
+855
;; Use string-join for variadic concatenation —
+856
;; `apply string-append` over a long list of
+857
;; lines would re-trip the Sigil-VM apply-arity
+858
;; ceiling that bit markdown-irc in v0.1.3.
+859
(string-join
+860
(map (lambda (l) (string-append l "\n"))
+861
lines)
+862
""))
+863
(else (or body "(no subordinates)"))))
864
(else
865
(format-simple-result result body))))))))))
866
test/test-tools.sglmodified
@@ -1,5 +1,7 @@
1
(import (sigil test)
2
(sigil string)
+3
(sigil env)
+4
(sigil process)
5
(sigil mcp server)
6
(sigil mcp protocol)
7
(apiary tools))
@@ -271,6 +273,63 @@
273
(assert-true (string? result))
274
(assert-true (string-contains? result "not connected"))))))
275
+276
;; ============================================================
+277
;; Bug A — spawn-worker groups defaulting
+278
;;
+279
;; or-empty-env (caller-arg env-name) is the resolution shape used
+280
;; by spawn-worker for its `groups` argument: explicit non-empty
+281
;; arg wins, else the env var, else #f. Empty string is treated
+282
;; as "not provided" (the same as #f) so an MCP caller passing
+283
;; `groups: ""` falls back to the env default rather than
+284
;; sending an explicit no-group request.
+285
;; ============================================================
+286
+287
(test-group "or-empty-env (groups defaulting helper)"
+288
(test "non-empty arg wins over env"
+289
(setenv! "APIARY_TEST_GROUP_AB" "from-env")
+290
(assert-equal "from-arg"
+291
(or-empty-env "from-arg" "APIARY_TEST_GROUP_AB"))
+292
(setenv! "APIARY_TEST_GROUP_AB" ""))
+293
+294
(test "missing arg falls back to env"
+295
(setenv! "APIARY_TEST_GROUP_AC" "ops-workers")
+296
(assert-equal "ops-workers"
+297
(or-empty-env #f "APIARY_TEST_GROUP_AC"))
+298
(setenv! "APIARY_TEST_GROUP_AC" ""))
+299
+300
(test "empty-string arg falls back to env (NOT treated as explicit)"
+301
(setenv! "APIARY_TEST_GROUP_AD" "ops-workers")
+302
(assert-equal "ops-workers"
+303
(or-empty-env "" "APIARY_TEST_GROUP_AD"))
+304
(setenv! "APIARY_TEST_GROUP_AD" ""))
+305
+306
(test "missing arg + unset env returns #f"
+307
;; Use a never-touched name so it's reliably unset.
+308
(assert-false (or-empty-env #f "APIARY_TEST_GROUP_NEVER_SET")))
+309
+310
(test "missing arg + empty-string env returns #f"
+311
(setenv! "APIARY_TEST_GROUP_AF" "")
+312
(assert-false (or-empty-env #f "APIARY_TEST_GROUP_AF")))
+313
+314
(test "non-string arg falls back to env"
+315
(setenv! "APIARY_TEST_GROUP_AG" "fallback")
+316
(assert-equal "fallback"
+317
(or-empty-env 42 "APIARY_TEST_GROUP_AG"))
+318
(setenv! "APIARY_TEST_GROUP_AG" "")))
+319
+320
(test-group "env-or-false"
+321
(test "returns env value when set and non-empty"
+322
(setenv! "APIARY_TEST_EOB" "yes")
+323
(assert-equal "yes" (env-or-false "APIARY_TEST_EOB"))
+324
(setenv! "APIARY_TEST_EOB" ""))
+325
+326
(test "returns #f when unset"
+327
(assert-false (env-or-false "APIARY_TEST_EOB_NEVER_SET")))
+328
+329
(test "returns #f when set to empty string"
+330
(setenv! "APIARY_TEST_EOB_EMPTY" "")
+331
(assert-false (env-or-false "APIARY_TEST_EOB_EMPTY"))))
+332
333
(test-group "trusted-sender?"
334
(test "owner is trusted (case-insensitive)"
335
(let ((s (make-enclave-bridge-state)))