Skip to content

XERK-264: make hub refusals visible in both clients - #433

Merged
xerhab merged 2 commits into
mainfrom
XERK-264
Aug 12, 2026
Merged

XERK-264: make hub refusals visible in both clients#433
xerhab merged 2 commits into
mainfrom
XERK-264

Conversation

@xerhab

@xerhab xerhab commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes XERK-264.

The hub refuses commands with a status and a JSON {error} body — 409 org
mismatch / unsupported agent, 503 host offline, 404 stale attachment, 413 too
long, and 429 command queue full once XERK-261 lands. Neither client read the
status, so every one of those reached the operator as a click that appeared to
work: the menu closed, the card repainted, the spinner span out its TTL, and
nothing had happened.

Web

  • One shared failure path. TurmaNav.toast + TurmaNav.refusalText in
    nav.js (the .toast CSS moved from sessions.html into app.css, so every
    page has it). Failures only — nothing announces success through it, so a toast
    on screen always means a command did not run.
  • post()/del() on both pages resolve null on a refusal, having already
    toasted the hub's own words
    , and callers roll back what they painted
    optimistically on that null: the repo's "Starting…" spinner, the renamed card,
    the pending row. A refused resume no longer navigates to a session page with
    nothing to open. 401 still goes to /login.
  • chat.js: the compose button keeps its short, actionable labels (a
    sentence would not fit) and the hub's reason goes to the same toast — it used
    to render the bare status number. The model / mode / run-against chips drop
    their optimistic memo on a refusal instead of painting a switch that never
    happened. Vendored copies (glasses, veiller) re-synced byte-identical.

Android

  • mapHistory mapped any null-bodied response — including an error body it
    failed to decode — to Pending
    , so a refused /history polled 20×3s and
    gave up in silence. It is now HistoryResult.Failed, ending the poll with the
    hub's message.
  • FleetViewModel.run reported every refusal as "hub unreachable" (Retrofit
    throws a non-2xx and its catch swallowed the body); ChatViewModel
    announced "✓ kill queued" whatever happened. Both read hubErrorMessage now
    and drop the optimistic pending row.
  • hubErrorMessage gains a Response overload, worded to match the web's
    fallback ("the hub answered HTTP <n>") so the same refusal reads the same on
    both clients.
  • The Sessions screen had no snackbar host at all (found by the emulator
    pass, fixed here): only Fleet/Chat collected FleetViewModel.messages, so
    every command fired from the sessions list reported into a flow nobody read.

XERK-30 parity: the Android side lands in this PR. CLAUDE.md gains the
cross-cutting contract (both clients, one wording).

Verification

Unitnode --test turma/tests/*.test.js: 946 pass (was 933; +13 new in
nav.test.js, sessions.test.js, and a new dashboard-refusal.test.js that
drives index.html's own command path). Android testDebugUnitTest: all green,
+7 new (MapHistoryTest, HubResponseErrorTest). glasses vitest 455 pass,
veiller bun test 344 pass (vendored chat.cjs byte-identical).

Web, driven in Chromium against a real hub (turma/server.js + a real
heartbeat; script kept out of the repo): 13/13 checks.

  • A genuine hub refusal end to end — the composer sends a prompt past
    SPAWN_FIELD_MAX, the hub answers 413 {"error":"prompt too long"} — reads
    "Start failed — prompt too long" on both pages, with no stuck spinner.
  • A 429 kill: "Kill failed — the host's command queue is full", card still there.
  • A refused rename: says why, and the card goes back to its real name.
  • A refused resume: stays put instead of navigating.
  • Toast computed style checked (fixed, opaque, z-index above the page); 390px +
    dark; a command the hub ACCEPTED raises nothing.

Android, driven on the turma228 emulator against the same hub behind a
proxy injecting the refusals: a confirmed kill shows "✗ the host's command queue
is full" and the session stays; opening a session whose /history is refused
shows "✗ the agent is offline" in ~3s after one request, instead of 20 over
60 seconds.

Not verified: the glasses client (its hub-client.ts already throws on a
non-2xx; unchanged here) and the wide/tablet Android layout, which was reasoned
about but not driven.

xerhab added 2 commits August 12, 2026 13:00
The hub refuses commands with a status and a JSON {error} body — 409 org
mismatch / unsupported agent, 503 host offline, 404 stale attachment, 413 too
long, 429 command queue full. Neither client looked at the status, so all of
that reasoning reached the operator as a click that appeared to work.

Web. post() in sessions.html and index.html parsed the body for a cmdId and
never read res.status. Both now resolve null on a refusal, having first raised
the hub's own words through one shared toast (TurmaNav.toast + refusalText in
nav.js, .toast moved to app.css so every page has it), and every caller that
painted something optimistically takes it back on that null: the "Starting…"
spinner, the renamed card, the resume that used to navigate to a session page
with nothing to open. del() gets the same contract. In chat.js the compose
button keeps its short labels — a sentence would not fit — and the reason goes
to the same toast; the model/mode/run-against chips drop their optimistic memo
instead of painting a switch that never happened.

Android. mapHistory mapped any null-bodied response, including an undecodable
error body, to Pending — so a refused /history polled 20×3s and gave up in
silence. It is now HistoryResult.Failed, ending the poll with the hub's message.
FleetViewModel.run reported every refusal as "hub unreachable" (Retrofit throws
a non-2xx, which its catch swallowed) and ChatViewModel announced "✓ kill
queued" whatever happened; both now read hubErrorMessage and drop the optimistic
pending row. hubErrorMessage gains a Response overload, worded to match the web.

XERK-30 parity: the Android side lands here, in the same change.
…st of the bottom nav

Two things the end-to-end pass turned up.

Driving the emulator: a refused kill from the Android Sessions screen sent the
command, took the 429, worded the message — and showed nothing. Only FleetScreen
and ChatScreen collected FleetViewModel.messages, so every command fired from
the Sessions list (kill, rename, move, resume from the ended list) reported into
a flow nobody was listening to. It now hosts a snackbar of its own, like the
dashboard's. Verified on the emulator: "✗ the host's command queue is full".

Driving a 390px viewport: the toast is on every page now, and at its desktop
offset it sat on top of the phone bottom-nav. It lifts clear of it.
@xerhab
xerhab merged commit 54a0f37 into main Aug 12, 2026
8 checks passed
@xerhab
xerhab deleted the XERK-264 branch August 12, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant