Skip to content

A partially dropped gateway upstream is visible in status, and LLP 0193 records why the gateway idles (#653) - #658

Open
philcunliffe wants to merge 6 commits into
masterfrom
fix/issue-653
Open

A partially dropped gateway upstream is visible in status, and LLP 0193 records why the gateway idles (#653)#658
philcunliffe wants to merge 6 commits into
masterfrom
fix/issue-653

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Stacks on #650

This branch is built on fix/issue-649 (PR #650) and must merge after it. Its first two commits are #650's; only 1c83444 is new here. #650 introduced listening: false and details.upstreams_configured, and item 1 below is a direct extension of exactly that machinery, so building standalone on master would have meant either duplicating #650 or conflicting with it. It also would have made half the required test coverage unwritable: on master a fully dropped upstream set throws at source start, so "a full loss still warns" has nothing to warn about. Once #650 merges, this PR's diff collapses to the single commit.

Item 3 of the issue (should overall flip for a fully broken gateway?) is not touched: it is reserved for a human. See the note at the bottom, which this work sharpens rather than answers.

1. A partial upstream loss is now visible

compileUpstreams drops any entry missing name or base_url, per entry and silently. With two upstreams configured and one valid the routing table is non-empty, the proxy binds, listening is never set, and #650's diagnostic (which gates on listening === false) never fires. The user gets a gateway that works for one provider and silently routes nothing for the other. Nothing else catches it either: @hypaware/ai-gateway registers no config section so hyp config validate prints config ok, and diagnoseV1Config's gateway_missing_*_upstream check matches on provider, which a broken entry still has.

Source side. readConfiguredUpstreams now runs the real compileUpstreams over the raw entries and reports the difference, rather than re-deriving the "does this entry survive?" predicate (a second copy would drift from the table the proxy is actually built with). status().details gains upstreams_dropped (always, including as 0, so core can tell a healthy gateway from a status file written by an older build by the field's presence) and upstreams_dropped_names when any dropped entry had a usable name. launchListener also logs aigw.upstreams_dropped at warn on the bound path, because the daemon log is where an operator looks first when one provider's rows never arrive.

Status side. One check, gatewayDroppedUpstreams, replaces gatewayIdleWithConfiguredUpstreams: it reads configured-vs-dropped once and covers both shapes of the fault.

Subsumed the detection, kept the kinds distinct

The issue asked me to evaluate subsuming the all-dropped case into a single count comparison. I subsumed the detection and kept two kinds, chosen by whether the gateway is bound:

There is now exactly one place that decides whether upstreams were lost, so the duplication the issue was worried about does not exist. But collapsing to a single kind would have cost real specificity in both directions. "The gateway is running but listening on nothing ... clients will get connection refused" is true only of the total loss and is the sentence an operator staring at a dead gateway needs; it is actively wrong for the partial case, where the gateway is up. And consumers gate on kind: calling a listening gateway idle would make the kind a lie about the one thing its name asserts, and would merge "refuses every connection" with "quietly misroutes one provider" into a single alert. The two branches are mutually exclusive by construction (a gateway is either bound or it is not), so they can never double-report.

Back-compat: a status file written before upstreams_dropped existed can still answer the all-dropped question (idle plus a configured count), and it is deliberately not used to guess at a partial loss - guessing there would warn about every install whose adapter presets outnumber its config entries. Such a file starts reporting properly the moment the daemon restarts on this build.

2. LLP 0193

llp/0193-upstream-less-gateway-idles.decision.md (Decision, Accepted) records what #650 established and had nowhere to live. It cites LLP 0119 and 0120 as the pair that rules out the alternatives (hermes needs the gateway plugin for the shared materializer, and must never be proxied, so it composes the plugin while contributing no upstream), states that the "at least one upstream" invariant moves to the source rather than disappearing (startProxy still keeps it: binding a listener that can route nothing is still a bug at that layer), and records the visibility half above under #visible-when-unintended.

Its Consequences section records the accepted tradeoff explicitly, including that the warning is non-degrading and what that costs. Two @refs point at it: launchListener (#idle-not-throw) and the status diagnostic (#visible-when-unintended).

Regression tests

All fail before the change. Reverting the four non-test files with the tests in place gives 7 failing (# pass 22 / # fail 7); all 29 pass after.

test/core/status-gateway-idle.test.js drives the real gateway source and the real compileUpstreams, feeds the details it publishes into collectHypAwareStatus, and asserts:

  • 2 configured / 1 compiled -> gateway_upstreams_dropped, message counts the loss and names openai but not the healthy anthropic, overall stays healthy, and gateway_idle_no_upstreams does not also fire.
  • the same partial loss where the dropped entry has no name at all -> still warns, off the count.
  • 2 configured / 0 compiled -> gateway_idle_no_upstreams, keeping its own "listening on nothing" / "connection refused" wording, and gateway_upstreams_dropped does not fire.
  • a fully valid two-upstream config -> silent.
  • hermes-only (upstreams: []) -> silent and healthy, through the same comparison.
  • an older status file with no upstreams_dropped -> does not guess at a partial loss.

test/plugins/ai-gateway-source.test.js covers the boot log and the published details directly: the aigw.upstreams_dropped warn and its attributes on a partial drop, and that a fully valid config logs nothing and reports upstreams_dropped: 0 with no empty name list riding along in every healthy status file.

Health is not degraded by either warning, and that is asserted in three places.

Checks

For the human, not decided here (issue item 3)

The reserved question was whether overall === 'healthy' should stay unflipped for a fully broken gateway. This work sharpens it rather than answering it: there are now two non-degrading gateway warnings, and they are not equally severe. gateway_upstreams_dropped on a gateway routing three providers and missing a fourth is clearly a working install and clearly should not degrade. gateway_idle_no_upstreams on a gateway-only install is a captureless one. If the answer is ever "flip it", the split above is the seam to flip along - the kinds are already distinct, and gateway_upstreams_dropped should almost certainly stay non-degrading either way.

Fixes #653

test and others added 6 commits August 6, 2026 02:33
A picker run selecting `hermes` and nothing else composed
`@hypaware/ai-gateway` with `{ upstreams: [] }` and then failed at source
start with "ai-gateway: at least one upstream must be configured before
start", so a reachable first-run choice produced a broken install.

The gateway plugin does two separable jobs: at activation it contributes
the `ai_gateway_messages` dataset and the shared
`ai_gateway.projected_exchange` materializer, and at source start it runs
the proxy. `@hypaware/hermes` wants the first alone. It is "never
modified, configured, or proxied" (LLP 0119) so it contributes no
`gateway_upstream`, yet the materializer is a hard `requires.plugins`
dependency (LLP 0120) so its picker row must still compose the gateway
plugin.

So the source now treats an empty compiled routing table as idle: no
listener bound, `state.listen` left unset (`localEndpoint()` keeps
throwing rather than handing an attach a URL nothing is listening on),
`status()` reporting `listening: false` with a reason. A `reload()` that
brings an upstream binds normally, so idling is recoverable.

Co-Authored-By: Claude <[email protected]>
…us (#650 review)

Letting an upstream-less `ai-gateway` source idle rather than fail its
start is right for the config that wants it (hermes composes the plugin
for its materializer alone, LLP 0120), but the same path swallowed a
real misconfiguration. A gateway-only install whose TOML says `url =`
where `base_url` was meant has every entry dropped by `compileUpstreams`
without complaint, and `diagnoseV1Config`'s `gateway_missing_*_upstream`
check does not fire for that shape. On master that install read
`[failed]` / `degraded`; on this branch it read `[started]` / `healthy`
while the user's client got ECONNREFUSED, with the reason living only in
`status.message`, which the daemon snapshot never captures.

`hyp status` now emits a non-degrading `gateway_idle_no_upstreams`
warning when the gateway reports `listening: false` and a non-empty
`details.upstreams`. That field is the raw configured names, pre-compile,
so it discriminates exactly: a hermes-only install publishes `[]` and
stays quiet and healthy, a dropped or misspelled upstream publishes
`['anthropic']` and warns, naming the entry and the field that drops it.

Also: `aigw.idle_no_upstreams` logs at `warn` with the configured count
and names when the config listed upstreams and compiled to none, staying
at `info` for the genuine hermes-only case; and `status()` reads the
context `reload()` handed it rather than the boot-time closure, so
`details.upstreams` describes the config in force (pre-existing, but the
new diagnostic reads that field).

Tests: the diagnostic fires for the dropped-upstream shape and not for
the hermes-only one, and never degrades `overall`; a reload that removes
every upstream tears a live listener down and idles without error, which
ends capture for already-attached clients and is now pinned deliberately.

Co-Authored-By: Claude <[email protected]>
 review 2)

The `gateway_idle_no_upstreams` warning gated on `details.upstreams`, the raw
configured names, so it could not see the config it most needed to see.
`compileUpstreams` drops an entry for a missing `name` exactly as silently as
for a missing `base_url`, and a nameless entry contributes no name at all, so
`provider = "anthropic", base_url = "..."` published `upstreams: []` and read
as hermes-only: `healthy`, no diagnostic, ECONNREFUSED at the client. The
repo's own `diagnoseV1Config` affirms that config, because it matches an
upstream by its `provider` field.

The gateway source already computed the count (its idle log is a `warn` on the
strength of it); it just never published it. It now does, as
`details.upstreams_configured`, and core gates on the count instead of the
names. The names still ride along and still make the message concrete when
they exist; a status file from a build that predates the count falls back to
counting the names, so an older daemon's dropped `base_url` stays visible.
Hermes-only (0 configured) stays quiet and healthy, and the diagnostic remains
non-degrading.

The repair lines pointed at `hyp config validate`, which prints `config ok` and
exits 0 for exactly this config: `@hypaware/ai-gateway` registers no config
section, so nothing checks upstream shape. Replaced with the edit that fixes
it, naming the config file and both required keys, followed by the restart
that makes it take effect.

Co-Authored-By: Claude <[email protected]>
`compileUpstreams` drops an upstream entry missing `name` or `base_url`,
per entry and silently. #650 made the all-dropped case visible; a partial
loss stayed invisible, because the proxy binds, `listening` is never set,
and the diagnostic only fired when it was `false`. A user who typo'd one
of two upstreams got a working gateway that routed nothing for that
provider.

The gateway source now publishes `upstreams_dropped` (raw configured
entries minus compiled routes) and the names it can attribute, and warns
at boot when the routing table is smaller than the config asked for.
`hyp status` reads the difference: one comparison, two mutually
exclusive kinds - `gateway_idle_no_upstreams` when nothing survived,
`gateway_upstreams_dropped` when some did. Both non-degrading, following
the `gateway_port_fallback` precedent.

Also mints LLP 0193, recording the "idle rather than throw" decision
#650 established (which lived only in `proxy.js` and a PR thread) and
its accepted visibility tradeoff.

Co-Authored-By: Claude <[email protected]>
PR #643 (branch openclaw-cli-backend-denylist) is open and older, and it
already claims both 0193 and 0194. Master's highest is 0192, so scanning
only master made 0193 look free. The minting rule takes the next number
past the highest across the default branch and every branch, so this PR
yields and moves to 0195, the first number free everywhere.

Mechanical: the file, its title line, and the four @refs that point at
it. No prose or decision content changes.

Co-Authored-By: Claude <[email protected]>
…names

The non-idle status message claimed traffic for a dropped upstream is
"not proxied and nothing is captured" unconditionally, but
mergeUpstreams backfills a registered adapter preset (e.g. @hypaware/claude's
`anthropic`) whenever its name is not already in the compiled config table,
so a typo'd override can still route through the preset's default endpoint.
Reword to hedge on that case, and fix the message's hardcoded plural to match
the idle branch's configured/upstream(s) treatment (reachable at
dropped === configured === 1 exactly via the preset-backfill path).

Also dedupe readConfiguredUpstreams' droppedNames, which could print a
name twice when two same-named config entries both fail to compile, and
split the LLP 0195 @ref on gateway_upstreams_dropped so the "does not
flip overall" half cites #consequences instead of #visible-when-unintended,
which never says that.

Co-Authored-By: Claude <[email protected]>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 - fac5488 - verdict: findings (4, all non-blocking, all fixed)

Reviewed only the commits unique to this PR, diffed against the merge-base with
origin/fix/issue-649 (c39f71c), since this branch stacks on unmerged #650.
Reviewing against master would have re-reviewed #650's work.

The design is sound. One comparison (raw.length - compiled.length) published as
details.upstreams_dropped, one status check branching into two kinds on idle.
Back-compat for pre-upstreams_dropped status files is handled and tested. All four
@ref anchors resolve to real anchors. LLP number 0195 is collision-free (0193/0194
are claimed by origin/openclaw-cli-backend-denylist; the renumber was correct).

The regression tests were independently confirmed to prove the fix: reverting the
four non-test files to c39f71c with the tests in place yields 7 failures, exactly
matching the PR body's claim. The status tests drive the real createStartSource and
real compileUpstreams rather than hand-writing details, so the reported count and
the drop rule cannot silently diverge. That discipline is worth keeping.

Finding 1 (non-blocking, message accuracy) - FIXED

src/core/daemon/status.js:719 claimed traffic for that upstream is not proxied and nothing is captured for it. That overclaims. readConfiguredUpstreams
(ai-gateway/src/source.js:436-437) computes kept from compileUpstreams(raw), the
compiled config table, but mergeUpstreams (source.js:393) backfills any registered
preset whose name is not already in the map. @hypaware/claude registers a preset named
anthropic; @hypaware/codex registers openai and chatgpt - the same names the
walkthrough composes from gateway_upstream.

So a user who typos a key on a walkthrough-composed anthropic upstream gets
upstreams_dropped: 1 and a warning saying nothing is captured, while anthropic traffic
is in fact still proxied and captured via the preset (to the default endpoint, not their
intended proxy). Firing the warning is correct - the override silently is not taking
effect. Only the second clause was wrong.

Fixed at status.js:717 by hedging the tail: ... is not in the routing table, so unless an adapter preset already covers the same name, traffic meant for ... is not proxied and nothing is captured.

Finding 2 (non-blocking, cosmetic) - FIXED

status.js:719 hardcoded plural in of its ${configured} configured upstreams, unlike
the idle branch which pluralizes properly. dropped === configured === 1 while bound is
reachable exactly via Finding 1's preset-backfill path. Fixed at status.js:717 with the
same configured === 1 ? 'upstream' : 'upstreams' treatment.

Finding 3 (non-blocking, cosmetic) - FIXED

ai-gateway/src/source.js:440 - droppedNames: out.filter((n) => !kept.has(n)) printed
a name twice when two entries share a name and both drop, rendering (openai, openai).
Fixed at source.js:444 with a Set, and the adjacent comment extended to cover this
duplicate case alongside the opposite one it already explained.

Finding 4 (non-blocking, @ref honesty) - FIXED

status.js:709's gloss claimed ... and neither flips overall. The first half matches
#visible-when-unintended; the non-degrading claim lives in ## Consequences {#consequences}, not the anchored section. Per CLAUDE.md's "keep refs honest", fixed at
status.js:704-705: the first gloss trimmed to what its anchor says, plus a second
@ref LLP 0195#consequences [constrained-by] for the overall-verdict claim.

Test coverage gap that let Finding 1 through - CLOSED

Every existing status test used a bare createGatewayState() with no presets registered,
so the preset-backfill path was never exercised. Added
test/core/status-gateway-idle.test.js - "a dropped upstream whose name matches a
registered adapter preset is still proxied, so the warning does not claim silence"
. It
registers a real anthropic preset, configures an upstream with a url/base_url typo
so it fails to compile, drives it through the real createStartSource, and asserts the
warning fires, the gateway still binds, the singular phrasing reads correctly, and the
message hedges rather than asserting silence.

Verification

Head moved fac5488 -> eee845c. Each fix was positively verified in the committed tree
(the named file and symbol actually changed), not inferred from a green suite. Both LLP
0195 anchors confirmed present. No em dashes introduced.

Suite at the new head, run in an isolated worktree: 3600 passed, 0 failed, 1 skipped
(3599 baseline + the new test). tsc --noEmit clean.

One caveat for the record: test/core/status-first-sync-hold.test.js:99 fails when run
from a checkout carrying live daemon state (a stray daemon_loaded_no_pid diagnostic
leaks in). It passes in a clean worktree on both master and this head, so it is an
environment artifact and not attributable to this PR. CI is the authority.

For the human, not a blocker

Issue #653's item 3 - should overall flip for a fully broken gateway-only install? - is
deliberately not answered here, and LLP 0195 records it as an open product-policy question
rather than settling it. That matches the issue's own framing, but it does mean an
Accepted LLP is shipping with an unresolved question inside it. Worth confirming that
is the intended record rather than an oversight.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 - eee845c - verdict: clean

No blocking or non-blocking findings. Reviewed the commits unique to this PR
(1c83444, fac5488, eee845c) against merge-base c39f71c with
origin/fix/issue-649, since this branch stacks on unmerged #650.

Round 1's four fixes verified correct, not merely present

1. Reworded non-idle message (status.js:717) - correct. The hedge is true on the
branch that needs it: mergeUpstreams (source.js:376-393) backfills any registered
preset whose name is absent from the compiled config table, so a dropped anthropic
entry is still routed when @hypaware/claude is active. The converse was also checked -
the idle branch keeps the unhedged "clients will get connection refused", and that
stays true, because upstreams.length === 0 is reachable only when no preset backfilled
either. The hedge sits on exactly the branch that needs it and nowhere else.

2. Pluralization (same line) - correct. All three reachable shapes read correctly:
configured=1,dropped=1 gives "1 of its 1 configured upstream (anthropic) ... that entry
is ... traffic meant for it"; configured=2,dropped=1 gives "1 of its 2 configured
upstreams ... that entry is"; dropped=2 gives "those entries are ... meant for them".
The dropped-driven clauses pluralize independently of the configured-driven noun,
which is the correct coupling.

3. Set dedupe (source.js:445) - correct, no consumer breakage. This was the fix
most capable of introducing a new bug, since dropped stays a raw count while
droppedNames is now deduped. Independently confirmed no consumer treats them as
parallel: the boot log (source.js:216-222), the status details (source.js:97-100),
and core's gatewayDroppedUpstreams (status.js:194-208). None indexes, zips, or
length-compares the pair - status.js:207 uses droppedNames.length > 0 only as a
presence check for the back-compat fallback. The two were already documented as diverging
in the other direction (source.js:437-444), so the decoupling is deliberate.
Set preserves insertion order, so names stay in config order.

4. Split @ref annotations (status.js:704-705) - correct. Each gloss now matches
its own anchor: #visible-when-unintended says "One comparison, two kinds, mutually
exclusive" and enumerates both; ## Consequences {#consequences} says "The warning
does not degrade overall.
... loud in diagnostics, not a flip of the health
verdict." The other three refs in the diff also resolve (LLP 0114#fallback-is-visible,
LLP 0195#idle-not-throw, LLP 0139#repair-must-be-runnable).

The new test proves what it claims

test/core/status-gateway-idle.test.js:326-371 registers a real preset through the real
plugin API and drives the real createStartSource / compileUpstreams / mergeUpstreams,
then feeds the actually published details into collectHypAwareStatus - no hand-written
fixtures. Its preconditions are asserted rather than assumed (upstreams_dropped === 1
and details.port truthy), so it fails loudly if the backfill path stops being the thing
under test instead of silently passing.

It would catch a regression: the negative assertion matches the old wording at
fac5488:status.js:716 exactly, the positive assertion catches a revert of the hedge, and
/1 of its 1 configured upstream[^s]/ catches a revert of the pluralization.

Fresh pass over the unique diff

Nothing to report. Checked and found sound: the back-compat inference
dropped = rawDropped ?? (idle ? configured : 0) (status.js:196) cannot mis-fire;
idle && 0 < dropped < configured is unreachable, so the idle message's use of
configured is safe; stringList/nonNegativeInt keep the degenerate-details path
non-throwing; the new gateway_upstreams_dropped kind is in StatusDiagnosticKind and no
renderer switches on kind; readConfiguredUpstreams correctly uses the new ctx on the
reload(nextCtx) path.

Two things deliberately not raised as findings, recorded for completeness:
details.registered_presets is already published, so core could intersect it with the
dropped names and state definitively whether a preset covers each, rather than hedging -
the current text is true, this would only be more precise (optional follow-up, not a
defect). And the (openai) parenthetical is mildly ambiguous in isolation about whether
it names the dropped subset or the configured set, though the counts disambiguate it and
moving it would churn the shared idle message.

Suite + typecheck, clean worktree at eee845c

  • node scripts/run-tests.js: 3600 pass, 0 fail, 1 skipped - matches baseline.
  • npx tsc -p tsconfig.json --noEmit: clean, exit 0.
  • The known status-first-sync-hold.test.js live-daemon-state artifact did not appear in
    a clean worktree, confirming it is environmental and not attributable to this PR.

Still open for the human, not a blocker

Issue #653's item 3 - should overall flip for a fully broken gateway-only install? - is
deliberately unanswered, and LLP 0195 records it as an open product-policy question rather
than settling it. That matches the issue's framing, but an Accepted LLP shipping with
an unresolved question inside it is worth confirming as intended.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 6, 2026
philcunliffe pushed a commit that referenced this pull request Aug 6, 2026
- Renumber llp/0195-setup-ends-on-a-question -> 0198: 0195 collides with
  PR #658's fix/issue-653 doc, 0196/0197 are taken by PR #668. Updates all
  28 @ref sites plus the LLP 0135 Extended-by block.
- ask.js askableClients: a successful status probe reporting zero attached
  clients is evidence of detachment, not grounds to fall back to every
  launchable client on PATH. Only a thrown probe now falls through.
- ask.js --list: launchable claim now reflects actual resolvability
  instead of being hardcoded true.
- ask.js docblock/comment: four suggested prompts, not five.
- Add test/core/commands/ask.test.js covering askableClients' probe
  success/throw branches and the no-launcher/--list exit-code contract.
- Add two firstLookHadRows cases (slow, absent/error) to
  wizard/index.test.js pinning the two mutants the review found.
- Add a no-preauth assertion to client-launch-manifest.test.js: bundled
  launch args never carry --allowedTools or --dangerously-skip-permissions.

Co-Authored-By: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: deferred review findings from PR #650

1 participant