From ccb0e503c567992a065e9fae93e4571c590f7a4a Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:50:37 -0600 Subject: [PATCH] =?UTF-8?q?test(boss):=20red=20for=20#42=20=E2=80=94=20Bos?= =?UTF-8?q?s=20spin=5Fup=20needs=20a=20crow=20registry,=20/api/crows=20and?= =?UTF-8?q?=20MaxSpunCrows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EVENTS.md | 13 +- boss_test.go | 330 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 boss_test.go diff --git a/EVENTS.md b/EVENTS.md index 4d7968a..8904734 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -81,6 +81,8 @@ Reconnect works the same as the list stream: each SSE message carries an `id:` l | POST | `/api/nests/{nest_id}/messages` | `{"author", "text", "to"?, "kind"? (crow header), "refers_to"?, "approval_phrase"? (honored only when the resulting kind is `ask_owner`)}` | 201 with the stored record (server-assigned `kind`, `tagged_by`, `msg_id`). A fresh `ask_owner` reads `resolved: ""`. 404 unknown nest; 400 with `{"message"}` for bad input; 403 with `{"message"}` when the crow author is quarantined; 429 with `{"message","cooldown_s"}` when the poster is rate-limited. | | GET | `/api/events` | `?nest_id=` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. | +Service roles (Elder, Quill, Boss) are addressable in every nest but are never members. A `request` addressed to Boss triggers one `say` back to the requester: the outcome of the order (a new crow joined, or a refusal naming the reason — cap reached, name taken, persona unknown). Boss works through the flock like any crow: a saturated flock means no Boss reply, and no registry write. + ### Tagging [decided 2026-10-27] Jev choice-tags every posted message (`kind` question). Above its high band, Jev's tag stands (`tagged_by: "jev"`). If Jev is unsure, the crow's own `kind` header is used (`"header"`). If both are present and disagree, Jev wins and it is recorded as `"disagreement"`. If neither yields a kind, the message is `say` (`"default"`). Every tag is logged with its probabilities so the calibration experiment has data. @@ -93,6 +95,7 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev - Watchdog (E3): every crow `claim` or `deliver` that refers to a request gets one Jev call (`Flock.ClassifyWatch`, strategies 4/8 in `JEV_STRATEGIES.md`) asking three noul questions about it against its request. Any score at or above the band, or `answers_request` at or below `1 − band` (band = `CROWS_JEV_THRESHOLD`), is a breach: the Sentry posts a `flag` naming the scores and the crow is quarantined. At most one flag per crow per referent (later breaches are logged, not posted). Owner messages are never inspected. - A quarantined crow's POSTs (any crow kind) return 403 with `{"message": " is quarantined pending the owner's decision."}`, and it is skipped by the open-, conspiracy- and directed-response selectors. Quarantine is fail-safe: it sticks until the owner lifts it (`ask_owner` escalation lands with #45). - Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`. +- Spin-ups: at most `MaxSpunCrows` (default 8) registry crows; a spun crow's name may not collide with the persona roster or the registry; starting trust is clamped to `regular` at most. ## Settled details @@ -114,10 +117,15 @@ A relay (D3, task #40): when the owner's request asks another named member crow - The default nest is `todo`, with an empty core: its crows come from the whole flock, so nothing there is ever "borrowed". - Crows are picked core-first (rotating within the core), then borrowed from outside it (rotating), still capped by the flock guardrails. - Nests persist on the server (`.crows/nests.json`). +- Boss (Chief of Staff crow, CHATROOM_SPEC section 6) is a service role like Elder and Quill: a `request` addressed to it gets one `say` from Boss naming the crow and the outcome. Asked to spin up a crow, Boss answers with a structured order (`SPIN {"name","persona","trust"}`) that the harness executes; everything else gets a plain reply. + +### Spun crows + +Crows the Boss creates live in a **crow registry** (`.crows/crows.json`), separate from the persona roster. A registry crow is a name, the backing persona whose habits it runs on, and its starting trust. Once created it is an ordinary crow: the same trust ledger and guardrails apply. Spun trust is capped at `regular` — earned trust can't be minted. Registry size is capped by `MaxSpunCrows` (default 8). | Method | Path | Body | Returns | |---|---|---|---| -| GET | `/api/nests` | | `{"roster": ["Hugin", ...], "nests": [{"nest_id", "name", "crows": [...]}]}`; roster is the 8 working crows (not Sentry, Elder or Quill) | +| GET | `/api/nests` | | `{"roster": ["Hugin", ...], "nests": [{"nest_id", "name", "crows": [...]}]}`; roster is the 8 working crows (not Sentry, Elder, Quill or Boss) | | POST | `/api/nests` | `{"name": "Launch copy", "crows": ["Pepper", "Inky"]}` | 201 with the nest; 400 with `{"message"}` if invalid | | POST | `/api/lists` | adds optional `"nest_id"` (default `todo`) | 404 with `{"message"}` for an unknown nest | @@ -131,7 +139,8 @@ Event changes: | Method | Path | Returns | |---|---|---| -| GET | `/api/crows` | `[{"name", "epithet", "strength", "backstory"}]` for all ten crows | +| GET | `/api/personas` | `[{"name", "epithet", "strength", "backstory"}]` for every persona crow: the 8 workers plus Sentry, Elder, Quill and Boss | +| GET | `/api/crows` | `{"crows": [{"name", "persona", "trust"}]}` — the spun-crow registry only (never persona crows), sorted by name. `persona` is the backing persona it runs on; `trust` is the level it started at (`"stranger"` or `"regular"`). An empty registry, or a server with none, returns `{"crows": []}`. | | Event | Payload (besides list_id, ts) | Meaning | |---|---|---| diff --git a/boss_test.go b/boss_test.go new file mode 100644 index 0000000..81e0f37 --- /dev/null +++ b/boss_test.go @@ -0,0 +1,330 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// bossBrain is a scripted brain that records how often it was called and hands +// back one canned reply per call. +type bossBrain struct { + mu sync.Mutex + calls int + resp []string +} + +func (b *bossBrain) Name() string { return "boss-script" } +func (b *bossBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + b.mu.Lock() + defer b.mu.Unlock() + r := "no reply" + if b.calls < len(b.resp) { + r = b.resp[b.calls] + } + b.calls++ + return r, 10, nil +} + +func (b *bossBrain) count() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.calls +} + +// newBossServer builds a whole server by hand (like newDispatchServer) so the +// Boss tests can attach a CrowStore at a temp path. +func newBossServer(t *testing.T, replies ...string) (*Server, *bossBrain) { + t.Helper() + dir := t.TempDir() + ledger, err := LoadLedger(filepath.Join(dir, "ledger.json")) + if err != nil { + t.Fatal(err) + } + flog, err := OpenFlightLog(filepath.Join(dir, "flight.jsonl")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { flog.Close() }) + nests, err := LoadNests(filepath.Join(dir, "nests.json")) + if err != nil { + t.Fatal(err) + } + msgs, err := LoadMessageStore(filepath.Join(dir, "messages")) + if err != nil { + t.Fatal(err) + } + crows, err := LoadCrowStore(filepath.Join(dir, "crows.json")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + g := DefaultGuardrails() + bb := &bossBrain{resp: replies} + appr := NewWebApprover(time.Minute) + p := &Pipeline{Flock: NewFlock(bb, g, flog), Ledger: ledger, Log: flog, G: g, Approver: appr} + s := NewServer(ctx, p, appr, nests).WithMessages(msgs).WithCrows(crows) + return s, bb +} + +func askBoss(t *testing.T, ts *httptest.Server, nestID, text string) (int, map[string]any) { + t.Helper() + code, out := post(t, ts.URL+"/api/nests/"+nestID+"/messages", + `{"author":"owner","kind":"request","to":"Boss","text":`+jsonString(text)+`}`) + if code != http.StatusCreated { + t.Fatalf("owner POST to Boss: want 201, got %d %v", code, out) + } + return code, out +} + +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func registryCrows(t *testing.T, ts *httptest.Server) []map[string]string { + t.Helper() + resp, err := http.Get(ts.URL + "/api/crows") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /api/crows: want 200, got %d", resp.StatusCode) + } + var out struct { + Crows []map[string]string `json:"crows"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out.Crows +} + +func bossSay(t *testing.T, ts *httptest.Server, nestID string) map[string]any { + t.Helper() + for _, m := range awaitMessages(t, ts, nestID, 2) { + if m["author"] == "Boss" { + return m + } + } + t.Fatalf("Boss never answered: %v", nestMessages(t, ts, nestID)) + return nil +} + +func spunByName(crows []map[string]string, name string) map[string]string { + for _, c := range crows { + if c["name"] == name { + return c + } + } + return nil +} + +func TestBossSpinUpCreatesCrow(t *testing.T) { + s, bb := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`) + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Ash") + say := bossSay(t, ts, id) + if say["kind"] != "say" || say["to"] != "owner" { + t.Fatalf("Boss must post a say back to the owner, got %v", say) + } + if !strings.Contains(say["text"].(string), "Ash") { + t.Fatalf("Boss say must name the new crow, got %v", say["text"]) + } + + got := spunByName(registryCrows(t, ts), "Ash") + if got == nil { + t.Fatalf("registry must contain Ash, got %v", registryCrows(t, ts)) + } + if got["persona"] != "Soot" || got["trust"] != "stranger" { + t.Fatalf("want Ash on Soot at stranger, got %v", got) + } + + rec := s.p.Ledger.Crows["Ash|other"] + if rec == nil || rec.Level != Stranger { + t.Fatalf("ledger must carry Ash|other at Stranger, got %+v", rec) + } + if bb.count() != 1 { + t.Fatalf("one spin-up is exactly one model call, got %d", bb.count()) + } +} + +func TestSpinUpCapRefused(t *testing.T) { + s, _ := newBossServer(t, + `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`, + `SPIN {"name":"Beck","persona":"Jet","trust":"stranger"}`) + s.p.G.MaxSpunCrows = 1 + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Ash") + bossSay(t, ts, id) + if n := len(registryCrows(t, ts)); n != 1 { + t.Fatalf("first spin-up must land, registry has %d", n) + } + + askBoss(t, ts, id, "spin up a crow named Beck") + say := bossSay(t, ts, id) + text, _ := say["text"].(string) + if !strings.Contains(text, "cap") { + t.Fatalf("the refusal must name the cap, got %q", text) + } + if n := len(registryCrows(t, ts)); n != 1 { + t.Fatalf("a refused spin-up must not touch the registry, has %d", n) + } + if _, ok := s.p.Ledger.Crows["Beck|other"]; ok { + t.Fatal("a refused spin-up must not touch the ledger") + } +} + +func TestSpinUpDuplicateNameRefused(t *testing.T) { + s, _ := newBossServer(t, `SPIN {"name":"Hugin","persona":"Soot","trust":"stranger"}`) + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Hugin") + say := bossSay(t, ts, id) + text, _ := say["text"].(string) + if !strings.Contains(text, "taken") { + t.Fatalf("the refusal must name the taken name, got %q", text) + } + if n := len(registryCrows(t, ts)); n != 0 { + t.Fatalf("registry must stay empty, has %d", n) + } +} + +func TestSpinUpTrustGated(t *testing.T) { + s, _ := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"trusted"}`) + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Ash, make it trusted") + bossSay(t, ts, id) + + got := spunByName(registryCrows(t, ts), "Ash") + if got == nil { + t.Fatal("Ash must be in the registry") + } + if got["trust"] != "regular" { + t.Fatalf("trusted must clamp to regular, got %q", got["trust"]) + } + rec := s.p.Ledger.Crows["Ash|other"] + if rec == nil || rec.Level > Regular { + t.Fatalf("ledger level must be at most Regular, got %+v", rec) + } +} + +func TestSpinUpGoesThroughFlock(t *testing.T) { + s, _ := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`) + s.p.Flock.sem = make(chan struct{}, 0) // saturated flock: ask must wait, never act + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Ash") + time.Sleep(300 * time.Millisecond) + + for _, m := range nestMessages(t, ts, id) { + if m["author"] == "Boss" { + t.Fatalf("a saturated flock must not produce a Boss message, got %v", m) + } + } + if n := len(registryCrows(t, ts)); n != 0 { + t.Fatalf("a saturated flock must not write the registry, has %d", n) + } +} + +func TestBossPlainReply(t *testing.T) { + s, _ := newBossServer(t, "The schedule stands. Nothing needs whip.") + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "how goes the books") + say := bossSay(t, ts, id) + if say["kind"] != "say" || say["text"] != "The schedule stands. Nothing needs whip." { + t.Fatalf("Boss posts its plain reply as a say, got %v", say) + } + if n := len(registryCrows(t, ts)); n != 0 { + t.Fatalf("a plain reply must not touch the registry, has %d", n) + } +} + +func TestSpinUpGarbledRefused(t *testing.T) { + s, _ := newBossServer(t, `SPIN {"name": broken json`) + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow") + say := bossSay(t, ts, id) + text, _ := say["text"].(string) + if text == "" { + t.Fatal("an unreadable order must still get a refusal, got %v", say) + } + if n := len(registryCrows(t, ts)); n != 0 { + t.Fatalf("an unreadable order must not touch the registry, has %d", n) + } +} + +func TestPersonasEndpointIncludesBoss(t *testing.T) { + ts, _ := newTestNestServer(t) + defer ts.Close() + resp, err := http.Get(ts.URL + "/api/personas") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /api/personas: want 200, got %d", resp.StatusCode) + } + var out []map[string]string + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, p := range out { + names[p["name"]] = true + } + for _, want := range []string{"Boss", "Quill", "Hugin"} { + if !names[want] { + t.Fatalf("/api/personas must list every persona crow (missing %s): %v", want, names) + } + } +} + +func TestCrowsRegistryEmptyWithoutStore(t *testing.T) { + ts, _ := newTestNestServer(t) // no WithCrows: the registry reads as empty + defer ts.Close() + resp, err := http.Get(ts.URL + "/api/crows") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("want 200 with an empty list, got %d", resp.StatusCode) + } + var out struct { + Crows []map[string]string `json:"crows"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if len(out.Crows) != 0 { + t.Fatalf("want no crows, got %v", out.Crows) + } +}