Files
ratatoskr/docs/contracts/issues/6.contract.md
T
vh 804c2df6eb feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up
Issue #6 (TUI startup error visibility): restructure run_tui lifecycle so
pre-App.run() failures land on real stderr instead of getting eaten by
the alt-screen teardown. New _resolve_then_run async helper opens the
AsyncClient via async-with, does pre-flight session resolution, routes
AgentNotFound / SessionApiFailed / network errors to sys.stderr (verbatim
same labels + exit codes as cli._amain), then constructs RatatoskrApp
with pre-resolved state and awaits app.run_async(). RatatoskrApp.__init__
signature widens to (args, *, session_id, agent_id, client) — all three
required. on_mount narrows to identity-widget population; on_unmount
becomes a no-op (client lifetime owned by run_tui's async-with).

Issue #5 (--end-user-id for per-end-user agents): sessions.create_session
gains keyword-only end_user_id kwarg with PRE-003 non-empty assertion;
ParsedArgs.end_user_id field added (default None); --end-user-id flag
with non-empty validation; _amain + _resolve_then_run thread it to their
create_session calls. RATATOSKR_END_USER_ID env-var fallback
(flag > env > None) per the post-2026-05-23 amendment; env.sh (gitignored)
ships "ratatoskr-tui" as project-stable partition default.

Worldtree-dev consumer-API follow-up (althing 01KSBARG2B8M): User-Agent
header added (ratatoskr/<version> (vh@phasefinal.com), version pulled via
importlib.metadata) to both AsyncClient constructions so server logs can
distinguish ratatoskr traffic from other consumers.

Volva code-review (2 rounds on #6) found 8 test-precision gaps + 1 PRE
assertion drift, all Category 1 fixed: missing PRE-001 at
_resolve_then_run entry; Rule separator assertions on markdown render;
RichLog-write spy on empty submit; input-cleared + no-new-worker on
cancelling busy; worker.cancel observation on three force-exit paths;
on_unmount-no-close focused test (the prior client-lifetime test patched
run_async so on_unmount was never exercised); happy --new resolve test
verifying POST count + identity propagation.

Issues #2/#3/#4/#5 contracts amended in-place to reflect:
- create_session widened (PRE-003, body construction step, body shape POST)
- ParsedArgs description + _parse_args STEPS + _amain create_session call
  + new TESTS for end_user_id + env-var fallback
- _resolve_then_run STEPS + new TEST entries; on_mount narrowed;
  INV-007 amended for new client ownership
- Post-#6 adjustment note on issue #5 (_resolve_then_run replaces
  on_mount as the threading site since #6 moved session resolution out
  of the alt-screen)

188 tests GREEN; ruff clean. Bumps to v0.1.0 — first minor release, the
load-bearing reason is RatatoskrApp.__init__'s breaking signature change
(additive end_user_id alone wouldn't have triggered a minor pre-v1.x).

Files Gitea issues #9 (spec-pin refresh v0.19.0 → v0.22.1), #10 (track
Worldtree #196 subject:{type,id} migration), #11 (AdminEvents pane auth
prerequisite admin.events.read). Infra-ops pinged via althing for
agents.call:lofn scope add (broker pattern; they forwarded to
worldtree-dev because personal Worldtree exposes no public
scope-mutation endpoint).
2026-05-23 14:34:53 -07:00

22 KiB

contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
contract_version target_module scope depends_on used_by language complexity estimated_loc confidence assumptions open_questions prd dependencies
2.1 ratatoskr.tui Restructure `ratatoskr.tui`'s `run_tui` lifecycle so startup errors (`AgentNotFound`, `SessionApiFailed`, network-error-during-create_session) print to real stderr instead of getting eaten by the alt-screen teardown. Move session resolution OUT of `on_mount` (which runs inside the alt-screen) and INTO `run_tui` (sync wrapper, BEFORE `App.run()` opens the alt-screen). `httpx.AsyncClient` ownership moves with it: opened by `run_tui` via async-with; the `RatatoskrApp` instance becomes a consumer of an externally-owned client. `on_mount` shrinks to identity-widget population from pre-resolved state. Mid-session errors during streaming (issue #4 INV-008) continue to render in the alt-screen; only PRE-`App.run()` failures use stderr. No new modules; in-place amendment to issue #4's contract. No semantic change to issue #1/#2/#3 surfaces.
httpx
textual
ratatoskr.sessions
ratatoskr.cli
python medium 60 0.85
Textual's `App.run()` enters an alt-screen lifecycle that tears down on `self.exit()` or normal termination. Any content rendered to widgets inside the alt-screen (e.g., RichLog.write) is invisible to the operator after teardown — it lived in the alt-screen buffer, not the operator's scrollback. Real stderr writes survive teardown.
`httpx.AsyncClient` is safely usable across an `App.run()` boundary: opened in an `async with` block in `run_tui`, accessed by the App via `self.client`, closed by the same `async with` after `App.run()` returns. The client doesn't care about Textual's lifecycle; it's a plain httpx object the App holds a reference to.
Synchronous `run_tui` is the operator-facing entry point per issue #4's contract (called from `ratatoskr.cli.main` via lazy import). `run_tui` calls `asyncio.run(_resolve_then_run(args))` which is a single async function that handles BOTH the pre-flight HTTP (opening the client, resolving the session) AND the App lifecycle via `await app.run_async()`. Everything runs in one event loop managed by that single `asyncio.run(...)` call — NOT two sequential event loops. `app.run_async()` is Textual's async entry point (as opposed to the sync `app.run()`) and is the correct choice because we're already inside an async context with an active `httpx.AsyncClient`. Mixing a nested `asyncio.run()` inside an existing event loop would be incorrect.
Tests for the moved error paths use stderr capture (pytest's `capsys`) at the run_tui level, NOT the App.run_test() pilot. Pilot tests cover the in-alt-screen paths (#4's existing TestStreamTurnWorker / TestActionInterrupt / TestActionQuit). Pre-flight errors are App-free; standard stderr-capture works.
Should the pre-flight error label format match the cli's `[session_api_failed] status=... body=...` format exactly (already in cli's `_amain` STEP 2), so operators see the same label whether they hit the error via `--send` or via TUI startup? Draft: yes — same label, same shape, same exit code. Consistency beats per-presenter variance.
Should `run_tui` emit an `[connecting...]` status line to stderr before the pre-flight HTTP, so a slow connection isn't silently waited on? Draft: no for v1. The pre-flight is fast on a working network (< 1s for POST /sessions); if a slow path turns up, surface in a follow-up.
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
6 #6 6378989fbd465ba8 null null null 2026-05-23T02:11:18+00:00
issue path reason
4 src/ratatoskr/tui.py In-place contract amendment: `run_tui` STEPS expanded with pre-App.run() session resolution + error routing to stderr; `RatatoskrApp.__init__` signature widens to accept pre-resolved `session_id` / `agent_id` / `client`; `on_mount` STEPS narrowed (no more session-create); `on_unmount` STEPS narrowed (client closed by run_tui's async-with, not on_unmount). Several issue #4 TESTS get restructured: error-on-mount tests become error-on-resolve tests at the run_tui layer.

TUI startup error visibility — surface pre-flight errors to real stderr

Context

Issue #4's RatatoskrApp.on_mount runs INSIDE the Textual alt-screen and calls create_session (when --new) to mint a session before the app becomes interactive. When create_session raises (AgentNotFound, SessionApiFailed, network errors), my code today writes a labeled line to the RichLog widget and calls self.exit(<code>). The exit code is right, but the labeled line is invisible: the alt-screen tears down roughly 200ms after self.exit(), and the RichLog buffer goes with it. The operator sees a blank terminal and an exit code — no diagnostic.

Surfaced 2026-05-21 (ratatoskr --new --agent lofn blanked silently; turned out to be the issue #5 end_user_id_required 422; operator had to re-run under --send to see the actual error). --send mode handles this class of error correctly because stderr labels go to the operator's real terminal, not the alt-screen.

This issue restructures the lifecycle so startup-phase errors use the same stderr path that --send uses. Session resolution moves OUT of on_mount (alt-screen) and INTO run_tui (sync wrapper, real terminal). Mid-session errors during streaming continue to render in the alt-screen per issue #4 INV-008 — that path is fine; the user is interactively present and the transcript is visible.

Data flow

Input change: none at the operator-facing level. ratatoskr --new --agent <id> and ratatoskr --session <id> both still launch the TUI.

Output change:

  • When session-create fails (in --new mode) OR when the network won't reach the server, the operator now sees a labeled line on the real terminal stderr, not the alt-screen:
    • [agent_not_found] agent_id={exc.agent_id} (exit 12)
    • [session_api_failed] status={exc.status} body={exc.body!r} (exit 20) — exc.body is already truncated to 1024 bytes at SessionApiFailed.__init__ per issue #2 INV-004; !r is the repr of that already-truncated bytes value
    • [network_error] {type(exc).__name__}: {exc} (exit 21)
  • Format matches ratatoskr.cli._amain's existing error labels exactly (same shape, same exit codes) so operators see one consistent vocabulary across --send and TUI modes.
  • When session resolution succeeds, the alt-screen opens and behavior is identical to today's: identity widgets populated, chat pane ready for input.
  • Mid-session errors during streaming (issue #4 INV-008 set: SseConnectionDropped, SseConnectFailed, MalformedSseId, MalformedSseData, TurnIdFlip) STILL render in the alt-screen and return the app to idle. Unchanged.

Side effects:

  • httpx.AsyncClient lifetime widens: now spans the pre-flight HTTP AND the App's lifetime, owned by run_tui via async with.

On disk: none (unchanged).

Invariants

  • INV-001 [hard]: Session resolution (mint when --new; attach when --session) MUST complete BEFORE App.run() enters the alt-screen. Errors at this phase MUST print to sys.stderr (the real terminal, not a RichLog widget) and MUST cause run_tui to return the appropriate exit code WITHOUT calling App.run(). The alt-screen MUST NOT open when session resolution fails — operators get a clean stderr diagnostic on their normal terminal, with no flash-and-disappear artifact.
  • INV-002 [hard]: httpx.AsyncClient is owned by run_tui via async with. The client is opened BEFORE the pre-flight session resolution, passed by reference to RatatoskrApp.__init__, accessed by the App via self.client during streaming, and closed by the same async with AFTER App.run() returns. The App is a consumer of an externally-owned client; it MUST NOT call self.client.aclose() (the async with does that). Issue #4's on_unmount STEPS narrow accordingly.
  • INV-003 [hard]: RatatoskrApp.__init__ signature widens to (args, *, session_id: str, agent_id: str | None, client: httpx.AsyncClient). All three are pre-resolved by run_tui and REQUIRED at construction. The app no longer mints anything; it consumes pre-resolved state.
  • INV-004 [hard]: on_mount STEPS narrow: open the identity Static widget, set self.state = "idle", set self.hint = HINT_IDLE. No more session-create branch; no more client-open. The <unknown> carve-out for agent_id (issue #4 INV-002) is preserved — when --session <id> is used without --agent, agent_id is None and the identity widget renders <unknown> · …<tail> as today.
  • INV-005 [hard]: Mid-session errors during streaming (issue #4 INV-008 set) are UNCHANGED. They render to the RichLog transcript via _render_event_to_log / explicit log.write and return the app to idle state. Only PRE-App.run() errors get the new stderr-label treatment. The split is: pre-alt-screen failures → real stderr; in-alt-screen failures → RichLog. This is the load-bearing observability invariant.
  • INV-006 [hard]: Exit codes (12, 20, 21, 0, 3) and label formats MUST match ratatoskr.cli._amain's [agent_not_found] / [session_api_failed] / [network_error] shape verbatim. Operators see one vocabulary regardless of which presenter they're using.
  • INV-007 [hard]: No core.* / worldtree.* imports (existing boundary; unchanged).

Out of scope

  • General TUI logging infrastructure (e.g., a structured DiagnosticsLog surface, log levels, log filtering). Each side pane is its own issue.
  • Persistent error log file at ~/.cache/ratatoskr/last-error.log. Rejected per design-brief §8d ("no cross-process resume, no config dir"). The fix is "make startup errors visible on stderr", not "log everything to disk".
  • In-alt-screen restructuring (e.g., a status bar that surfaces errors at the bottom of the screen during streaming). Issue #4 INV-008 already handles in-alt-screen errors correctly via RichLog; this issue only addresses pre-alt-screen.
  • 422 → user-friendly hint translation. When --new --agent lofn hits 422 end_user_id_required, this issue surfaces the raw label to stderr; the user still has to read the body to understand. Hint translation is issue #5's optional follow-up (deferred there).
  • Pre-flight status line ([connecting...] before the HTTP). Deferred; pre-flight is fast on a healthy network.
  • Re-entering the picker on failure. If session-create fails, the TUI exits cleanly; the operator re-launches with corrected args. No retry loop in v1.

Constraints

  • [compatibility] Spec pin unchanged. The wire surface is unchanged; only the client's invocation timing moves earlier.
  • [performance] No new HTTP round-trips. The same single POST /sessions happens once per --new launch; just sequenced before App.run() instead of inside on_mount.
  • [security] Same as today — Authorization header on the client, no logged credentials.
  • [style] Async-native at the resolve layer. run_tui becomes a thin sync wrapper around asyncio.run(_resolve_then_run(args)) to keep one entry point. Ruff line-length=100.

Architecture

ratatoskr <args>                           [shell entry, console-script]
  │
  └─ ratatoskr.cli.main(argv) [sync]
       │
       └─ when args.send_content is None ──► from ratatoskr.tui import run_tui
                                              return run_tui(args)
                                                   │
                                                   └─ run_tui(args) [sync]
                                                        │
                                                        ├─ assert PRE-001..PRE-002
                                                        └─ asyncio.run(_resolve_then_run(args))
                                                             │
                                                             ├─ async with httpx.AsyncClient(...) as client:
                                                             │     │
                                                             │     ├─ try: resolve session
                                                             │     │   IF args.new: info = await create_session(client, args.agent_id)
                                                             │     │              session_id = info.session_id; agent_id = info.agent_id
                                                             │     │   ELSE:        session_id = args.session_id; agent_id = args.agent_id
                                                             │     │ except AgentNotFound: stderr label; return 12  ◄── PRE-alt-screen
                                                             │     │ except SessionApiFailed: stderr label; return 20  ◄── PRE-alt-screen
                                                             │     │ except (httpx.ConnectError|ReadTimeout|TransportError): stderr; return 21
                                                             │     │
                                                             │     ├─ # Session resolved; enter alt-screen
                                                             │     ├─ app = RatatoskrApp(args, session_id, agent_id, client)
                                                             │     ├─ return await app.run_async() or 0
                                                             │     │     │
                                                             │     │     ├─ on_mount: populate identity widget; state=idle
                                                             │     │     ├─ on_input_submitted: spawn _stream_turn_worker
                                                             │     │     ├─ _stream_turn_worker: stream; mid-session errors → RichLog per INV-008  ◄── IN-alt-screen
                                                             │     │     └─ on_unmount: nothing (client closed by async with below)
                                                             │     │
                                                             │     └─ # App returned; async with closes client
                                                             └─ # exit code propagated to cli.main

In-place amendments to issue #4 (the work)

This issue's contract is small because the real work is amending issue #4's contract in place. The amendments are pinned here so reviewers see the whole change in one place; the actual contract file at docs/contracts/issues/4.contract.md is amended in-place as part of this issue's commit.

Issue #4 (ratatoskr.tui) amendments

run_tui STEPS expanded:

FN run_tui(args: ParsedArgs) -> int
STEPS:
  1. [setup, prescriptive] Validate PRE-001 (isinstance(args, ParsedArgs) and args.send_content is None)
  2. [setup, prescriptive] Validate PRE-002 (bool(args.session_id) != bool(args.new))
  3. [sequential, prescriptive] RETURN asyncio.run(_resolve_then_run(args))


FN _resolve_then_run(args: ParsedArgs) -> int    # NEW helper
ASYNC: yes
STEPS:
  1. [setup, prescriptive] OPEN httpx.AsyncClient(base_url=args.server_url,
       headers={"Authorization": f"Bearer {args.api_key}"},
       timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)) via async-with
  2. [branch, prescriptive] IF args.new:
       TRY: info = await create_session(client, args.agent_id)
       ON AgentNotFound as exc:
         sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
         RETURN 12
       ON SessionApiFailed as exc:
         sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
         RETURN 20
       ON (httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError) as exc:
         sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
         RETURN 21
       SET session_id = info.session_id; agent_id = info.agent_id
     ELSE:
       SET session_id = args.session_id; agent_id = args.agent_id  # agent_id may be None — INV-002 carve-out preserved
  3. [sequential, prescriptive] Construct app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
  4. [sequential, prescriptive] exit_code = await app.run_async()   # Textual's async-runner; lets the same event loop handle the alt-screen
  5. [cleanup, prescriptive] RETURN exit_code or 0

Note: app.run_async() (Textual's async-runner) is used instead of app.run() (sync) because we're already in an async context inside the async with httpx.AsyncClient(...). Mixing asyncio.run(...) inside an existing event loop would be incorrect; the async variant lets one loop handle both the pre-flight HTTP AND the App lifecycle.

RatatoskrApp.__init__ signature widens:

def __init__(self, args: ParsedArgs, *, session_id: str, agent_id: str | None, client: httpx.AsyncClient) -> None

All three new kwargs are REQUIRED. Stored on self as self.session_id, self.agent_id, self.client. The state machine attributes (self.state, self.active_turn_id, self.stream_worker, self.hint) are unchanged.

on_mount STEPS narrow:

async def on_mount(self) -> None:
STEPS:
  1. [setup, prescriptive] assert self.client is not None and self.session_id is not None
  2. [sequential, prescriptive] Compute identity:
       agent_slot = self.agent_id or "<unknown>"
       identity = f"{agent_slot} · …{self.session_id[-8:]}"
  3. [sequential, prescriptive] Populate widgets:
       self.sub_title = identity (Header mirror)
       self.query_one("#identity", Static).update(identity)
  4. [sequential, prescriptive] SET self.state = "idle"; self._set_hint(self.HINT_IDLE)

No more session-create branch; no more client-open. ERROR_ROUTING for AgentNotFound / SessionApiFailed / network errors is removed from on_mount — those routes now live in _resolve_then_run. Issue #4's POST-001 (client-open-after-mount) and POST-002 (session_id non-empty) are still satisfied but by _resolve_then_run setting up state, not by on_mount's create call.

on_unmount STEPS narrow (or removed):

async def on_unmount(self) -> None:
STEPS:
  (none — client lifetime managed by run_tui's async-with, NOT this hook)

Issue #4's existing on_unmount test (unmount_closes_client) is restructured: client closing now happens via run_tui's async-with exit, which fires after app.run_async() returns. The test moves from "ctrl+d → on_unmount → client closed" to "ctrl+d → run_tui returns → client closed".

TESTS amendments (issue #4 in-place):

Removed (or restructured to the run_tui layer):

  • agent_not_found_on_mount → becomes agent_not_found_on_resolve at the _resolve_then_run layer. Assertion shape: capsys.readouterr().err contains [agent_not_found]; run_tui returns 12; no app instance ever entered alt-screen.
  • session_api_failed_on_mountsession_api_failed_on_resolve.
  • network_error_on_mountnetwork_error_on_resolve.
  • client_open_after_mountclient_open_after_resolve (client opened by run_tui, accessible via self.client once the app is mounted).
  • unmount_closes_clientrun_tui_closes_client_on_app_exit (asserts the async-with closed the client after app.run_async() returned).

New TESTS (in the _resolve_then_run block at the run_tui layer):

  • alt_screen_never_opens_on_resolve_error [trace]: monkeypatch RatatoskrApp.run_async to a sentinel that fails the test if called; set up respx to return 404 from POST /sessions; assert run_tui returns 12; assert the sentinel was NEVER invoked. Directly probes INV-001 (alt-screen MUST NOT open).
  • client_lifetime_owned_by_run_tui [trace]: spy on httpx.AsyncClient.aclose; successful run; assert exactly one aclose call AFTER app.run_async returned, NOT during on_unmount. Probes INV-002.
  • stderr_label_format_matches_cli [trace]: assert the stderr label shape (e.g., [agent_not_found] agent_id=missing) matches the format emitted by cli._amain's existing handler verbatim. Probes INV-006.

Issue #4's happy_new_session_mount test stays (now exercises the identity widget population via the pre-resolved state); the assertion on POST /sessions call count moves to the new _resolve_then_run test layer.


Acceptance

  • Issue #4 contract amended in-place; drift-check clean.
  • Issue #6 contract drift-check clean.
  • All existing tests + new _resolve_then_run coverage GREEN under uv run pytest tests/.
  • uv run ruff check src/ tests/ clean.
  • Boundary smoke tests/test_no_worldtree_imports.py still passes.
  • Manual smoke (the original failure mode from 2026-05-21): ratatoskr --new --agent <nonexistent> produces a VISIBLE [agent_not_found] line on stderr; no screen-blanking artifact; exit code 12. (This is the smoke that surfaced the bug; verify it's now the success case.)
  • Regression smoke: ratatoskr --new --agent mimir against personal Worldtree still works end-to-end (alt-screen opens, chat pane works, Ctrl-D exits clean). Mid-session errors during a streaming turn STILL render in the alt-screen per INV-008 — verify by hitting one (e.g., send a turn, then kill the server side, see [connection_dropped] in the transcript, state returns to idle).

Dependencies

  • Issue #4 (ratatoskr.tui shell) — landed on main; this issue amends its contract.
  • Issue #5 (--end-user-id for per-user agents) — independent; both can land in either order, but #5 + #6 compose naturally (#6 will surface #5's 422 as a visible stderr label instead of a black alt-screen).
  • Issue #7 (mid-stream robustness, MalformedSseData) — landed; #6's pre/in-alt-screen split is orthogonal to #7's empty-data/malformed distinction (different error layers entirely).