Commit Graph

10 Commits

Author SHA1 Message Date
vh a3c92b68dc feat(#11): AdminEvents pane — GET /admin/events SSE (session-filtered)
v1 coverage-audit: the last unbuilt design-brief §5 debug pane. #11's
blocker was already satisfied (admin key carries admin.events.read).
Completes the admin/debug-observability core.

- sse_client.py: AdminEvent dataclass + stream_admin_events — a new
  long-lived SSE consumer for the admin lifecycle stream (envelope
  {id,type,timestamp,data}), admin-scoped (bearer-override), Last-Event-ID
  resume. non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped.
- tui.py: "AdminEvents" TabPane + _format_admin_event + _admin_event_matches
  (design-brief §6 filter: active-session + non-heartbeat system.*) +
  _stream_admin_events long-lived best-effort worker (unconditional
  on_mount; self-labels not-configured / unavailable / stream-ended).
- Contract-skipped for stream_admin_events (out of #1's turn-SSE scope;
  spec § Admin Event Stream is the reference). TDD: 4 sse_client + 5 tui
  tests. Suite 561 green.
- LIVE-AUTH-PROVEN on :8081 (GET /admin/events -> HTTP 200 under admin key).

Coverage: REST 12/40. Tier 1 debug-observability core complete.
2026-06-30 23:25:34 -07:00
vh 0c7660791f feat(#1): shared SSE resume orchestration; wire cli --send
v1 coverage-audit slice b1. The audit found reconnect_turn had no
caller — every presenter dropped the stream on disconnect instead of
resuming, leaving the "reference SSE-resume implementation" (design-
brief §3/§8d) unreachable. Add stream_turn_resilient as the single
shared resume surface (design-brief §8b "share the consumer, branch
the presenter") and route cli --send through it.

- stream_turn_resilient wraps stream_turn + reconnect_turn: on
  SseConnectionDropped (mid-stream drop or clean EOF before terminal),
  resume from the last-seen sse_id via reconnect_turn (Last-Event-ID),
  up to max_reconnects (default 5). last_seen persists across attempts.
- Non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed)
  propagate unchanged, per contract #1's "surface, not recover".
- cli.py: --send consumer now drives stream_turn_resilient (transparent
  reconnect). tui/web still consume bare stream_turn (follow-up).
- Contract #1 amended (FN stream_turn_resilient) + validated; 8 TDD
  cases (happy, resume-after-1/2-drops, clean-EOF resume, unresumable
  zero-event, max-reconnects-exhausted, zero-budget, buffer-expired-
  propagates). Suite 518 green; ruff + mypy clean on touched code.
2026-06-30 15:42:33 -07:00
vh e4317f6a73 fix: pin the eager-503 default error_code to not_ready (Worldtree b2)
worldtree-dev finalized the #331 503 turn-launch error_code as `not_ready`
(re-pinned from internal_error; retryable, matching the /readyz 503 sense)
and re-froze the OpenAPI at 2.2.0 documenting the 409/503 statuses our
v0.18.3 mapping already handles. Tighten our fallback default from the
placeholder `turn_launch_unavailable` to the canonical `not_ready` (the
default only fires when the body omits error_code — the real code is
surfaced verbatim regardless). +1 test, suite 510 green.

NOTE: a full conversation-api-spec.md re-vendor to the b2 era is a separate,
larger pin-refresh (ratatoskr vendors the markdown spec, not the OpenAPI
JSON) — deferred, to bundle with the v1 coverage-audit / when personal is on b2.
2026-06-30 13:26:14 -07:00
vh b2e4901264 feat: map Worldtree b1 eager turn-launch statuses (409/503) in stream_turn
Worldtree v1.0.0b1 (#331) decoupled turn execution from the SSE connection,
so turn-launch failures now arrive EAGERLY as an HTTP status before any
stream: 409 agent_not_available (pre-b1 was a 200 + in-stream error event)
and 503 (retryable turn-launch / infra failure). stream_turn previously
funneled both into a generic SseConnectFailed.

Map them to typed SseConnectFailed subclasses — AgentNotAvailable (409) and
TurnLaunchUnavailable (503, retryable=True) — carrying the parsed
error_code/message from the {detail:{error_code,message}} envelope.
Subclassing keeps existing `except SseConnectFailed` handlers working with
zero changes (POST-003 preserved — no synthetic event yielded; raise mirrors
reconnect_turn's 400/410/412 pattern).

worldtree-dev confirmed 409/503 are real runtime statuses; the OpenAPI 2.1.0
gap (not enumerating them) is theirs to fix (doc-completeness, not a wire
break). The 503 error_code is being re-pinned upstream (today internal_error
-> likely not_ready); our handling keys on STATUS so it's robust to the final
code — tighten the 503 default once they confirm.

Body shape live-confirmed against demo b1's 404/401 responses. Suite 509 green.
Contract docs/contracts/issues/1.contract.md updated.
2026-06-30 13:11:33 -07:00
vh 78bfcadb9e feat(sse,tui): bump spec pin to v0.29.0 + AwaitingLlmFirstToken (v0.14.0)
Spec pin moved da93ca7 (v0.28.0) → 562001a (v0.29.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted.
The only material delta is Worldtree #201's awaiting_llm_first_token
SSE heartbeat — a top-level event (NOT a worker_phase extension, per
INV-053's three-field stability) that fires at a configurable interval
(default 5s) during the BuildingPrompt → CallingLLM gap.

Wire layer (sse_client.py):
- New `AwaitingLlmFirstToken` dataclass: sse_id / turn_id /
  elapsed_ms_since_building_prompt (server-authoritative monotonic)
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any slow-first-token turn
  from a v0.29.0 server (unknown SSE event type → ValueError)

TUI layer (tui.py):
- Audit pipeline: per-event debug-pane line with elapsed in seconds
- Live transcript indicator: first heartbeat mounts a Static
  ("awaiting first token · 5.0s"); subsequent heartbeats update it
  in place; any non-heartbeat event removes it (the gap closed)
- Turn-summary line now carries heartbeat count
- Indicator demoted via .awaiting-label CSS so it reads as ambient
  progress, not content

Tests: 2 wire-layer (single + monotonic sequence) + 3 presenter
(audit line shape, single-mount semantic, indicator removal on gap
close). Suite: 318 passing.
2026-05-25 22:57:33 -07:00
vh 92aa05c688 feat(sse,tui): bump spec pin to v0.28.0 + AffectUpdate event (v0.11.0)
Spec pin moved 55101e9 (v0.19.0) → da93ca7 (v0.28.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted
from Worldtree at the new SHA. The only material delta consumed in
this bump is Worldtree #204's affect_update SSE event surface.

Wire layer (sse_client.py):
- New AffectUpdate dataclass: sse_id / status / turn_id / snapshot
  (snapshot is None for status="scheduled")
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any persona-enabled turn
  from a v0.28.0 server (unknown SSE event type → ValueError)

TUI layer (tui.py):
- AffectUpdate routes through the v0.10.0 audit pipeline only — one
  debug-pane line per arrival with dominant_emotion + PAD for
  status="current", lightweight status+turn_id for status="scheduled"
- No transcript / tools / thinking pane writes — the persona UX shape
  (Persona TabPane vs sticky header line) is deferred to a separate
  bump pending operator direction

Tests: 2 new wire-layer tests for current+scheduled parsing + 2 new
presenter audit tests for routing and audit-line shape.

Not yet consumed: GET /agents/{id}/persona_state endpoint (step 2 of
the integration plan).
2026-05-25 18:48:44 -07:00
vh 11ef6830ab fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
Two related fixes for the same user-reported bug pattern from a
running session against ratatoskr:sindra (qwen3.6-35-a3b-heretic):

## 1. Streaming text overlapping the transcript

Operator: "new text comes at the bottom and overwrites the existing
pane information instead of pushing it up naturally."

Root cause: the v0.6.0 `#current-text` Static was `dock: bottom`
with `height: auto`, sitting between the transcript RichLog (1fr)
and the prompt Input (dock: bottom). As text streamed, the Static
grew UPWARD but Textual didn't dynamically resize the 1fr transcript
to accommodate — the growing Static visually OVERLAPPED the
transcript's bottom rows. On Done, `current_text.update("")` snapped
it to height 0 and the transcript re-laid-out — "boom, everything
updates."

Fix: remove `#current-text` Static entirely. Apply the same
coalesce-on-newline pattern v0.7.1 used for thinking — Text deltas
accumulate in `TuiPresenterState.text_chunk_buffer`, flushing whole
lines (each `\n` boundary) directly to `log` (transcript). On Done:
flush remaining tail, then [done] label + Rule + Markdown body.

Trade-off accepted: streamed lines + post-Done Markdown body are
both in the transcript (some content duplication). The Markdown
body re-renders the same content with proper formatting (lists,
bold, code blocks). Acceptable — operator gets both the live-progress
streaming AND the canonical rendered version.

## 2. MalformedSseId raw='' crashing every turn

Operator: "current session is erroring on every turn with
[malformed_sse_id] raw=''"

Worldtree's qwen3.6-35-a3b-heretic provider emits some events
without `id:` lines (observed 2026-05-25 mid-stream). When the FIRST
such event arrives before any prior id has been seen, httpx_sse's
`ServerSentEvent.id` is `""`. `_parse_sse_id('')` raised ValueError
→ MalformedSseId → turn worker bailed → operator saw the label
every turn.

Per SSE RFC, events without `id:` are legitimate (they just don't
update Last-Event-ID). Issue #7 already covered the empty-DATA
keepalive case with skip-silently semantics. Empty-id is the same
shape of wire weirdness; same fix shape:

  if sse.id == "":
      continue  # treat as keepalive

Ordered AFTER the empty-data branch so an empty-data + empty-id
event still gets skipped on the data check.

## Tests + smoke

287/287 GREEN (was 286, +1 for empty-id skip; +1 net Text-flow test
adjustments). Ruff clean.

Verified Worldtree alive when the user hit the empty-id bug
(/healthz returned ok in 18ms) — not a server-down issue, just
wire-format mid-stream.

## Caveats

The fix doesn't recover content from the dropped empty-id event.
If the event happened to carry meaningful data (not a true
keepalive), we silently lose it. Acceptable trade-off: pre-v0.8.1
EVERY turn died on the offending agent; post-v0.8.1 the turn
continues and any single dropped frame is recoverable from logs if
debugging. Worldtree-side fix (always emit ids) is the right
upstream answer; ratatoskr just stops panicking on wire weirdness.

Patch bump (v0.8.0 → v0.8.1) — both fixes are bug fixes; no public
API change. The `TuiPresenterState.render` signature loses the
`current_text` parameter (was added v0.6.0), but presenter is an
internal contract; no external callers.
2026-05-24 21:39:02 -07:00
vh c713208585 feat(sse_client,cli,tui): implement issue #7 — empty-data skip + MalformedSseData
Bundles initial TDD impl + Volva-code-review F1/F3 amendments.

sse_client.py:
- New MalformedSseData(raw) exception; truncates raw to 200 chars at
  __init__ (mirrors MalformedSseId.raw[:64] precedent).
- _iter_events gains `if sse.data == '': continue` BEFORE
  _parse_sse_id. Empty-data frames are silently skipped per issue #7
  INV-001 (keepalive semantics). Empty-data + bad-id is still a
  keepalive; intentional ordering, don't reorder.
- _iter_events json.loads(sse.data) now wrapped — JSONDecodeError →
  MalformedSseData(raw=sse.data).

cli.py:
- Imports MalformedSseData; _run_turn ERROR_ROUTING gains the case →
  stderr `[malformed_sse_data] raw={exc.raw!r}` + exit 22 (protocol-
  failure bucket, same as MalformedSseId/TurnIdFlip).

tui.py:
- Imports MalformedSseData; _stream_turn_worker ERROR_ROUTING gains
  the case → transcript label; finally block restores state→idle
  per INV-008 (mid-session errors don't exit the app).

Tests (6 new):
- test_sse_client.py: empty_data_skipped (tracer — 4 frames in, 3
  events out), malformed_data_raises, whitespace_data_raises,
  malformed_data_truncation, AND empty_data_skip_preserves_last_seen_sse_id
  (F1 from Volva code-review — drop-after-empty probes internal
  last_sse_id non-advancement via SseConnectionDropped.last_seen_sse_id).
- test_cli.py: malformed_sse_data (tightened to assert exact
  `[malformed_sse_data] raw='not-json'` shape per F3),
  malformed_sse_data_truncation (5000-char payload — verifies
  truncation carries through presenter rendering, F3).
- test_tui.py: malformed_sse_data_returns_to_idle (state→idle per
  INV-008; app does NOT exit).

Smoke validation (2026-05-22): the original crashing prompt
("what about system 1 and system 2 framing?") now completes cleanly
end-to-end. mimir streamed 3193 tokens (50 seconds, 374980-token
context), `[done] turn_id=96 duration_ms=50436`. Empty-data frames
somewhere in the stream silently skipped; no crash.

172/172 tests GREEN; ruff clean; all 5 issue contracts (#1, #3, #4,
#5, #7) drift-check clean.

Persistent-memory updated per the commit-along rule: status reflects
v0+#7 milestone; new dated decisions for #5/#6/#7 filing + #7
implementation; foot-gun entry for unguarded json.loads(sse.data).
2026-05-22 16:41:38 -07:00
vh c17af18351 fix(sse_client): address Volva code-vs-contract drift (issue #1)
Volva's code-spec review (thread 01KS4CP6ZZ1F) surfaced four code-vs-
contract drift findings on the TDD-passing implementation. All four
addressed here; no contract amendments required.

1. _iter_events fell off the end of aiter_sse() normally on clean EOF
   before any Done/Error/Cancelled. Per INV-001 the iterator MUST NOT
   raise StopAsyncIteration before a terminal event unless the HTTP
   connection drops, in which case it raises SseConnectionDropped.
   Clean EOF before terminal is the same semantic — the stream ended
   without delivering its contracted invariant. Fix: track terminal_seen
   inside _iter_events; after the async-for completes, if not seen,
   raise SseConnectionDropped(last_seen_sse_id=...). Two new tests:
   test_clean_eof_before_terminal (one text then EOF) and
   test_zero_event_eof (empty stream — last_seen_sse_id is None).

2. SseConnectFailed and CancelFailed both store .body without
   truncation; ERROR_ROUTING specifies resp.read()[:1024]. Fix
   truncates in each exception's __init__ before storing. New test
   test_connect_failed_body_truncated (503 + 5000-byte body → 1024)
   and test_cancel_failed_truncates_body (same shape on cancel).

3. _parse_sse_id PRE-001 specifies `assert isinstance(raw, str)`.
   Previous code called raw.split(":") directly, which raises an
   incidental AttributeError on non-str inputs — not the contracted
   precondition path. Fix adds the assert. New test
   test_non_string_input covers int and None.

4. Cancel ERROR_ROUTING said httpx.HTTPStatusError other status →
   CancelFailed, but no test exercised the branch. test_cancel_failed_
   truncates_body covers this (above) — single test double-covers
   findings 2 and 4.

43 tests GREEN (42 sse_client + boundary smoke); ruff clean.

Meta-note from Volva: TDD caught the main happy/adversarial SSE shape,
resume header/body, turn-id flip, and cancel races. The remaining
misses were "negative space" cases (clean premature EOF, exception
payload truncation, untested generic cancel branch). Calibration
evidence that cross-model review pulls weight on what same-model
TDD's hypothesis-space doesn't probe.
2026-05-20 21:33:32 -07:00
vh 02f2a04b37 feat(sse_client): implement issue #1 contract via TDD
Implements docs/contracts/issues/1.contract.md. Four entry points
(stream_turn, reconnect_turn, cancel_turn, _parse_sse_id) + nine
typed Event variants + ten domain exceptions. 37 tests covering
every TESTS: entry verbatim, plus the boundary smoke test still
passes.

Tracer-bullet ordering per the contract's per-FN tracer tags:
_parse_sse_id (foundation; happy_simple) → stream_turn
(happy_one_text_done) → reconnect_turn (happy_resume_from_seq_3) →
cancel_turn (happy_cancel). Each FN's tracer went RED then GREEN
before its other tests landed.

Shared SSE-iteration logic (INV-002 sse_id presence + INV-003
turn_id stability + terminal-break) lives in private _iter_events
helper. expected_turn_id=None gives stream_turn's "establish from
first event" semantics; expected_turn_id=N gives reconnect_turn's
"first event is already a flip-candidate" semantics — the
two-entry-point distinction Volva surfaced during the paraphrase
round.

A few implementation choices worth recording:

- _parse_sse_id uses a `^-?\\d+$` regex pre-check to reject any
  whitespace before int() is called. Python's `int(" 3 ")` silently
  strips, which would have made the trailing_whitespace adversarial
  test pass for the wrong reason.

- The connection_drop test uses a custom httpx.AsyncByteStream
  subclass (_DropAfter) that yields chunks then raises
  RemoteProtocolError mid-stream. respx alone can't simulate
  mid-stream HTTP errors.

- ToolResult.result and ToolStart.arguments are typed as Any
  because the server's tool wire shape varies per tool; the spec
  doesn't pin a generic schema.

- Boundary smoke test (no core.* / worldtree.* imports under
  src/ratatoskr/) still GREEN — INV-005 holds.

Also: one E501 line-length fix in test_no_worldtree_imports.py
that ruff flagged once the new tests pulled it into scope.
2026-05-20 21:25:20 -07:00