docs(spec): considering-state + transport timeout; park streaming
Pivot M2 anti-freeze from streaming to a lighter considering-state: a rotating in-character 'the Master considers…' indicator (authored content, §13 spirit) on the proven non-streaming loop, plus the HttpTransport request-timeout fix so a hung call yields to the fallback instead of spinning forever. Meets §14's anti-freeze goal by reframing the wait; near-zero new failure surface. Streaming design PARKED (banner + roadmap ○), revisit if playtest shows the wait hurts. Conscious §14 deviation recorded per §18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
167
docs/superpowers/specs/2026-07-10-considering-state-design.md
Normal file
167
docs/superpowers/specs/2026-07-10-considering-state-design.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Considering-state + transport timeout (M2) — design
|
||||
|
||||
Date: 2026-07-10
|
||||
Milestone: **M2 — prove aliveness** (roadmap). Replaces streaming as the M2
|
||||
anti-freeze item; **polish, not proof**.
|
||||
Charter side (§2): **text/UI** — authored presentation while a call is in flight,
|
||||
plus a reliability fix. No game state; no AI; nothing mutates the canon log.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the model-call wait read as *the DM is speaking* rather than *the game is
|
||||
frozen* (§14) — by **reframing** the wait in-character instead of hiding it.
|
||||
While a `narrate()` / `speak()` call is in flight, the client shows a rotating,
|
||||
authored "the Master considers…" indicator (Claude-Code-style verb rotation on a
|
||||
~2s cadence). When the response lands, the indicator clears and the prose
|
||||
appears. If the call hangs, a request **timeout** gives way to the authored
|
||||
fallback instead of spinning forever — so the wait is both **framed**
|
||||
(in-character) and **bounded** (never hangs).
|
||||
|
||||
This meets §14's *goal* on the **proven, non-streaming loop**, with essentially
|
||||
no new failure surface. It is the counter-proposal to Narrator streaming (parked,
|
||||
`2026-07-10-narrator-streaming-design.md`), chosen because streaming is the POC's
|
||||
largest transport rework and adds mid-stream error/tag-boundary failure surface
|
||||
that a polish feature shouldn't carry while the aliveness question is already
|
||||
answered.
|
||||
|
||||
## What already exists (and is proven)
|
||||
|
||||
- **Client loop:** `DmService.narrate(canon_log)` and `NpcService.speak(...)` are
|
||||
async; the harness disables its button, `await`s the call, then renders the
|
||||
result. On any failure the service already degrades to an authored fallback
|
||||
line (§13) via `FallbackLibrary` (loads `res://content/fallback/*.json`, returns
|
||||
a hardcoded constant if the file is missing/malformed).
|
||||
- **Transport:** `HttpTransport` wraps a Godot `HTTPRequest` node and maps any
|
||||
non-`RESULT_SUCCESS` completion — **including `RESULT_TIMEOUT`** — to
|
||||
`DmResponse.failed(...)`, which the service degrades on. But `HTTPRequest.timeout`
|
||||
is left at its default **`0` (disabled)**: a hung proxy never completes, so the
|
||||
await never returns. This is the deferred `_http.timeout=0` minor.
|
||||
- **Config:** `ProxyConfig` already exposes `base_url()` from a `ProjectSettings`
|
||||
key with a default — the pattern for the new timeout setting.
|
||||
- **Harnesses:** `narrate_harness.gd` and `npc_harness.gd` both build their UI in
|
||||
code and follow the disable-button → await → render shape.
|
||||
|
||||
The two seams this needs (the fallback-as-content loader; the transport's
|
||||
non-success → degrade path) already work. The wait is currently just *blank*.
|
||||
|
||||
## Decisions
|
||||
|
||||
Settled during brainstorming; each is load-bearing.
|
||||
|
||||
1. **Reframe the wait, don't hide it.** §14's goal is that latency not read as a
|
||||
freeze. A rotating in-character indicator achieves that by framing the pause as
|
||||
the GM *thinking* — arguably more in-voice, for a turn-based game, than text
|
||||
spraying onto the screen. Streaming (which genuinely hides latency) is parked
|
||||
behind this, revisited only if playtesting shows the wait hurts.
|
||||
|
||||
2. **Loading is content (§13 spirit).** The considering phrases are **authored
|
||||
content**, loaded like the fallback lines — a small pool of dry, on-voice lines
|
||||
(§3: the narrator is dry and never winks). Not a spinner asset, not a
|
||||
hardcoded "Loading…". A missing/malformed file degrades to one hardcoded
|
||||
constant, so the indicator itself cannot fail.
|
||||
|
||||
3. **Rotate through the pool on a ~2s cadence.** Like Claude Code cycling
|
||||
*thinking / considering / cooking*. A typical ~2s call shows one or two lines;
|
||||
a slow one keeps rotating until the response or the timeout. The **phrase
|
||||
selection is a pure unit**; the **timer + label wiring is a thin node shim** —
|
||||
the project's standard pure-core/node-shim split.
|
||||
|
||||
4. **The rotation index is instance state, not reset per call.** One indicator
|
||||
instance persists on the harness and is reused across turns; `next()` advances
|
||||
a running index that `start()`/`stop()` do **not** reset. So turn 2 continues
|
||||
where turn 1 left off — the first phrase varies turn to turn **without**
|
||||
randomness (keeps the core deterministic and unit-testable; no `randi()` in the
|
||||
tested path).
|
||||
|
||||
5. **The timeout fix lands here.** `ProxyConfig` gains
|
||||
`request_timeout_seconds()` (a `ProjectSettings` key, default **35.0** —
|
||||
just above the server's `OLLAMA_TIMEOUT_SECONDS=30` so the server's own
|
||||
error/degrade surfaces first when the *model* is slow, while still bounding a
|
||||
fully-hung proxy). `HttpTransport` sets `_http.timeout` to it. A hang →
|
||||
`RESULT_TIMEOUT` → the **existing** `failed()` → degrade path fires. This is
|
||||
why the timeout is essential *to this feature specifically*: without it the
|
||||
considering-state would rotate **forever** on a hang — worse than a frozen
|
||||
button, because it looks like progress.
|
||||
|
||||
6. **No server changes.** The proxy and the `/dm/narrate` · `/npc/speak`
|
||||
contracts are untouched. This is entirely client-side presentation + one client
|
||||
config value.
|
||||
|
||||
7. **Wire it into both harnesses.** Both prose surfaces (narrate + npc) get the
|
||||
indicator — same small integration, and the NPC surface is where the aliveness
|
||||
feel matters most. The component is reusable by the eventual real dialogue UI.
|
||||
|
||||
## Components
|
||||
|
||||
- `client/scripts/ui/considering_phrases.gd` — `ConsideringPhrases` (`class_name`,
|
||||
pure `RefCounted`). `_init(path := DEFAULT_PATH)` loads a string pool from
|
||||
`res://content/considering.json` (`{"considering": ["…", …]}`) using the same
|
||||
robust instance-`JSON.parse()` loader `FallbackLibrary` uses (never
|
||||
`JSON.parse_string()` — the project's gutconfig turns its engine error into a
|
||||
false test failure). `next() -> String` returns the pool entry at a running
|
||||
index and advances it (wrapping); an empty/missing pool returns
|
||||
`LAST_RESORT` ("The Master considers your words."). Unit-tested.
|
||||
- `client/scripts/ui/considering_indicator.gd` — `ConsideringIndicator` extends
|
||||
`Node` (a repeating rotation needs a `Timer` in the tree, so it can't be a pure
|
||||
`RefCounted`). The harness `add_child()`s it; in `_ready` it creates a repeating
|
||||
`Timer` child (`ROTATE_SECONDS := 2.0`, `one_shot=false`). `start(label:
|
||||
Control)` sets the label to `phrases.next()` and starts the timer, whose timeout
|
||||
sets the label to `phrases.next()`; `stop()` stops the timer and clears the
|
||||
label. Holds one `ConsideringPhrases` for its lifetime (decision 4). The
|
||||
timer/tree wiring is the untested node shim (verified by the harness run); the
|
||||
phrase logic it drives is tested via `ConsideringPhrases`.
|
||||
- `client/content/considering.json` — authored pool. Starter lines (dry, §3),
|
||||
reviewed like writing:
|
||||
- "The Master considers your words."
|
||||
- "Somewhere, dice are gathered."
|
||||
- "The tale weighs its next turn."
|
||||
- "The dark shifts, thinking."
|
||||
- "A page is turned, unhurried."
|
||||
- `client/scripts/net/proxy_config.gd` — add `TIMEOUT_SETTING`,
|
||||
`TIMEOUT_DEFAULT := 35.0`, `request_timeout_seconds() -> float` (mirrors
|
||||
`base_url()`).
|
||||
- `client/scripts/net/http_transport.gd` — in `_init`, set
|
||||
`_http.timeout = ProxyConfig.request_timeout_seconds()`. No other change; the
|
||||
existing `result != RESULT_SUCCESS → failed()` path already handles the timeout.
|
||||
- `client/scripts/harness/narrate_harness.gd` and `.../npc_harness.gd` — build a
|
||||
`ConsideringIndicator`; call `start(status_label)` before `await`ing the service
|
||||
and `stop()` after it returns (before rendering the result). Add a small status
|
||||
`Label` if the harness lacks one to host the rotating text.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Slow call** → the indicator rotates; on return it stops and the prose renders.
|
||||
- **Hung proxy / unreachable** → `HTTPRequest` times out at
|
||||
`request_timeout_seconds()` → `RESULT_TIMEOUT` → `DmResponse.failed(...)` → the
|
||||
service returns its degraded fallback; the harness `stop()`s the indicator and
|
||||
shows the authored fallback line. No infinite spin, no error string (§13).
|
||||
- **Missing/malformed `considering.json`** → `ConsideringPhrases` returns its
|
||||
hardcoded `LAST_RESORT`; the indicator still works.
|
||||
|
||||
## Testing
|
||||
|
||||
- **`ConsideringPhrases` (pure, GUT):** cycles the pool in order and wraps; the
|
||||
running index is **not** reset between logical "calls" (decision 4); a
|
||||
missing/empty file yields `LAST_RESORT`; a well-formed file loads its pool.
|
||||
- **`ProxyConfig.request_timeout_seconds`:** returns `TIMEOUT_DEFAULT` with no
|
||||
setting; honours a `ProjectSettings` override.
|
||||
- **`HttpTransport` timeout wiring:** constructing `HttpTransport` with a real
|
||||
`HTTPRequest` sets `http.timeout == ProxyConfig.request_timeout_seconds()`
|
||||
(read the property — a light assertion on the otherwise-untested shim).
|
||||
- **Degrade-on-timeout is already covered:** the existing
|
||||
`test_transport_failure_degrades` service tests exercise `failed()` → degraded
|
||||
result; a timeout maps to `failed()`, so no new service test is needed (note it,
|
||||
don't duplicate).
|
||||
- **Indicator:** the `Timer`/tree wiring is verified by running the harness (see
|
||||
the rotation, see it stop when prose arrives, kill the proxy → see it rotate
|
||||
then yield to the fallback after the timeout). No unit test for the timer.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:** `ConsideringPhrases` (pure) + `considering.json`; `ConsideringIndicator`
|
||||
(node shim); `ProxyConfig.request_timeout_seconds` + `HttpTransport` timeout
|
||||
wiring; both harnesses showing the indicator around the await; the test set above.
|
||||
|
||||
**Out:** streaming (parked, `2026-07-10-narrator-streaming-design.md`); any server
|
||||
change; real dialogue UI; combat flavor; companion-flavored considering lines
|
||||
(a nice later touch — Brannoc/Cadwyn voicing the wait — but not now).
|
||||
Reference in New Issue
Block a user