Repository navigation
os/v3/puppy: the chat page retries the NPU's own refusal (503 device_busy), and no request URL carries a user value - #926
Conversation
…busy), and no request URL carries a user value Two follow-ups to #925, both in the page the appliance serves. 1. engine#924 bounds the wait for the one device: a request that cannot have it within --queue-ms is REFUSED with a status instead of parked silently, and Lemonade's proxy does not retry that on the request path - so the page is the only layer that can. It now retries after the advertised delay (Retry-After, clamped to 10 s, 1 s when the header is absent) up to RETRIES = 3, saying 'NPU busy, retrying (2/3) in 1 s' while it waits, and after the last attempt it stops with the engine's own words instead of hanging or reporting zero chunks. Both shapes #924 can produce are handled, because this page streams: a 503 + Retry-After + {'error':{'type': 'device_busy'}} (status and headers reach the page through 1bit-webapp-proxy, which already forwards them), and - what the page actually sees for a streaming request - ONE 'data: {"error":{...}}' frame before 'data: [DONE]', which carries no type at all, so an error frame is retried when it says busy or when the stream had not produced a single chunk yet. A stream that fails after it produced content is reported, not silently requested again. 2. CodeQL, on the #925 diff: '?api=' made a fetch URL depend on a query parameter. It was a cycle-15 debug affordance nothing in the image uses (the launcher opens http://localhost:8080/ with no query string) and a way to make the appliance's own browser POST at another host, so it is gone: the paths are literal same-origin '/api/v1/...' and no tainted value reaches a request. ?model= / ?think= / the one-shot ?key= stay (none becomes a URL), and the key itself has not travelled in a URL since cycle 17. Proven in a real browser against the two documented refusal shapes (~/wt/os3i/c18-page-probe.py, screenshots in /tmp/busy-*): two streaming refusals then the answer; one 503 then the answer (status line reads 'NPU busy, retrying (1/3) in 1 s' in both); nine refusals -> four attempts then 'NPU busy: still refused after 4 attempts (the NPU is busy with another request) - press Send again in a moment'; and no refusals -> the answer unchanged. The probe also caught the bug that would have shipped: the predicate was named busy and var busy=false overwrote it, so the page answered 'TypeError: busy is not a function'. U3 webapp page now requires device_busy and 'NPU busy, retrying' in the page the RUNNING proxy serves, printing busy=N retry-text=N beside the other four counts.
|
#926 ready for review at 1a3cff9 — follow-up to #925 (merged as 5baf38a): the chat page retries engine#924's refusal (both shapes: 503 + Retry-After, and the streaming error frame before |
PR Reviewer Guide 🔍(Review updated until commit 8c40893)Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 1a3cff917 — one item, and it is a sentence in the body that the code does not implement: a busy-worded error frame arriving after content is retried, which also erases the answer the reader can already see.
I tested the shipped retry logic rather than reading it: the page's own <script> extracted verbatim and driven under node with a stubbed DOM/fetch (~/wt/reviews/test926.js, 9 cases). Eight are right:
OK 503 device_busy x1, then a stream requests=2 stat="done: 3 answer chunks …" retry: (1/3)
OK 503 device_busy x3, then a stream requests=4 retry: (1/3) (2/3) (3/3)
OK 503 device_busy x9 (never succeeds) requests=4 stat="NPU busy: still refused after 4 attempts (device_busy) - press Send again in a moment"
OK in-stream busy frame x2, then ok requests=3
OK 401 is not retried requests=1 stat="failed: Error: 401 - the API key is wrong or missing …"
OK 503 with Retry-After but no type requests=2
OK ?api= cannot move the request url=/api/v1/chat/completions (search was ?api=http://evil.example)
OK stream ends without [DONE] (content seen) requests=1 stat="… - the backend ended the stream without [DONE]"
The ninth is the item:
BAD busy frame AFTER content is reported, not retried
requests=2 stat="done: 1 answer chunk …"
The code's rule is
if (j.error) {
var why = …;
if (isBusy(j) || /busy/i.test(why) || !chunks) throw busyErr(1000, why);so the /busy/i disjunct fires regardless of chunks — an error frame whose message says "busy" is retried even when two content chunks have already been rendered. The body says the opposite one paragraph later ("A stream that fails after it has produced content is reported, not repeated — a mid-answer failure must not be silently requested again"), while the table above it says "retried when it says 'busy' or when the stream had not produced a single chunk yet". The two sentences disagree for exactly this case and the code implements the table.
It matters more than a wording nit because of what a retry does to the screen: tryOnce() starts with th.textContent = ""; an.textContent = "";, so the partial answer the reader is looking at is blanked and the turn is re-asked — my run ends with "1 answer chunk", the first attempt's two chunks gone.
Which way I would settle it: the sentence, not the table — retry only when nothing has been produced (isBusy(j) || (!chunks && /busy/i.test(why))). #924's refusal is pre-stream by construction (the engine decides before it answers), so a mid-stream "busy" is not a shape this page has to survive, and silently re-asking over visible text is the worse failure. Whichever correction you take, the body and the code have to say the same thing.
Everything else checks out:
?api=is gone and the CSR property is now testable: with?api=http://evil.examplein the search string the request still goes to/api/v1/chat/completions. The paths are literals,?key=/?model=/?think=never become URLs. CodeQL is SUCCESS on this head.Retry-Afterhandling: absent → 1 s,0→ immediate, clamped at 10 s (Math.min(s,10)*1000), andparseIntof a missing header lands on the 1 s default.- The
busy/isBusyshadowing bug they mention is not present in this head:var busyis the in-flight flag andisBusythe predicate, and my 401 case shows the flag still settles. - The gate row is honest about what it can see:
vm-check.shnow requiresdevice_busyandNPU busy, retryingin the page the proxy serves (busy=/retry-text=counts, expected tokenwebapp-thinking-picker-busy), and both strings are in the page (device_busy×4,NPU busy, retrying×2) — the row greps the served bytes, not the source tree. - Not-claimed section is right: this payload's engine predates #924, so on the c18 stick the retry is inert; the two shapes are reproduced against a probe.
CI at this head: build, Analyze×4, CodeQL SUCCESS, hw_gate SKIPPED, pr_agent CANCELLED (its own superseding run).
…nderers draw that screen now #921 merged the ratatui console TUI, and /etc/inittab's tty1 is the TUI when the image carries the binary and the dialog menu when it does not (the documented fallback). They draw the same facts and differ in one way that matters to a grep: dialog collapses runs of spaces in a --menu prompt, ratatui does not, so the TUI's screen says 'api http://…' and 'ssh root@…' with two spaces while the dialog menu's says one. U3's sibling row matches those strings from tty1's VGA buffer, so it would have printed two misleading missing-* words on a TUI image (and passed only because its marker keys on the single-spaced 'web UI http://' line). Measured on both renders of the same fixtures: 80x25 TUI snapshot and the dialog menu at 80x25.
|
#926 at b43be33 — one more commit, because #921 merged the ratatui console TUI while this was open: |
|
Persistent review updated to latest commit b43be33 |
There was a problem hiding this comment.
🟡 Changes recommended
The retry logic can restart a stream after answer or thinking output has begun, silently clearing the partial response instead of preserving it and reporting the error.
1 open issue
What changed in this PR
This PR adds bounded retries to the Puppy chat page for the NPU refusal responses introduced in #924 and removes the user-controlled API URL override.
Changes:
- Handles HTTP and streaming refusals with retry status and exhaustion messages.
- Keeps API requests same-origin by removing
?api=support. - Extends served-page checks for retry support and normalizes menu spacing across renderers.
| File | Description |
|---|---|
os/v3/puppy/vm-check.sh |
Checks retry markers in the served page and normalizes menu whitespace. |
os/v3/puppy/embed/opt/1bit/webapp/index.html |
Adds refusal retries and removes the API URL override. |
Review effort: Balanced
…ng, so a partial answer is never replaced Copilot's review of #926, and it is right: the retry condition was `isBusy(j) || /busy/i.test(why) || !chunks`, so (i) a busy-typed error after content was retried, and (ii) a *reasoning-only* stream (chunks = 0, rchunks > 0) also passed `!chunks` - and engine#924's own streaming refusal is exactly a reasoning-less-but-something shape. A retry clears the turn's two output elements, so either case silently replaced text the reader already had. Now a refusal is retried only when the stream produced NOTHING (no answer chunk AND no reasoning chunk): that keeps the c17 '0 chunks' recovery, which is what the retry exists for. A failure after any output is reported with the engine's own words and the chunk count, and the partial answer stays on screen.
|
Persistent review updated to latest commit 5dde8c0 |
|
Evidence for
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 5dde8c05b — both of my items are fixed, and I re-ran the harness that found them. CLEAN, build green.
1. The retry rule now matches the body's sentence. The condition is no longer a word heuristic:
var why = (j.error && j.error.message) || "the backend reported an error";
if (!chunks && !rchunks) throw busyErr(1000, why);
throw new Error(why + " (after " + (chunks + rchunks) + " chunk(s); the partial answer is kept)");Re-run of the same harness (~/wt/reviews/test926.js, the page's own <script> extracted verbatim and driven under node with a stubbed fetch/DOM), 10 cases — the one that failed my review now passes, and the case your new comment raises also passes:
OK busy frame AFTER content is reported, not retried requests=1
stat="failed: Error: the NPU is busy with another request (after 2 chunk(s); the partial answer is kept)"
OK busy frame after REASONING only is not retried requests=1
stat="failed: Error: the NPU is busy with another request (after 2 chunk(s); the partial answer is kept)"
OK 503 device_busy x1 / x3 / x9 2 / 4 / 4 requests, "(1/3)(2/3)(3/3)", then "still refused after 4 attempts"
OK in-stream busy frame x2, then ok 3 requests
OK 401 is not retried 1 request
OK 503 with Retry-After but no type 2 requests
OK ?api= cannot move the request url=/api/v1/chat/completions with ?api=http://evil.example in the search
OK stream ends without [DONE] (content seen) 1 request, "… - the backend ended the stream without [DONE]"
OK near-normal stream done, chunk count
So: a refusal retries only when the stream produced nothing at all — reasoning included — and after any output the partial answer is kept and the message says so. That is stricter than the table in the body was, and it is the right end of the ambiguity I flagged.
2. The CONNECT row no longer depends on how many spaces a renderer emits (b43be33b2). I re-ran the row's own word list and address regex against both real renderings — the dialog menu's collapsed one and the TUI's double-spaced one:
render 1 (dialog, runs collapsed): missing=[none] address-regex=ok
render 2 (ratatui, two spaces kept): missing=[none] address-regex=ok
tr -s ' ' is exactly the fix; with #921 merged, the TUI is what tty1 usually is now, so this closes the gap I left open on #925.
Nothing else changed that I can find: the 401 path still settles the in-flight flag, Retry-After is still clamped at 10 s with 1 s default, ?api= is still literal same-origin. build, Analyze×4, CodeQL green at this head.
|
Persistent review updated to latest commit 8c40893 |

os/v3/puppy: the chat page retries the NPU's own refusal (503 device_busy), because Lemonade does not
Follow-up to #925, on top of
mainafter it merged. engine#924 (app/unified.cpp) bounds the wait for the onedevice: a request that cannot have it within
--queue-ms(default 5000) is refused with a status instead ofparked silently — and Lemonade's proxy does not retry that on the request path, so the layer that can is the
page. Before #924 a queued request looked exactly like a stuck one (measured on the board: a 48-token probe waited
10.04 s behind a generation and then answered in 51 ms, while Lemonade's proxy gave up at ~6 s — the c17 board's
"0 chunks in 5.9 s", #911).
What the page now does
os/v3/puppy/embed/opt/1bit/webapp/index.htmlretries a refusal after the delay the engine advertises, up toRETRIES = 3, and says so on its status line (NPU busy, retrying (1/3) in 1 s). After the last attempt it stopswith the engine's own words (
NPU busy: still refused after 4 attempts (the NPU is busy with another request) - press Send again in a moment) instead of hanging or reporting zero chunks.It recognises both shapes #924 can produce, because this page streams:
503+Retry-After: 1+{"error":{"message":…,"type":"device_busy"}}1bit-webapp-proxy, which already forwards the upstream status and every non-hop-by-hop headerdata: {"error":{"message":"the NPU is busy with another request"}}frame, thendata: [DONE]app/unified.cppcloses every refusal properly, because a stream without[DONE]is what Lemonade reports as a hung backend)type, so the rule is: an error frame is retried when it says "busy" or when the stream had not produced a single chunk yetA stream that fails after it has produced content is reported, not repeated — a mid-answer failure must not be
silently requested again.
Retry-Afteris clamped to 10 s so a future engine cannot park a demo, and defaults to 1 s when the header isabsent (the streaming shape has none).
Proven in a real browser, against both documented shapes
~/wt/os3i/c18-page-probe.pygrew a refusal mode; Pale Moon under Xvfb, screenshots in~/wt/os3i/c18-busy-*:The probe also caught a real bug before any build saw it: the predicate was named
busy, the in-flight flag isalso
busy,var busy = falseoverwrote the function and the page answeredTypeError: busy is not a function.Renamed to
isBusy; the screenshots above are from after the fix.Gate
U3 webapp pagenow also requiresdevice_busyand theNPU busy, retryingtext in the page the running proxyserves, and prints
busy=N retry-text=Nwith the other four counts (webapp-thinking-picker-busy).Also here: the CodeQL finding on #925's diff
?api=made a fetch URL depend on a query parameter (client-side request forgery, low). It was a cycle-15 debugaffordance nothing in the image used — the launcher opens
http://localhost:8080/with no query string — and a wayto make the appliance's own browser POST at another host. It is gone: the paths are literal same-origin
/api/v1/modelsand/api/v1/chat/completions, so no tainted value reaches a request URL.?model=,?think=andthe one-shot
?key=stay (none of them becomes a URL), and the key has not travelled in a URL since cycle 17.The finding's thread on #925 was answered with this plan and resolved.
Not claimed
lemonade-onebit-rc-2026-11-03-c21, whoseengine predates npu serve: a request that cannot have the device is told so (engine#911) #924, so on that image the retry is inert — there is nothing to refuse. The two shapes above are
npu serve: a request that cannot have the device is told so (engine#911) #924's documented behaviour, reproduced by the probe, not a payload measurement.
NPU, i.e. a device ticket and a second client), so the end-to-end proof arrives with the first payload that
carries npu serve: a request that cannot have the device is told so (engine#911) #924.
enable_thinkinghalf was alreadyos/v3/puppy: cycle 18 — the chat page answers with thinking off and a model picker, the first headless screen says how to connect, and the desktop hands the screen back to the menu #925's.
U3 webapp pagerow's new two counts are asserted against the served page; they go green with the nextimage built from main. No image was rebuilt for this PR (a page and a row), so nothing here re-measures the c18b
artifact.