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).
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. |
|
python | medium | 60 | 0.85 |
|
|
|
|
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
--newmode) 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.bodyis already truncated to 1024 bytes atSessionApiFailed.__init__per issue #2 INV-004;!ris 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--sendand 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.AsyncClientlifetime widens: now spans the pre-flight HTTP AND the App's lifetime, owned byrun_tuiviaasync with.
On disk: none (unchanged).
Invariants
- INV-001 [hard]: Session resolution (mint when
--new; attach when--session) MUST complete BEFOREApp.run()enters the alt-screen. Errors at this phase MUST print tosys.stderr(the real terminal, not a RichLog widget) and MUST causerun_tuito return the appropriate exit code WITHOUT callingApp.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.AsyncClientis owned byrun_tuiviaasync with. The client is opened BEFORE the pre-flight session resolution, passed by reference toRatatoskrApp.__init__, accessed by the App viaself.clientduring streaming, and closed by the sameasync withAFTERApp.run()returns. The App is a consumer of an externally-owned client; it MUST NOT callself.client.aclose()(theasync withdoes that). Issue #4'son_unmountSTEPS 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 byrun_tuiand REQUIRED at construction. The app no longer mints anything; it consumes pre-resolved state. - INV-004 [hard]:
on_mountSTEPS narrow: open the identity Static widget, setself.state = "idle", setself.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_idis 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/ explicitlog.writeand return the app toidlestate. 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 lofnhits 422end_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 /sessionshappens once per--newlaunch; just sequenced beforeApp.run()instead of insideon_mount. - [security] Same as today —
Authorizationheader on the client, no logged credentials. - [style] Async-native at the resolve layer.
run_tuibecomes a thin sync wrapper aroundasyncio.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→ becomesagent_not_found_on_resolveat the_resolve_then_runlayer. Assertion shape:capsys.readouterr().errcontains[agent_not_found];run_tuireturns 12; no app instance ever entered alt-screen.session_api_failed_on_mount→session_api_failed_on_resolve.network_error_on_mount→network_error_on_resolve.client_open_after_mount→client_open_after_resolve(client opened by run_tui, accessible viaself.clientonce the app is mounted).unmount_closes_client→run_tui_closes_client_on_app_exit(asserts the async-with closed the client afterapp.run_async()returned).
New TESTS (in the _resolve_then_run block at the run_tui layer):
alt_screen_never_opens_on_resolve_error [trace]: monkeypatchRatatoskrApp.run_asyncto a sentinel that fails the test if called; set up respx to return 404 from POST /sessions; assertrun_tuireturns 12; assert the sentinel was NEVER invoked. Directly probes INV-001 (alt-screen MUST NOT open).client_lifetime_owned_by_run_tui [trace]: spy onhttpx.AsyncClient.aclose; successful run; assert exactly oneaclosecall AFTERapp.run_asyncreturned, 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 bycli._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_runcoverage GREEN underuv run pytest tests/. uv run ruff check src/ tests/clean.- Boundary smoke
tests/test_no_worldtree_imports.pystill 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 mimiragainst 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.tuishell) — landed on main; this issue amends its contract. - Issue #5 (
--end-user-idfor 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).