Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4317f6a73 | |||
| b2e4901264 | |||
| af67ad995c | |||
| 719e4d605b | |||
| c5c8ecf9d5 | |||
| 4f16ba588d |
@@ -106,7 +106,7 @@ POST: [POST-002 return_value] AsyncIterator yields ≥1 event ending in exactly
|
||||
POST: [POST-003 state_change] every yielded Event has a populated sse_id with both fields >= 1 -- assert all(e.sse_id.turn_id >= 1 and e.sse_id.seq >= 1 for e in events)
|
||||
ERROR_ROUTING:
|
||||
httpx.HTTPStatusError:
|
||||
local_handling: re-raise as SseConnectFailed(status=resp.status_code, body=resp.read()[:1024]) — server returned non-2xx before stream started (e.g., 404 session_not_found)
|
||||
local_handling: re-raise as SseConnectFailed(status=resp.status_code, body=resp.read()[:1024]) — server returned non-2xx before stream started (e.g., 404 session_not_found). EXCEPT the two eager turn-launch failures (Worldtree v1.0.0b1 #331), checked BEFORE raise_for_status and raised as typed SseConnectFailed SUBCLASSES carrying error_code: 409 -> AgentNotAvailable (agent unavailable; pre-b1 this was a 200 + in-stream `error` event), 503 -> TurnLaunchUnavailable (transient turn-launch failure; retryable=True). Subclassing keeps existing `except SseConnectFailed` handlers working with zero changes.
|
||||
flow_control: abort
|
||||
state_recovery: none (no events yielded yet)
|
||||
httpx.ReadError | httpx.RemoteProtocolError | httpx.ReadTimeout:
|
||||
@@ -129,7 +129,8 @@ ERROR_ROUTING:
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
|
||||
2. [sequential, flexibility=prescriptive] Open SSE connection via httpx_sse.aconnect_sse with method="POST", url=f"/sessions/{session_id}/messages", json={"content": content}
|
||||
ON httpx.HTTPStatusError before stream opens:
|
||||
2a. [branch, flexibility=prescriptive] IF response.status_code in (409, 503) (b1 #331 eager turn-launch failures): read the body, parse (error_code, message) from the `{"detail": {...}}` envelope OR a flat `{error_code, message}` body (status-derived default code when absent), then RAISE AgentNotAvailable (409) / TurnLaunchUnavailable (503).
|
||||
ON httpx.HTTPStatusError before stream opens (any other non-2xx):
|
||||
RAISE SseConnectFailed
|
||||
3. [loop, flexibility=prescriptive] FOR EACH sse_event in event_source.aiter_sse():
|
||||
0. [branch, flexibility=prescriptive] IF sse_event.data == "":
|
||||
@@ -162,6 +163,9 @@ TESTS:
|
||||
error_terminal [error]: mock emits one `text` then `error` with `error_code: "llm_output_invalid"` → consumer yields Text then Error; iteration ends; Error.message and Error.error_code are populated
|
||||
cancelled_terminal [error]: mock emits `cancelled` with phase=cancelled → consumer yields Cancelled with turn_id; iteration ends
|
||||
session_not_found [error]: mock returns 404 before stream opens → consumer raises SseConnectFailed(status=404)
|
||||
eager_409_agent_not_available [error]: mock returns 409 {detail:{error_code:"agent_not_available", message}} before stream → consumer raises AgentNotAvailable(status=409, error_code="agent_not_available", retryable absent); isinstance SseConnectFailed
|
||||
eager_503_retryable [error]: mock returns 503 before stream → consumer raises TurnLaunchUnavailable(status=503, retryable=True); isinstance SseConnectFailed
|
||||
eager_409_non_json_body [adversarial]: mock returns 409 with a non-JSON body → consumer raises AgentNotAvailable with the status-derived default error_code "agent_not_available"
|
||||
malformed_id_no_seq [adversarial]: mock event has `id: 42` (missing `:seq`) → consumer raises MalformedSseId; no event yielded
|
||||
malformed_id_alpha [adversarial]: mock event has `id: foo:bar` (non-integer parts) → consumer raises MalformedSseId
|
||||
turn_id_flip [adversarial]: mock emits text events with ids `42:1` then `99:2` → consumer raises TurnIdFlip; only the first event was yielded
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.sessions + ratatoskr.provider (+ cli/tui/web trigger surfaces)"
|
||||
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes is PARKED — vNext); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
|
||||
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390; `combined`→:8392 added post-#17 — the #18 composite, the web default) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes was PARKED at #17 — later shipped as #18 and surfaced in the web bind as the `combined` plane); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
|
||||
depends_on:
|
||||
- "httpx"
|
||||
- "ratatoskr.sessions"
|
||||
@@ -92,8 +92,8 @@ async def create_session(
|
||||
|
||||
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""'memory'->:8391, 'affect'->:8390 → f'http://{base_host}:{port}'. The
|
||||
Worldtree-visible base URL. See FN endpoint_for_plane."""
|
||||
"""'memory'->:8391, 'affect'->:8390, 'combined'->:8392 (#18 composite) →
|
||||
f'http://{base_host}:{port}'. The Worldtree-visible base URL. See FN endpoint_for_plane."""
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -218,12 +218,12 @@ STEPS:
|
||||
|
||||
```contract
|
||||
FN endpoint_for_plane(plane: str, base_host: str) -> str
|
||||
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390).
|
||||
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390, combined->:8392 — the #18 composite both-plane endpoint, surfaced post-#17).
|
||||
|
||||
PRE: [PRE-001 hard] plane in {"memory", "affect"} -- else ValueError
|
||||
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) -- assert
|
||||
PRE: [PRE-001 hard] plane in {"memory", "affect", "combined"} -- else ValueError
|
||||
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) / 8392 (combined) -- assert
|
||||
STEPS:
|
||||
1. port = 8391 if plane == "memory" else 8390
|
||||
1. port = {"memory": 8391, "affect": 8390, "combined": 8392}[plane]
|
||||
2. return the Worldtree-VISIBLE base URL (not client loopback); HTTPS relaxation is allowlist-side, not a URL concern
|
||||
```
|
||||
|
||||
|
||||
+57
-39
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — ratatoskr
|
||||
|
||||
_Last updated: 2026-06-18_
|
||||
_Last updated: 2026-06-20_
|
||||
|
||||
This file captures durable intent and supporting evidence (goals, decisions,
|
||||
foot-gun warnings, in-flight state) across context resets. Read it at session
|
||||
@@ -39,39 +39,41 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-06-19:_
|
||||
_As of 2026-06-20:_
|
||||
|
||||
**#18 DELIVERABLE 2 SHIPPED + PUSHED — the persona-telemetry gap is CLOSED.** The web
|
||||
pane now renders live PAD/valence for Tier-3 agents from OUR `:8390` affect store
|
||||
(`v0.17.14`, `39eebd1`, suite **482 green**, **pushed to origin**). Three pieces:
|
||||
provider read route `GET /affect/state/{agent_id}` (non-bifrost, added to the affect app
|
||||
via `app.add_route` — keeps `/bifrost/*` top-level + op-feed-skipped); web proxy
|
||||
`GET /api/affect/{agent_id}` (server-supplied `end_user_id`, colon-id `quote()`'d,
|
||||
`RATATOSKR_AFFECT_READ_URL` config, default `127.0.0.1:8390`); pane affect-render
|
||||
(`renderAffectPane`/`loadAffect`, honest pad+valence+emitted_at, labelled "affect", NO
|
||||
fabricated Tier-1 fields, explicit empty-state, 2s post-turn poll). Live-smoke + a
|
||||
Playwright DOM check PROVEN against real sindra/vuong PAD. The push also published the
|
||||
previously-held **#17** arc (`v0.17.8`→`v0.17.13`) — origin/main is now fully caught up.
|
||||
**#17 and #18 BOTH CLOSED — the composite both-plane binding is fully proven.** #18 shipped
|
||||
`v0.18.0` (`359dbb1`): D2 (PAD read-endpoint, `v0.17.14`) renders live PAD in the web pane from our
|
||||
`:8390` store; D1 (composite endpoint, `v0.17.16` `7f4ceaa`) — `build_combined_provider_app`
|
||||
(`provider/combined.py`) on `:8392` wraps bifrost's public `build_combined_app` over BOTH stores +
|
||||
the shared affect read route; one bound session drives memory.* AND affect.* through ONE endpoint,
|
||||
op-feed deriving plane per path. Suite **503 green**. **#17 closed in the tracker 2026-06-20**
|
||||
(shipped `v0.17.8`–`.13` + the `v0.17.17` op-feed field fix).
|
||||
|
||||
**#18 DELIVERABLE 1 (composite `:8392` endpoint) — PARKED on bifrost** (tracked Gitea #18).
|
||||
Routed to bifrost-dev for a public `build_combined_app` rather than hand-rolled from
|
||||
bifrost privates (debug-surface-uses-canonical principle). bifrost-dev confirmed it: clean
|
||||
additive minor (~`v0.9.0`), design locked (advertise-by-presence handshake, per-route
|
||||
call-time isolation), slotted AFTER WT #289. FR-1 RESOLVED — composite is bifrost-only,
|
||||
ZERO Worldtree change (single-endpoint caps-routed, worldtree-dev code-verified). NEXT:
|
||||
when bifrost ships `build_combined_app`, **repin + reimplement D1 against it** (per-plane
|
||||
failure status + op-feed plane-per-request derivation already specced in the issue).
|
||||
Nothing blocks on our side.
|
||||
**#18's final leg — the Worldtree-DRIVEN composite turn — RAN and is PROVEN end-to-end + persisted
|
||||
(2026-06-20).** infra-ops added `10.100.10.50:8392` to the personal WT's (`:8081`)
|
||||
`BIFROST_CLIENT_ALLOWED_HOSTS` (thread `01KVHWJGTT…`), unblocking the smoke. A real WT turn through
|
||||
`:8392` (session `b83a66b6`, agent `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) drove the
|
||||
FULL both-plane lifecycle on ONE endpoint, caps-routed by path: `handshake`
|
||||
(`caps_granted=[memory, affect]`) → `affect.fetch` + `memory.search` (reads) → `affect.emit`
|
||||
(`stored:true`, PAD row in `affect_snapshots`) → `memory.upsert_many` (`upserted:1`, chunk
|
||||
`2df1b79de761b948` in `memory_chunks`). Both writes verified directly in our SQLite. The
|
||||
model-backend outage that blocked the first attempt (both agents' models `model_unavailable`) was
|
||||
operator-fixed mid-session, then the resmoke completed clean. **No open legs remain on the composite.**
|
||||
|
||||
**OPERATOR SESSION STATE — running shells are PRE-#18 code (foot-gun).** web `:8765` +
|
||||
affect `:8390` + memory `:8391` are the prior session's background shells running OLD code
|
||||
(no read route; web has no `RATATOSKR_AFFECT_READ_URL`). To see D2 live in the operator's
|
||||
own session, RESTART `:8390` (affect provider, new code → gains the read route) + `:8765`
|
||||
(web, new code + `RATATOSKR_AFFECT_READ_URL=http://127.0.0.1:8390` + `RATATOSKR_END_USER_ID`).
|
||||
This session's live-smoke used THROWAWAY `:8393`/`:8766` instances vs the same `affect.db` to
|
||||
avoid disrupting them. Consumer/owner key = `wt_live_d81b…`; providers SQLite + sqlite-vec,
|
||||
`memory.db`/`affect.db` at repo root (affect.db has live sindra PAD: vuong pleasure 0.146,
|
||||
familiarity 0.589, interaction_count 8).
|
||||
**bifrost repinned 0.8.0 → 0.10.0** (floor, `provider` extra). 0.10.0 made `affect.fetch`
|
||||
MANDATORY (strong-or-absent: `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch`,
|
||||
gating EVERY affect op incl. emit) — so the repin FORCED `affect.fetch` (`v0.17.15`, conformed
|
||||
to bifrost's reference `InMemoryAffectStore.fetch` → `{found, snapshot?}`) or our shipped affect
|
||||
plane would 400. The composite's affect cap depends on it.
|
||||
|
||||
**OPERATOR SESSION STATE — `:8390`/`:8391`/`:8765` shells are PRE-#18 code (foot-gun).** web `:8765`
|
||||
+ affect `:8390` + memory `:8391` are prior-session background shells on OLD code. The **`:8392`
|
||||
composite provider is RUNNING on NEW code** (`ratatoskr-combined-provider`, pid started Jun19,
|
||||
`RATATOSKR_OPFEED_PATH=/tmp/ratatoskr-combined-opfeed.jsonl`, shared `affect.db`/`memory.db`) — now
|
||||
`:8392`-allowlisted and WT-turn-proven. To see the full web stack on new code, RESTART `:8390`/`:8765`
|
||||
from current code (D2 web needs `RATATOSKR_AFFECT_READ_URL`). Consumer/owner key = `wt_live_d81b…`
|
||||
(`~/.config/ratatoskr/provider.env`, mode 600, rotate via infra-ops); providers SQLite + sqlite-vec,
|
||||
`memory.db`/`affect.db` at repo root (live sindra PAD: vuong + the `resmoke-choco-1` smoke fixture).
|
||||
|
||||
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
|
||||
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
|
||||
@@ -84,15 +86,17 @@ linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
|
||||
(separate `consumer_agents` table) — invisible to `GET /agents`; check via
|
||||
`GET /agents/<owner>:<name>` with the owner key.
|
||||
|
||||
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); bifrost 0.8.0 / wire v0.6
|
||||
(`scope_all`+`scope_any`); WT handshake now advertises `bifrost_version 0.6.0` (worldtree-dev
|
||||
honesty-fix FYI `858ba58` — we don't pin/assert it, no-op our side). Heimdall key env-only
|
||||
at `~/.config/ratatoskr/provider.env` (mode 600); rotate via infra-ops. `graphify-out/`
|
||||
runs dirty (auto-regen, not chased). Open issues: #10 (subject migration), #11 (AdminEvents
|
||||
pane) — deferred; **#18** (D2 PAD-read SHIPPED `v0.17.14`; D1 composite PARKED on bifrost
|
||||
`build_combined_app`). Codex-first pilot dormant.
|
||||
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); **bifrost 0.10.0 / wire v0.6**
|
||||
(`scope_all`+`scope_any`). Heimdall key env-only at `~/.config/ratatoskr/provider.env` (mode
|
||||
600); rotate via infra-ops. `graphify-out/` runs dirty (auto-regen, not chased). **Open issues:
|
||||
#11** (AdminEvents pane — the next-reachable Worldtree-I/O coverage gap, blocked on an
|
||||
`admin.events.read` scope request) and **#10** (subject-migration watch on Worldtree #196) — both
|
||||
deferred. **#17 + #18 CLOSED.** Codex-first pilot dormant. No in-flight implementation work — repo
|
||||
is at a converged checkpoint; v1 advances when Worldtree does (v1 = full Worldtree I/O coverage).
|
||||
|
||||
Branch: `main` (== `origin/main` @ `39eebd1`). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
Branch: `main` (tag `v0.18.0`, `359dbb1`) — **in sync with `origin/main`** (the full #17+#18 arc is
|
||||
pushed). This `/snapshot` commit will sit one ahead of origin until pushed (push is the operator's
|
||||
call). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
@@ -127,6 +131,14 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[2026-06-18]` **#18 D2 implemented via direct in-session TDD (suite 470→482).** Provider read route `GET /affect/state/{agent_id}` added via `app.add_route` (NOT an outer `Mount` — keeps `/bifrost/*` top-level so the existing route test + the op-feed path-check stay valid); web `GET /api/affect/{agent_id}` proxy (server-supplied `end_user_id`, colon-id `quote()`'d, `RATATOSKR_AFFECT_READ_URL`); pane renders the affect-emit shape honestly. Contract `docs/contracts/issues/18.contract.md` (D2-scoped; D1 deferred). **heid-code-review panel (Gróa 5 / Hulda 3 / Regin 0): 1 real INV-001 drift + 4 test-gaps, all fixed.** No contract amendments (code was wrong, contract was right).
|
||||
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin.** Live-smoke PROVEN against real data (throwaway `:8393`/`:8766` vs the real `affect.db` → real sindra/vuong PAD through the full web→provider chain; Playwright DOM check confirmed the pane render + the F1 fix — no fabricated "neutral"). The push carried 9 previously-held commits incl. the deliberately-unpushed #17 (`v0.17.8`→`v0.17.13`); origin/main now == `39eebd1`, tag `v0.17.14`.
|
||||
|
||||
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent).** 0.10.0's `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch` and gates EVERY affect op — an emit-only store 400s. Implemented `affect.fetch` (`v0.17.15`, `ca6af6b`) conformed to bifrost's reference `InMemoryAffectStore.fetch` (`{found, snapshot?}`): the forced D1 prerequisite + a new Worldtree I/O point consumed. Flagged the now-stale consumer-guide line to bifrost-dev (fixed `a2e6d62`).
|
||||
- `[2026-06-19]` **#18 D1 SHIPPED — composite `build_combined_app` on `:8392` (`v0.17.16`, `7f4ceaa`); #18 CLOSED; published `v0.18.0` (`359dbb1`).** `build_combined_provider_app` wraps bifrost's public builder over both stores + the shared read route; op-feed `plane='combined'` per-path. Direct in-session TDD; heid-code-review panel (Gróa/Hulda/Regin) returned ZERO drift. Live-proven at wire+dispatch; WT-turn gated on infra-ops `:8392` allowlist.
|
||||
- `[2026-06-19]` **op-feed handshake field-name fix (#17, `v0.17.17` `d60b77d`): `capabilities_requested`→`capabilities`.** The summary read a field that never exists on the wire (bifrost reads `capabilities`, `_protocol.py:181`) → caps_requested was always null. Surfaced by the heid panel (Regin) during the D1 review — a latent #17 bug, not D1 drift.
|
||||
- `[2026-06-19]` **Ratatoskr is a REFERENCE implementation of the Worldtree/Bifrost standard (operator).** Adopt the dep's canonical way (even if ours works); INFORM of drift/gaps; ADVISE a different approach only when ours is genuinely better (dep owner decides), never unilaterally fork. [auto-memory `feedback-ratatoskr-is-a-reference-impl-adopt-canonical`]
|
||||
- `[2026-06-19]` **Ratatoskr v1 is DERIVED from Worldtree I/O coverage (operator) — no self-defined feature ROADMAP.** v1 = consume all of Worldtree's I/O points, reached when Worldtree hits 1.0; the convergence target is a coverage map, not a 3–7 capability list. [auto-memory `project-ratatoskr-v1-derived-from-worldtree-io-coverage`]
|
||||
- `[2026-06-20]` **#18's final leg PROVEN — composite `:8392` WT-driven smoke ran end-to-end + persisted.** infra-ops allowlisted `10.100.10.50:8392` on the personal WT (`01KVHWJGTT…`); a real WT turn (session `b83a66b6`, `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) dispatched the full both-plane lifecycle through ONE endpoint — handshake (both caps) → `affect.fetch` + `memory.search` → `affect.emit` (`stored:true`) → `memory.upsert_many` (`upserted:1`) — both writes verified in our SQLite (`affect_snapshots` PAD row + `memory_chunks` chunk `2df1b79…`). First attempt blocked by a `model_unavailable` outage on the personal WT (both agents' models down), operator-fixed mid-session, then clean. The composite has no open legs.
|
||||
- `[2026-06-20]` **#17 CLOSED in the tracker.** Shipped end-to-end (`v0.17.8`–`.13` + op-feed field fix `v0.17.17`); the 2026-06-20 composite smoke re-exercised its op-feed live. Closing comment captures the full both-plane proof. Open issues now just #11 (scope-blocked) + #10 (watch).
|
||||
|
||||
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
@@ -156,4 +168,10 @@ defense against re-attempting the same cul-de-sac.
|
||||
- `[2026-06-18]` **Rationalized away a KNOWN contract-invariant deviation during TDD — only the cross-model code-review caught it.** #18 D2's `loadAffect` called `setPersonaStrip(snap)`, which renders `dominant_emotion || "neutral"`; the affect snapshot has no `dominant_emotion`, so it fabricated a "neutral" emotion — violating the very INV-001 ("no synthesized Tier-1 fields") I had WRITTEN. I knew the strip did this and talked myself into it as acceptable. Neither the design panel nor TDD caught it (unit tests don't exercise the JS render); the post-implementation `/heid-code-review` did (Gróa + Hulda both). **Lesson: a known deviation from a contract invariant is drift even when you've rationalized it — flag it, don't argue yourself past it; the post-implementation cross-model review is the backstop for author-rationalized drift, distinct from the design-stage panel.**
|
||||
- `[2026-06-18]` **Latent SQLite thread-safety bug in the affect store, surfaced ONLY by the new HTTP read route.** `open_affect_store` created the connection without `check_same_thread=False`; the bifrost emit path never tripped it (uvicorn's loop ran on the connection's creating thread), but the `TestClient`-driven read route runs handlers off a worker thread → `sqlite3.ProgrammingError`. Fix: `check_same_thread=False` (safe — the event loop serializes access) + explicit `PRAGMA busy_timeout=5000` (don't rely on sqlite3's `timeout=5.0` default). **Lesson: a sqlite-backed ASGI app needs `check_same_thread=False`; the HTTP-layer test exposed what the direct-store-method tests structurally couldn't.**
|
||||
|
||||
- `[2026-06-19]` **The SAME `check_same_thread` sqlite bug recurred in the MEMORY store — exposed by the contract-mandated `search` dispatch test (TestClient = worker thread).** Heid's test-fidelity finding (the D1 dispatch test used `describe_store` where the contract says `search`) → fixing it to `search` tripped `sqlite3.ProgrammingError` because `open_memory_store` also lacked `check_same_thread=False`. Fixed (mirrors affect INV-006). **Lesson: this bug is PER-STORE — every sqlite-backed ASGI store needs `check_same_thread=False`; an HTTP-layer (TestClient) test exposes what direct-store tests can't, and the composite serving memory over HTTP makes it bite.**
|
||||
- `[2026-06-19]` **Full WT-driven `:8392` live-smoke is infra-gated — `:8392` not in WT's `BIFROST_CLIENT_ALLOWED_HOSTS` (bind 422s).** New provider ports are NOT auto-allowlisted (only `:8390/:8391` are). Self-driven dispatch (minted consumer-key JWTs → `:8392`) is the wire-proof; the WT-turn needs infra-ops to add `:8392` (requested `01KVHWJGTT…`).
|
||||
- `[2026-06-19]` **heid-code-review pulled MORE weight than its own "marginal" self-assessment.** The panel returned zero drift, but its single test-fidelity finding CASCADED into 2 real latent-bug fixes when applied (the memory `check_same_thread` bug + Regin's op-feed field-name bug). **Lesson: a contract-fidelity nudge can transitively expose bugs the test never reached — don't dismiss a "marginal" finding by its count.**
|
||||
|
||||
- `[2026-06-20]` **The post-turn-async timing trap bit AGAIN — even a 35s post-`[done]` read missed the promotion `upsert_many` by ~2s** (it landed `19:48:58`; the read was ~`19:48:56`). A 15s-interval background poll caught it on the first tick. Same family as the affect.emit / async-promotion traps already logged — re-confirmed that "wait once then read" is fragile for post-turn writes; **poll a window, don't snapshot once.** (The affect.emit write, by contrast, DID land inside the 35s window — promotion is the slower of the two post-turn writes.)
|
||||
|
||||
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.18.0"
|
||||
version = "0.18.4"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -30,7 +30,7 @@ web = [
|
||||
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
|
||||
provider = [
|
||||
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
|
||||
"bifrost>=0.10.0", # consumer engines + library (0.10.0: build_combined_app (#18) + mandatory affect.fetch, strong-or-absent; 0.8.0/wire-v0.6: scope_all/scope_any split (#11); 0.7.0/v0.5: agent_self)
|
||||
"bifrost==1.0.0", # consumer engines + library. 1.0.0 = first STABLE release, wire v0.6 FROZEN (non-breaking repin from >=0.10.0; build_combined_app #18 + mandatory affect.fetch; 0.8.0/v0.6 scope_all/scope_any #11; 0.7.0/v0.5 agent_self)
|
||||
"jsonschema>=4", # bifrost runtime dep — envelope validation
|
||||
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
|
||||
]
|
||||
|
||||
+12
-10
@@ -227,19 +227,21 @@ async def list_sessions(
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
||||
|
||||
Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the
|
||||
Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's
|
||||
loopback — Worldtree must reach the provider over the network. `http://` is
|
||||
deliberate: the HTTPS relaxation is allowlist-side (Worldtree's
|
||||
BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint
|
||||
is supplied directly, bypassing this helper.
|
||||
Issue #17 dev helper: `memory` → :8391, `affect` → :8390, `combined` → :8392
|
||||
(the #18 composite both-plane endpoint). Returns the Worldtree-visible base
|
||||
(e.g. `http://10.100.10.50:8391`), NOT the client's loopback — Worldtree must
|
||||
reach the provider over the network. `http://` is deliberate: the HTTPS
|
||||
relaxation is allowlist-side (Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS), not a
|
||||
URL concern. A production HTTPS endpoint is supplied directly, bypassing this
|
||||
helper.
|
||||
"""
|
||||
if plane not in ("memory", "affect"):
|
||||
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
|
||||
if plane not in ports:
|
||||
raise ValueError(
|
||||
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
|
||||
f"unknown plane: {plane!r} "
|
||||
"(expected 'memory', 'affect', or 'combined')"
|
||||
)
|
||||
port = 8391 if plane == "memory" else 8390
|
||||
return f"http://{base_host}:{port}"
|
||||
return f"http://{base_host}:{ports[plane]}"
|
||||
|
||||
|
||||
def _bifrost_error_from(resp: httpx.Response) -> str | None:
|
||||
|
||||
@@ -215,6 +215,59 @@ class SseConnectFailed(Exception):
|
||||
self.body = body
|
||||
|
||||
|
||||
class AgentNotAvailable(SseConnectFailed):
|
||||
"""Eager 409 from the turn POST (Worldtree v1.0.0b1, #331): the session's
|
||||
agent is unavailable, so the turn never launched. Pre-b1 this arrived as a
|
||||
200 stream + an in-stream `error` event; b1 surfaces it eagerly. Subclass of
|
||||
SseConnectFailed so existing `except SseConnectFailed` handlers still catch
|
||||
it — this type just adds the parsed `error_code` + `message`."""
|
||||
|
||||
def __init__(self, *, body: bytes, error_code: str, message: str) -> None:
|
||||
super().__init__(status=409, body=body)
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
|
||||
|
||||
class TurnLaunchUnavailable(SseConnectFailed):
|
||||
"""Eager 503 from the turn POST (Worldtree v1.0.0b1, #331): a transient
|
||||
turn-launch failure (loop shutdown / resource exhaustion). RETRYABLE.
|
||||
Subclass of SseConnectFailed; adds `error_code`, `message`, `retryable`."""
|
||||
|
||||
retryable = True
|
||||
|
||||
def __init__(self, *, body: bytes, error_code: str, message: str) -> None:
|
||||
super().__init__(status=503, body=body)
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
|
||||
|
||||
# Canonical error_codes (Worldtree #331 / v1.0.0b2): 409 -> agent_not_available,
|
||||
# 503 -> not_ready (retryable; re-pinned from internal_error). Used only as a
|
||||
# fallback default when the body omits error_code — the real code is surfaced
|
||||
# verbatim from the {detail:{error_code,message}} envelope.
|
||||
_EAGER_TURN_FAILURE_CODE = {409: "agent_not_available", 503: "not_ready"}
|
||||
|
||||
|
||||
def _eager_failure_fields(body: bytes, status: int) -> tuple[str, str]:
|
||||
"""Extract (error_code, message) from an eager turn-launch failure body
|
||||
(#331). Accepts the Worldtree `{"detail": {...}}` envelope OR a flat
|
||||
`{error_code, message}`; falls back to a status-derived default code and a
|
||||
generic message when the body is absent / non-JSON / malformed."""
|
||||
try:
|
||||
parsed: Any = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = None
|
||||
src: dict[str, Any] = {}
|
||||
if isinstance(parsed, dict):
|
||||
detail = parsed.get("detail")
|
||||
src = detail if isinstance(detail, dict) else parsed
|
||||
code = src.get("error_code") or _EAGER_TURN_FAILURE_CODE[status]
|
||||
message = src.get("message")
|
||||
if not isinstance(message, str):
|
||||
message = f"turn launch failed (HTTP {status})"
|
||||
return str(code), message
|
||||
|
||||
|
||||
class SseConnectionDropped(Exception):
|
||||
"""Raised when the HTTP/SSE connection dropped mid-stream."""
|
||||
|
||||
@@ -434,6 +487,19 @@ async def stream_turn(
|
||||
f"/sessions/{session_id}/messages",
|
||||
json={"content": content},
|
||||
) as event_source:
|
||||
# Worldtree v1.0.0b1 (#331): turn-launch failures arrive EAGERLY as a
|
||||
# status before any stream — 409 agent_not_available (pre-b1 this was a
|
||||
# 200 + in-stream `error` event), 503 a transient retryable launch
|
||||
# failure. Surface them as typed SseConnectFailed subclasses carrying
|
||||
# error_code; request-level non-2xx (404 session_not_found, etc.) stay
|
||||
# generic SseConnectFailed.
|
||||
status = event_source.response.status_code
|
||||
if status in (409, 503):
|
||||
body = await event_source.response.aread()
|
||||
code, message = _eager_failure_fields(body, status)
|
||||
if status == 409:
|
||||
raise AgentNotAvailable(body=body, error_code=code, message=message)
|
||||
raise TurnLaunchUnavailable(body=body, error_code=code, message=message)
|
||||
try:
|
||||
event_source.response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
|
||||
@@ -138,7 +138,7 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
||||
consumer_key = request.app.state.bifrost_consumer_key
|
||||
visible_host = request.app.state.bifrost_visible_host
|
||||
if bifrost_plane:
|
||||
if bifrost_plane not in ("memory", "affect"):
|
||||
if bifrost_plane not in ("memory", "affect", "combined"):
|
||||
return JSONResponse(
|
||||
{"error_code": "invalid_bifrost_plane"}, status_code=400
|
||||
)
|
||||
|
||||
@@ -513,6 +513,7 @@ body {
|
||||
<label class="field-label" for="bifrost-plane">Bifrost binding (Tier-3 provider)</label>
|
||||
<div class="select-wrap">
|
||||
<select id="bifrost-plane">
|
||||
<option value="combined" selected>combined (:8392) — PAD + memory in one session</option>
|
||||
<option value="">none — observe only</option>
|
||||
<option value="memory">memory (:8391) — durable recall</option>
|
||||
<option value="affect">affect (:8390) — persona / PAD</option>
|
||||
|
||||
@@ -384,6 +384,13 @@ class TestEndpointForPlane:
|
||||
== "http://10.100.10.50:8390"
|
||||
)
|
||||
|
||||
def test_combined_plane_maps_to_8392(self) -> None:
|
||||
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
|
||||
assert (
|
||||
endpoint_for_plane("combined", "10.100.10.50")
|
||||
== "http://10.100.10.50:8392"
|
||||
)
|
||||
|
||||
def test_unknown_plane_raises_value_error(self) -> None:
|
||||
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
||||
with pytest.raises(ValueError):
|
||||
|
||||
@@ -6,6 +6,7 @@ import respx
|
||||
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AgentNotAvailable,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
Cancelled,
|
||||
@@ -21,6 +22,7 @@ from ratatoskr.sse_client import (
|
||||
SseId,
|
||||
Text,
|
||||
TurnIdFlip,
|
||||
TurnLaunchUnavailable,
|
||||
_parse_sse_id,
|
||||
cancel_turn,
|
||||
reconnect_turn,
|
||||
@@ -415,8 +417,9 @@ class TestStreamTurn:
|
||||
async def test_connect_failed_body_truncated(self) -> None:
|
||||
"""ERROR_ROUTING: SseConnectFailed.body is truncated to <= 1024 bytes."""
|
||||
big_body = b"x" * 5000
|
||||
# 500 (not 409/503 — those are now eager turn-launch carve-outs, #331).
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(503, content=big_body)
|
||||
return_value=httpx.Response(500, content=big_body)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SseConnectFailed) as exc_info:
|
||||
@@ -424,6 +427,71 @@ class TestStreamTurn:
|
||||
assert len(exc_info.value.body) <= 1024
|
||||
assert exc_info.value.body == big_body[:1024]
|
||||
|
||||
@respx.mock
|
||||
async def test_eager_409_agent_not_available(self) -> None:
|
||||
"""b1 #331: eager 409 -> AgentNotAvailable (SseConnectFailed subclass) with
|
||||
typed error_code; the turn never streams."""
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
409,
|
||||
json={
|
||||
"detail": {
|
||||
"error_code": "agent_not_available",
|
||||
"message": "agent ratatoskr:sindra is unavailable",
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AgentNotAvailable) as exc:
|
||||
_ = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
assert exc.value.status == 409
|
||||
assert exc.value.error_code == "agent_not_available"
|
||||
assert "unavailable" in exc.value.message
|
||||
assert isinstance(exc.value, SseConnectFailed) # existing handlers still catch
|
||||
|
||||
@respx.mock
|
||||
async def test_eager_503_turn_launch_unavailable_retryable(self) -> None:
|
||||
"""b1 #331: eager 503 -> TurnLaunchUnavailable (retryable, SseConnectFailed subclass)."""
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
503,
|
||||
json={"error_code": "turn_launch_failed", "message": "resource exhausted"},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(TurnLaunchUnavailable) as exc:
|
||||
_ = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
assert exc.value.status == 503
|
||||
assert exc.value.retryable is True
|
||||
assert exc.value.error_code == "turn_launch_failed"
|
||||
assert isinstance(exc.value, SseConnectFailed)
|
||||
|
||||
@respx.mock
|
||||
async def test_eager_409_non_json_body_defaults(self) -> None:
|
||||
"""b1 #331: eager 409 with a non-JSON body -> AgentNotAvailable with the
|
||||
status-derived default error_code."""
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(409, content=b"<html>nope</html>")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AgentNotAvailable) as exc:
|
||||
_ = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
assert exc.value.error_code == "agent_not_available"
|
||||
|
||||
@respx.mock
|
||||
async def test_eager_503_non_json_body_defaults_not_ready(self) -> None:
|
||||
"""b2: eager 503 with a non-JSON body -> TurnLaunchUnavailable with the
|
||||
canonical default error_code `not_ready`."""
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(503, content=b"<html>nope</html>")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(TurnLaunchUnavailable) as exc:
|
||||
_ = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
assert exc.value.error_code == "not_ready"
|
||||
assert exc.value.retryable is True
|
||||
|
||||
@respx.mock
|
||||
async def test_no_text_aggregation(self) -> None:
|
||||
"""no_text_aggregation: consumer yields each text event separately; no concat."""
|
||||
|
||||
@@ -837,6 +837,50 @@ class TestWebBifrostBind:
|
||||
}
|
||||
assert upstream.headers["Authorization"] == "Bearer server-ck"
|
||||
|
||||
@respx.mock
|
||||
def test_combined_plane_binds_to_8392(self) -> None:
|
||||
"""combined [#18 composite]: a 'combined' plane from the browser → the server
|
||||
binds the :8392 both-plane endpoint; bound-state echoes plane='combined'."""
|
||||
import json as _json
|
||||
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK)
|
||||
)
|
||||
app = create_app(
|
||||
_mock_client_factory(),
|
||||
bifrost_consumer_key="server-ck",
|
||||
bifrost_visible_host="10.100.10.50",
|
||||
)
|
||||
resp = TestClient(app).post(
|
||||
"/api/sessions",
|
||||
json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "combined"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["bifrost"] == {
|
||||
"plane": "combined",
|
||||
"endpoint": "http://10.100.10.50:8392",
|
||||
"status": "bound",
|
||||
}
|
||||
upstream = route.calls[0].request
|
||||
body = _json.loads(upstream.content)
|
||||
assert body["bifrost"] == {
|
||||
"endpoint_url": "http://10.100.10.50:8392",
|
||||
"scope": None,
|
||||
}
|
||||
|
||||
def test_dropdown_offers_combined_as_default(self) -> None:
|
||||
"""(a)+default: the SPA plane dropdown offers a 'combined' (:8392) option,
|
||||
it is the DEFAULT-selected one, and single-plane memory/affect remain."""
|
||||
from pathlib import Path
|
||||
|
||||
import ratatoskr.web as web_pkg
|
||||
|
||||
html = (Path(web_pkg.__file__).parent / "static" / "index.html").read_text()
|
||||
assert '<option value="combined" selected>' in html
|
||||
assert 'value="memory"' in html and 'value="affect"' in html
|
||||
|
||||
@respx.mock
|
||||
def test_plane_without_server_config_is_400(self) -> None:
|
||||
"""A plane requested but no server-held key/host → bifrost_not_configured."""
|
||||
|
||||
@@ -190,14 +190,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "bifrost"
|
||||
version = "0.10.0"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
|
||||
dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
]
|
||||
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0.tar.gz", hash = "sha256:aba1869dba68d921f2e0be8fb560277073da09ec2ad5f410e226cacd5e84fe1a" }
|
||||
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0.tar.gz", hash = "sha256:93130d68dfd9868580a4514277996ba176837972b9e42129eda8bb03ad3b18b9" }
|
||||
wheels = [
|
||||
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0-py3-none-any.whl", hash = "sha256:88adbce23fa8840a14f9493e4f0cf6f9320f4950845f7a6080387defe574a5cc" },
|
||||
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0-py3-none-any.whl", hash = "sha256:1a53baa2b0596b7c418e2d82e3eeee0f13054604d78b592609ee1aff90dccac2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1052,7 +1052,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatoskr"
|
||||
version = "0.18.0"
|
||||
version = "0.18.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1086,7 +1086,7 @@ web = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.10.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
||||
{ name = "bifrost", marker = "extra == 'provider'", specifier = "==1.0.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "httpx-sse", specifier = ">=0.4" },
|
||||
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },
|
||||
|
||||
Reference in New Issue
Block a user