Two-hop connection model — socket state and live upstream #27
Labels
No labels
needs-info
needs-triage
ready-for-agent
ready-for-human
v2
wayfinder:grilling
wayfinder:map
wayfinder:prototype
wayfinder:research
wayfinder:task
wayfinder:ticket
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
aiko/denpa#27
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What to build
Two hops, honestly drawn. Replaces #11, whose premise changed with ADR-0001.
The hops are now client to go-between and go-between to adapter, where the adapter runs inside the Hermes gateway process. That second hop is a websocket the go-between itself holds (ADR-0003), which changes this issue for the better:
Upstream health is live, not last-known. The old issue was rescoped to "the upstream hop is last-known, stamped with when it was last proven, unknown until the first turn" because nothing reported Hermes' health. That is obsolete: if the adapter's socket is connected then Hermes is reachable by definition, and the go-between knows that continuously without polling anything.
So 5a becomes a genuine live-failure screen rather than a post-mortem, and 3a can report both hops truthfully instead of proving them with a test transmission.
Acceptance criteria
Reference
ADR-0001, ADR-0003. Design:
docs/design/denpa-receiver/README.md3a, 3c, 5a, 6a-6d.Branch:
issue/denpa-27-two-hop-status(denpa-client, pushed toorigin, not merged, no PR).What landed
src/renderer/src/services/two-hop.ts— the model. Two hops derived from the only two facts this client holds: its own socket phase, and the last report the go-between pushed.twoHopStatus(socket, report)returns alocaland anupstreamHopState(each with a✓/✕/!/—mark, the design's own JP label and a one-line reason), the station word, and the § 7e pal fault.Wiring:
websocket-handler.tsxhandles the pushed message and clears the report on any socket move;WebSocketContextpublishesupstream;use-receiver-chrome.tsfeeds the station bar'sidentity;PalContainerandOverlayPalContainerfeed#18/#23's existingupstream-downfault. No second status vocabulary — the station word and the 7e treatment table already existed and are consumed as they are.Visible behaviour: the station word is
圏外when my own socket is down,受信不能when my socket is fine and the go-between holds no adapter,同調中while connecting or while upstream has not been reported yet, and受信中only when both hops are reached. Upstream-down also puts her into the 7e treatment (不通, dead meter,上流が落ちてる).The wire — and the part that does not exist
This is the client half only. The issue body and the brief both say
#21added anupstream_statusmessage. It did not. There is noupstreamstring anywhere in the go-between'ssrc/open_llm_vtuber/. The fact exists there —AdapterHandler.attached,adapter_handler.py:47-49— but it is never sent to a viewer:_send_initial_messages(websocket_handler.py:118-142) pushesfull-text,set-model-and-confandcontrol: start-mic, and nothing else.So I defined the client's end of it:
{"type": "upstream-status", "attached": <bool>, "detail"?: <str>}on/client-ws, pushed on viewer connect and on every adapter attach/detach. Nothing polls and nothing requests it. A message that does not carry a booleanattachedis read as no report, never as detached.Until the go-between sends it, hop two draws as unknown (
—,まだ報告がない), which is calm rather than a fault. That keeps the client honest but leaves acceptance criteria 2 and 4 unreachable, and criterion 3 true only from the first report onward. The go-between change is small — push the currentadapter.attachedin_send_initial_messages, and broadcast on attach and inserve'sfinallyon detach — but it isollvt-hermes-bridgework, so it needs its own issue rather than a client-side guess. Recorded in the questions ledger.Tests
26 new tests in
src/renderer/src/services/two-hop.test.tsx. Suite: 301 passed in 28 files (baseline 275 in 27). Red first: with the model present but the wiring reverted tomain, exactly the 4 wiring tests failed; with the model absent the file failed to resolve. Typecheck adds no error in any file this branch touches.Covered: the two failures are drawn differently and never collapse into one word; a detached report is dropped the moment my socket goes, so a failure is never latched across a reconnect; unknown, connecting and down are distinct marks and distinct station words;
upstreamReportFromrefuses a missing or non-boolean flag; the station bar renders受信不能and not受信中; and the wiring tests assert the pushed case exists,setIntervaldoes not, andtwo-hop.tscontains nodenpa.string — hop one's frame vocabulary is the go-between's business, not this client's.Deliberately left out
二段とも同調verify block and § 3c's 局 panel. None of those surfaces exists in the client yet; the model exposes both hops as data for whoever builds them.· 端末承認済み). The client sends no stabledevice_idand no client message carries approval state — the go-between's own#21note says the viewer route authenticates nobody.auth-refused,no-modelandmic-loststill have no producer and stay absent.Not verified
No rendered output was inspected and no live turn was driven end to end. The upstream half of the model has never seen a real message, because none is sent yet. The required
cavecrew-reviewerpass did not run — no agent-spawn tool is available to a subagent in this harness — so assume one pair of eyes on this, not two.Review pass on
issue/denpa-27-two-hop-status. Five of six findings fixed; the sixth was fixed differently because the suggested fix is not reachable in this toolchain.This issue is now two branches, and they merge together or not at all.
aiko/denpa—issue/denpa-27-two-hop-status(client)aiko/ollvt-hermes-bridge—issue/denpa-27-upstream-status-push(go-between)Review's blocker was that the client answered an
upstream-statusmessage no producer sent, so the whole upstream half — parser, model, fault feed — handled a message that never arrives, under a name the client had invented alone. Re-verified before acting: noupstreamstring anywhere insrc/open_llm_vtuber/,AdapterHandler.attached(adapter_handler.py:47-49) read by nothing, and the three connect messages atwebsocket_handler.py:118-142carrying no hop-two field. Review offered either landing the producer or deleting the client half; deleting it abandons criteria 2 and 4, which only the go-between can satisfy, so the producer landed:broadcast_upstream_status, a push in_send_initial_messages, and announce-on-attach / announce-on-detach inAdapterHandler.serve. Both ends name the same message now. Go-between tests 117 -> 123.The rest:
—.local-downtreatment: § 7e's shape (不通, dead meter, acid edge, なぜ) with § 6a's own headlineどこにも繋がらない, deliberately not 上流が落ちてる — with our socket shut upstream is unverifiable, not down, and blaming it sends the operator to the wrong machine.role="status",aria-live="polite", and anaria-labelnaming it 受信状態, now that it tracks the connection instead of being a constant.WebSocketHandlerwith a stubbedwsService; not reachable —vitest.config.tsisenvironment: 'node'with no jsdom and no testing-library, so effects never run and there is nothing to assert on, and adding either is a new dependency the brief rules out. Instead the whole of what an arriving message does to hop two is the pureupstreamAfterMessage(previous, message)that the handler calls, covered by nine direct tests. What remains of the source-scanning is four mount checks, the same shape as the pre-existingchrome-is-mounted.test.ts.Every fix was checked by mutating it back and confirming exactly the intended test fails.
Deliberately left out: the § 5a / § 6a full failure screens, § 3a's verify block and § 3c's 局 panel — the model exposes both hops as data but no component draws a chain yet. Pairing state is still not part of the socket hop. The station word renders in one colour for all four states; § 5a never specifies the word's colour, so picking one would be design invention.
Client: 312 passed in 28 files (275 baseline). Typecheck 586 error lines, none in any file this branch touches. Assumptions in
.denpa-work/QUESTIONS.md.Branches
issue/denpa-27-two-hop-statusonaiko/denpa(client) — pushed.issue/denpa-27-upstream-status-pushonaiko/ollvt-hermes-bridge(go-between) — pushed. This second branch was not planned; see "The blocker" below for why it exists.What was built
A two-hop connection model for the client, plus the go-between producer that feeds it.
Client (
src/renderer/src/services/two-hop.ts, new): hop one is our own websocket to the go-between, read off the socket phase stringswsService.onStateChangealready publishes andwebsocket-handler.tsxalready stores aswsState(websocket-handler.tsx:280-284); anything that is not OPEN or CONNECTING is closed. Hop two is the go-between's socket to the adapter, learned from a pushedupstream-statusclient message.upstreamAfterMessageis the pure reducer the handler calls,stationStatusForpicks the station word,palFaultForpicks the § 7e pal treatment, andHopState/HOP_GLYPH/HOP_MARK_COLOR_TOKENpublish both hops as data.Wired into:
websocket-handler.tsx(the message case and the socket-transition discard),websocket-context.tsx,websocket-service.tsx(message shape),use-receiver-chrome.ts→station-bar.tsx(the station word), and bothpal-container.tsxandoverlay-pal-container.tsx(the fault).Go-between:
UPSTREAM_STATUS_TYPE+broadcast_upstream_status, a push from_send_initial_messages, and announce-on-attach / announce-on-detach inAdapterHandler.serve.All the Japanese strings come from the design doc, none were invented:
受信不能/圏外(docs/design/denpa-receiver/README.md:312),✓ cyan / ✕ magenta / ! acid / — grey(:315),アダプタ接続中/アダプタ未接続(:220),✕ 中継局 socket 開けない/— 上流 確認できない(:353),上流が落ちてる(:499),どこにも繋がらないfrom § 6a.README.md:396is the requirement that WebSocketContext distinguish the two hops.The blocker, and what I did about it
Review flagged as a blocker that
upstream-statushad no producer anywhere — a client-invented wire. I verified the gap myself rather than taking it on trust: noupstreamstring anywhere indenpa/src/open_llm_vtuber/**.py;AdapterHandler.attached(adapter_handler.py:47-49, set at:75on accept, cleared at:83-84in thefinally) is read by nothing exceptsend_up(:88); the three messages this client is actually sent on connect —{"type":"full-text","text":"Connection established"},set-model-and-conf,{"type":"control","text":"start-mic"}(websocket_handler.py:118-142) — carry no hop-two field at all.So the claim in this issue and in the agent brief that
#21added anupstream_statusmessage is false. The go-between holds the upstream fact and never forwards it. I did not build on that claim.For context on why hop two is a real, continuously-held socket rather than a poll: the adapter is the websocket client and dials out (
hermes_plugin/denpa/protocol.py:29,DEFAULT_WS_URL = "ws://127.0.0.1:12393/adapter-ws", per ADR-0003). The two routes are separate endpoints and the client never sees the adapter one —/client-wsatroutes.py:32,/adapter-wsatroutes.py:49-52— which is why this client must not parsedenpa.*frames itself.#21'snoticehandling (adapter_handler.py:113-119, mappingprotocol.FRAME_NOTICEto{"type": "notice", "text": text}) is the evidence that hop-one frames get translated by the go-between and never reach the client raw.Review offered two exits: land the producer, or delete the client's upstream half and mark criteria 2 and 4 blocked. I took the first, because the second abandons most of this issue. The go-between branch above is that producer. Both ends now name the same message, in the same change set.
Tests
Client: 312 passed in 28 files. Baseline on
mainis 275 in 27 files — the brief's "26 files" is a miscount, the 275 matches exactly. So +1 file, +37 tests.Go-between: 123 passed, baseline measured directly on
denpamainwith the new file ignored is 117. Clean +6.ruff checkandruff formatclean on the three touched files.What the tests cover: the socket-phase → hop-one mapping including the never-opened case;
upstreamAfterMessageacross a well-formed attach, a well-formed detach, a missingattached, a non-booleanattached, and an unrelated message type; the discard of a stored report on every socket transition;stationStatusForfor each hop pairing including the healthy-path regression below;palFaultForfor all five § 7e treatments; the glyph and colour tables; and on the go-between, the push on connect, the announce on attach, and the announce in the detachfinally.Every fix was verified by mutating it back and watching exactly the intended test fail —
stationStatusForreverted, thepalFaultForlocal branch removed, the?? previousremoved, the aria attributes removed, thelocal-downheadline swapped. The new go-between test file failed collection onUPSTREAM_STATUS_TYPEbefore the producer existed.Typecheck: 586
error TSlines, zero in any file this branch touches (grepped for two-hop, station-bar, pal-fault, pal-container, overlay-pal, websocket-handler, websocket-context, use-receiver-chrome, desktop-pal). 586 matches the baseline recorded for this branch. The brief says 585 formain; I could not measuremainin this worktree to attribute the one-line delta, becausemainis checked out in the primary worktree. The only four non-vendored errors are in files this branch does not touch (chat-history-panel.tsx,live2d-config-context.tsx,use-sidebar.ts,use-audio-task.ts). One new error I introduced intwo-hop.test.tsxwas found and fixed before committing.Review findings — all addressed
Two blockers, four majors, three minors. Fixed:
stationStatusForshowed同調中forever — with no producer, an unknown upstream downgraded every healthy OPEN socket, a user-visible regression againstmain's受信中(which came fromDEFAULT_IDENTITY,station-bar.tsx:35) and a direct violation of criterion 3. Now localok+ upstreamunknownreturns RECEIVING. Per-hop honesty stays in the chain data, where unknown keeps its grey dash.upstreamAfterMessage, which returns the previous report unchanged for anything that does not carry a booleanattached.local-down, reusing 7e's shape (不通, dead meter, acid edge, なぜ) with § 6a's headlineどこにも繋がらないrather than上流が落ちてる— with our own socket shut, upstream is unverifiable, not down.role="status",aria-live="polite", and anaria-label(STATION_STATUS_LABEL=受信状態).fs.readFileSyncbehaviour assertions are deleted, replaced with nine direct tests of the real reducer the handler calls, including the malformed-push case. Four mount-only checks remain, same shape as the pre-existingchrome-is-mounted.test.ts.MessageEventwidening narrowed.Skipped, with justification:
WebSocketHandlerwith a stubbedwsServicesubject and assert context changes" — is not reachable here.vitest.config.tssetsenvironment: 'node'; there is no jsdom and no testing-library, so a rendered component never runs its effects and there is nothing to assert on. Adding either is a new dependency the brief rules out, andnode_modulesis a junction so installing is not available anyway. I fixed the underlying complaint instead, by moving all of the wiring's logic out of the component into a pure function, so the tests exercise the real code path rather than source text. Ledgered.color="carrier") for all four states. § 5a specifies a dead meter and a status word but never the word's colour, so picking one is design invention. Ledgered with the upgrade path — bind it toHOP_MARK_COLOR_TOKENthe way the chain glyphs already are.Deliberately left out
二段とも同調verify block, § 3c's局panel. None of those surfaces exists in the client. The model publishes both hops as data and no component draws a chain.· 端末承認済み). The client sends no stabledevice_idand no client message carries approval state.caveman:cavecrew-reviewerdid not run. No Task/agent-spawn tool exists in a subagent's toolset here, so the required review gate is unmet. The review findings above came from the orchestrating pass, not from that agent.Could not verify at all
受信不能/圏外/同調中read as distinct at 10px; that the不通treatment actually lands on the pal in a running app.main, per above.Assumptions
{"type": "upstream-status", "attached": <bool>, "detail"?: <str>}on/client-ws, pushed on viewer connect and on every adapter attach/detach — never polled, never requested. Both ends now agree on this name because I wrote both, but it is still a name I chose. Renaming touchesUPSTREAM_STATUS_TYPEintwo-hop.ts:49, the case inwebsocket-handler.tsx, and the go-between constant. Nothing else.attachedis no report, never detached. Silence must not be drawn as failure.unknownregardless of the last report, and the stored report is discarded on every socket transition. The adapter reconnects on its own bounded backoff and the gateway has its own watcher, so a latched failure would outlive the fault.local-downone; § 6a (socket won't open) is a full-screen fault, not a pal one, and that screen is still undrawn. The station word says圏外.identity.statusis the right surface, not a new one.#10's faceplate is keyed on AiState (faceplate-spec.ts:81) and carries no connection axis; the station word already defaulted to受信中fromchrome-spec.ts:16, so it is the existing connection vocabulary.同調中is declared intwo-hop.tsrather than imported, becausefaceplate-spec.tsholds it as an inline literal in the LOADING treatment and exports no constant. Same word, two declarations.#18and Android overlay#23) — a dropped adapter is equally true on either.Acceptance criteria
1 and 3 are met, 3 from the first report onward. 2 and 4 are now reachable, because the producer exists — but neither has been observed working over a real socket, only in tests on each side. Treat them as implemented and unproven rather than done.
Process note
This worktree was preserved from an earlier interrupted attempt at this same issue and already contained most of the client implementation uncommitted. I read all of it, verified every wire claim it rested on myself (and that is how the false
upstream_statusclaim surfaced), added the wiring tests, and demonstrated red by reverting the wiring files tomain— four wiring tests failed with the model present, and the whole file failed to resolve with the model removed. I did not watch the original 22 model tests go red before they were written. Everything added in this pass was written test-first.Ledger entries are in
.denpa-work/QUESTIONS.md.Shipped. Merged to
mainat0e91bd9(phase C1).Closed as part of reconciling the board with
main: the autonomous run's policy was not to auto-close, so shipped work stayed open and the tracker read as though nothing had landed. That policy is now reversed — issues close when they land.