From 5a1d2a2fbe9be66733520b90fb1daaed48e4dac5 Mon Sep 17 00:00:00 2001 From: gen16k Date: Mon, 14 Sep 2026 02:21:23 +0900 Subject: [PATCH] comments: the retired Claude auto route is described as retired (#1358) L97 (waired-ai/waired#1313) retired the auto route, the post-dispatch fallback and its reroute notice, the X-Waired-Fallback / X-Waired-Fallback-Allowed headers, the Stop hook notice, the gateway-models.json cache write with its discovery flag, and the waired/subagent label (#1183-#1188, decision docs/decisions/20260903/0333-no-automatic-crossing-to-or-from-anthropic.md). Fifteen comments still explained one of those as today's behaviour, and a reader of that comment alone would take the mechanism for live: - claudemanaged/hook.go and managedsettings.go headers said waired installs the Stop hook and co-writes the discovery flag; - gateway/server.go, probe.go, anthropic.go, anthropic_models.go, peerwait.go and agentconfig/config.go said a pre-commit abort makes "auto mode reroute the turn", that TTFB budgets are armed only behind the X-Waired-Fallback-Allowed gate, that the over-window 400 copies Anthropic's wording, and that discovery reads max_input_tokens; - cmd/waired-agent/inference.go said the class comes from the managed-settings subagent label and that a stalled peer reroutes; - proxy/intercept/server.go listed dispatchAuto's fallbackRecorder as a writer shape; router/endpoint_router.go and gui/tray/state.go named the reroute notice and the auto route; - the e2e harness (harness.go, budget.go, legs.go) described the fail-open replay and the X-Waired-Fallback evidence as current. Each is rewritten to say what happens now (a 4xx that names the peer, no reroute) and, where the history explains the shape, to say so in the past tense with the retiring PR. The one piece of code that changes is internal/agentgrade/probe.go: it read the X-Waired-Fallback header, which nothing sets any more, into an error-message marker. The field, the read and the test that staged the header are removed; the local gateway the probe drives has no passthrough to leave through. Fixes #1358 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RiDigZqYQR62KtoUukmehu Signed-off-by: gen16k --- cmd/waired-agent/inference.go | 20 +++--- internal/agentconfig/config.go | 26 ++++--- internal/agentgrade/probe.go | 23 +++---- internal/agentgrade/probe_test.go | 22 ------ internal/e2e/integration/budget.go | 3 +- internal/e2e/integration/harness.go | 12 ++-- internal/e2e/integration/legs.go | 4 +- internal/gateway/anthropic.go | 35 +++++----- internal/gateway/anthropic_models.go | 14 ++-- internal/gateway/peerwait.go | 2 +- internal/gateway/probe.go | 69 ++++++++----------- internal/gateway/server.go | 28 ++++---- internal/gui/tray/state.go | 6 +- internal/integration/claudemanaged/hook.go | 23 ++++--- .../claudemanaged/managedsettings.go | 30 ++++---- internal/proxy/intercept/server.go | 17 ++--- internal/router/endpoint_router.go | 2 +- 17 files changed, 163 insertions(+), 173 deletions(-) diff --git a/cmd/waired-agent/inference.go b/cmd/waired-agent/inference.go index 4c0fbd565..492d643ef 100644 --- a/cmd/waired-agent/inference.go +++ b/cmd/waired-agent/inference.go @@ -962,11 +962,12 @@ func startInferenceSubsystem(ctx context.Context, wg *sync.WaitGroup, logger *sl // overlay set it is mesh-capable — the intercept is a LOCAL surface // (loopback from Claude Code on this device), so a remote dispatch // here is one hop and the receiving peer's overlay stays local-only. - // The directiveSelector applies the operator's per-class node policy - // (main / sub → local | pinned peer) per request; ClassifyModel - // derives the class from the managed-settings subagent label; the - // resolver maps unresolvable Anthropic ids to the class target - // node's model (#600 extended per-class). + // The directiveSelector picks WHICH Waired node serves each request — + // this device or a mesh peer, following the operator's `waired worker` + // preference unless the /model id names a node; classifyClaudeClass + // derives the class from the X-Claude-Code-Agent-Id header + // (waired-agent#1186); the resolver maps unresolvable Anthropic ids to + // the serving node's model (#600). claudeDeps := baseGatewayDeps() claudeDeps.Selector = &directiveSelector{p: provider} // AllowOpenAI stays false: the intercept surface speaks Anthropic @@ -983,8 +984,9 @@ func startInferenceSubsystem(ctx context.Context, wg *sync.WaitGroup, logger *sl // directives under the same flag. claudeDeps.ClaudeModelDirectives = cfg.ClaudeModelRouteDirectives // #757: bound the pre-first-byte window on a PEER leg per traffic class so a - // stalled-but-reachable serving peer reroutes (auto mode only — see the - // intercept's X-Waired-Fallback-Allowed gate) instead of hanging the turn. + // stalled-but-reachable serving peer ends the turn with a 4xx that names it + // instead of hanging the turn (every peer leg, the pinned one included — the + // auto-only gate went with the auto route, waired-agent#1184). // Subagents get the tighter budget; 0 disables. The gateway arms this only // for remote:* selections, so a locally-served turn is never affected. claudeDeps.TTFBBudget = func(class string) time.Duration { @@ -3154,8 +3156,8 @@ func (p *agentInferenceProvider) EngineReady() (bool, string) { // ActiveSelection. It backs the Claude-intercept model mapping (#600): // unlike EngineReady it does NOT gate on ready/parked state — a mid-pull // or loading model must still resolve so the router can answer with the -// precise ErrModelNotReady (503 + Retry-After, which auto mode falls back -// on) rather than a blanket "no local model". Only a missing selection +// precise ErrModelNotReady (503 + Retry-After, which the client waits +// out) rather than a blanket "no local model". Only a missing selection // reports false. // // The setup report reads it too, through setupActiveModelID: on a host diff --git a/internal/agentconfig/config.go b/internal/agentconfig/config.go index 89309d468..12f559291 100644 --- a/internal/agentconfig/config.go +++ b/internal/agentconfig/config.go @@ -172,14 +172,16 @@ type InferenceConfig struct { // ClaudeTTFBBudgetMainMs / ClaudeTTFBBudgetSubMs bound the pre-first-byte // window (milliseconds) for a MAIN / SUBAGENT Claude request routed to a // mesh PEER (#757). If the peer returns no response headers within the - // budget the leg is aborted BEFORE the response commits, so an auto-routed - // turn reroutes to the Anthropic API instead of hanging on a - // stalled-but-reachable peer; a pinned (route=waired) leg is never - // affected. These are generous infinite-hang BACKSTOPS, not snappy reroute - // thresholds: /healthz readiness does not imply the model is loaded, so a - // cold model load legitimately sits inside this window. Subagents get the - // tighter budget (a stalled subagent is cheap to reroute and reads to the - // user as a hang). 0 disables the deadline for that class. + // budget the leg is aborted BEFORE the response commits, so the person + // reads a 4xx that names the peer instead of watching a hang; nothing + // reroutes the turn to the Anthropic API + // (docs/decisions/20260903/0333-no-automatic-crossing-to-or-from-anthropic.md), + // and the pinned peer's leg is covered like any other. These are generous + // infinite-hang BACKSTOPS, not snappy thresholds: /healthz readiness does + // not imply the model is loaded, so a cold model load legitimately sits + // inside this window. With a ClaudePeerWaitCeiling*Ms set for the class + // the budget is a grace period rather than a deadline (below). 0 + // disables the deadline for that class. ClaudeTTFBBudgetMainMs int `json:"claude_ttfb_budget_main_ms"` ClaudeTTFBBudgetSubMs int `json:"claude_ttfb_budget_sub_ms"` @@ -224,9 +226,11 @@ type InferenceConfig struct { // real turns for. // // The cost of the larger figure is that such a turn is silent for longer: - // a leg the intercept may reroute cannot be held open with an SSE - // keepalive (docs/decisions/20260821/2142), so nothing is written until - // the first byte either way. + // a peer leg is not held open with an SSE keepalive + // (docs/decisions/20260821/2142 — its non-2xx can be an over-window 400 + // that a committed keepalive could no longer relay, see + // internal/gateway/anthropic.go), so nothing is written until the first + // byte either way. // // 0, or any value not longer than the main budget, leaves the flat // deadline in place. diff --git a/internal/agentgrade/probe.go b/internal/agentgrade/probe.go index 5f849d86e..4b62590cd 100644 --- a/internal/agentgrade/probe.go +++ b/internal/agentgrade/probe.go @@ -362,20 +362,18 @@ func (p Probe) one(ctx context.Context, model string, c Case, offered map[string // upstreamError carries a non-2xx from the gateway with its body, so // the caller can tell an engine that is down from an engine rejecting // the model's output. +// +// It used to carry the X-Waired-Fallback marker as well, which told "the +// request left local routing entirely" apart from "the model answered +// badly" (waired-agent#29). That header went with the auto route +// (waired-agent#1184), and the local gateway this probe drives has no +// passthrough to leave through. type upstreamError struct { - Status int - Body string - Fallback string + Status int + Body string } func (e *upstreamError) Error() string { - if e.Fallback != "" { - // Without this marker, "the model answered badly" and "the - // request left local routing entirely" look identical from - // here, and that ambiguity cost a week once (waired-agent#29). - return fmt.Sprintf("HTTP %d (X-Waired-Fallback: %s — the request did not stay local): %s", - e.Status, e.Fallback, truncate(e.Body)) - } return fmt.Sprintf("HTTP %d: %s", e.Status, truncate(e.Body)) } @@ -421,9 +419,8 @@ func (p Probe) post(ctx context.Context, req gateway.AnthropicRequest) (gateway. } if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { return gateway.AnthropicResponse{}, &upstreamError{ - Status: httpResp.StatusCode, - Body: string(raw), - Fallback: httpResp.Header.Get("X-Waired-Fallback"), + Status: httpResp.StatusCode, + Body: string(raw), } } diff --git a/internal/agentgrade/probe_test.go b/internal/agentgrade/probe_test.go index 038961680..3766386b7 100644 --- a/internal/agentgrade/probe_test.go +++ b/internal/agentgrade/probe_test.go @@ -89,28 +89,6 @@ func TestRun_engineDownIsNotAVerdict(t *testing.T) { } } -// A fail-open — the request escaping local routing to the real upstream -// — must be visible in the error, not silently graded. Without the -// marker, "the model answered badly" and "this answer came from -// somewhere else entirely" are indistinguishable (waired-agent#29). -func TestRun_failOpenMarkerSurfaces(t *testing.T) { - p := probeAgainst(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("X-Waired-Fallback", "local_status_404") - w.WriteHeader(http.StatusBadGateway) - _, _ = w.Write([]byte(`{"error":"upstream"}`)) - }) - rep, err := p.Run(context.Background(), "subject") - if err != nil { - t.Fatalf("Run: %v", err) - } - if rep.Grade != GradeUnknown { - t.Fatalf("grade = %q, want %q", rep.Grade, GradeUnknown) - } - if !strings.Contains(rep.Results[0].Detail, "did not stay local") { - t.Errorf("detail must name the fail-open, got %q", rep.Results[0].Detail) - } -} - func TestRun_passingModel(t *testing.T) { p := probeAgainst(t, func(w http.ResponseWriter, r *http.Request) { var req struct { diff --git a/internal/e2e/integration/budget.go b/internal/e2e/integration/budget.go index 7d3849738..b17d74f9a 100644 --- a/internal/e2e/integration/budget.go +++ b/internal/e2e/integration/budget.go @@ -123,8 +123,7 @@ func engineDeadReason(body []byte) string { } // retryableStatus reports whether a bare status code is plausibly transient. -// Used both for a direct response and for the local status recovered from the -// fallback header (where no local body survives). +// Used for the status of a direct response. func retryableStatus(status int) bool { switch status { case http.StatusTooEarly, // 425 — engine/model not ready yet diff --git a/internal/e2e/integration/harness.go b/internal/e2e/integration/harness.go index c6ec21388..4e6bb057d 100644 --- a/internal/e2e/integration/harness.go +++ b/internal/e2e/integration/harness.go @@ -192,11 +192,13 @@ func routeRecorded(leg Leg) bool { // --- HTTP drives --- // driveResponse is one drive attempt's response. It carries the HEADERS the -// old (status, body) pair threw away: on a Claude-leg fail-open the local -// error is discarded before the upstream replay, and X-Waired-Fallback is the -// only evidence of it left on the wire. Dropping that header is exactly why a -// dead model runner read as "waired proxy could not reach the upstream API" -// for a week (waired-agent#29). +// old (status, body) pair threw away: the X-Waired-* headers are where the +// gateway says what served the turn and why a leg failed. Dropping them is +// exactly why a dead model runner once read as "waired proxy could not reach +// the upstream API" for a week (waired-agent#29) — back then a Claude-leg +// fail-open replayed the turn upstream and X-Waired-Fallback was the only +// evidence left on the wire. That replay is retired (waired-agent#1184); the +// sentinel keeps proving it stays retired. type driveResponse struct { Status int Header http.Header diff --git a/internal/e2e/integration/legs.go b/internal/e2e/integration/legs.go index ff50762c3..842ebb54b 100644 --- a/internal/e2e/integration/legs.go +++ b/internal/e2e/integration/legs.go @@ -73,8 +73,8 @@ func claudeRealAnthropicIDLeg() Leg { // claudeUnresolvableIDLeg keeps the #600 mapping covered. The Anthropic ids // Claude Code sends name no catalog model, so an alias miss must resolve to -// something servable instead of 404ing into the auto-fallback (the -// local_status_404 class). +// something servable instead of 404ing (once the local_status_404 fallback +// class, now a 404 the person reads). // // It exists because rewriting claudeRealAnthropicIDLeg against #1091 would // otherwise take that coverage to ZERO: the tiny alias resolves in the diff --git a/internal/gateway/anthropic.go b/internal/gateway/anthropic.go index c5eec42de..3c540f2e1 100644 --- a/internal/gateway/anthropic.go +++ b/internal/gateway/anthropic.go @@ -48,9 +48,9 @@ func writeAnthropicError(w http.ResponseWriter, status int, errType, message str // becomes a 400. A 500 tells a well-behaved client "transient, try // again" — Claude Code retried one 11 times over 182 s against a // rejection that would have failed identically on attempt 12. Saying 400 -// is both the accurate statement and the one that stops the storm; auto -// mode still reroutes, because the intercept's fallback window is any -// status >= 400. +// is both the accurate statement and the one that stops the storm: the +// person reads one error instead of eleven retries, and nothing reroutes +// the turn elsewhere (waired-agent#1184). // // Matched on the marker regardless of which status the engine picked: // the classification is about why the request failed, not about the @@ -214,16 +214,17 @@ func (h *HandlerSet) handleAnthropicMessagesImpl(w http.ResponseWriter, r *http. ) // #623 context-window guard: reject a prompt that overruns the served - // model's effective window with the exact Anthropic 400 that triggers - // Claude Code's auto-compaction, instead of forwarding it to the engine - // (Ollama would silently truncate the prompt head — the root cause of - // local-model tool spam / instruction drift). Placed before the engine - // is looked up / started so an over-window request never loads a model. - // The staged HeaderLocalError marks this 400 as "surface, don't fall - // back" for the intercept's auto mode (a fallback to the real Anthropic - // API would abandon local serving instead of compacting). The guard is - // active only where Deps.ContextWindowFor is wired (the Claude-intercept - // HandlerSet); a 0 window means "unknown" and fails open. + // model's effective window with the 400 Claude Code compacts on — its + // message is the documented `capability_rejected: prompt_too_long` + // token (contextOverflowToken, waired-agent#1187) — instead of + // forwarding it to the engine (Ollama would silently truncate the + // prompt head — the root cause of local-model tool spam / instruction + // drift). Placed before the engine is looked up / started so an + // over-window request never loads a model. The staged HeaderLocalError + // names the reason for the journal and for a relaying waired node + // (relayPeerContextOverflow). The guard is active only where + // Deps.ContextWindowFor is wired (the Claude-intercept HandlerSet); a 0 + // window means "unknown" and lets the request through. // // The window belongs to whoever ANSWERS. Deps.ContextWindowFor knows // only this device — its manifests, its applied tuning — so on a mesh @@ -793,7 +794,8 @@ func (h *HandlerSet) proxyAnthropicStream(ctx context.Context, client *http.Clie // #757: bound only the PRE-first-byte window. reqCtx governs the peer // request; the watch below cancels it when the leg may not go on // waiting, so postToEngine errors BEFORE the stream commits and the - // intercept's auto fallback reroutes. The watch is disarmed the instant + // client reads a 4xx that names the peer instead of a half-written + // stream (nothing reroutes since waired-agent#1184). The watch is disarmed the instant // postToEngine returns (headers received), so a slow-but-progressing // completion is never cut mid-stream (mid-stream cancellation is #651). reqCtx, cancel := context.WithCancel(ctx) @@ -1770,9 +1772,8 @@ func stageContextOverflow(w http.ResponseWriter, promptTokens, window int) { // which kind of 400 it sent, and re-emitting the canonical envelope is // what makes a mesh leg behave like a local one (waired-agent#436). // -// The staged header also marks it "surface, don't fall back" for the -// intercept's auto mode: falling back to the real Anthropic API here -// would abandon local serving for a turn that only needed compacting. +// The staged header also carries the reason to the journal, the same way +// a local over-window 400 does (stageContextOverflow). func relayPeerContextOverflow(w http.ResponseWriter, resp *http.Response, body []byte, rr *requestRec) bool { if resp.StatusCode != http.StatusBadRequest || resp.Header.Get(HeaderLocalError) != LocalErrorContextOverflow { diff --git a/internal/gateway/anthropic_models.go b/internal/gateway/anthropic_models.go index 5522b2a90..153de4af6 100644 --- a/internal/gateway/anthropic_models.go +++ b/internal/gateway/anthropic_models.go @@ -12,12 +12,14 @@ import ( ) // anthropicModel is the Anthropic Models API object, extended with -// max_input_tokens — the field Claude Code's gateway model discovery -// (CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1) reads to size its -// auto-compaction threshold (#623). We advertise the effective LOCAL -// window (min native / host-sustainable, from Deps.ContextWindowFor) so -// Claude Code compacts before it overruns the model and Ollama truncates -// the prompt head. Omitted (0) when the window is unknown. +// max_input_tokens (#623) — the field Claude Code's gateway model +// discovery reads when that discovery is switched on. waired no longer +// switches it on (the /model rows come from the modelPicker setting, +// waired-agent#1185), so for Claude Code the window travels in +// CLAUDE_CODE_MAX_CONTEXT_TOKENS and the over-window 400 does the +// guarding; the field still advertises the effective LOCAL window (min +// native / host-sustainable, from Deps.ContextWindowFor) to whatever +// client lists models here. Omitted (0) when the window is unknown. type anthropicModel struct { Type string `json:"type"` ID string `json:"id"` diff --git a/internal/gateway/peerwait.go b/internal/gateway/peerwait.go index d026c4ba9..ccf085cbd 100644 --- a/internal/gateway/peerwait.go +++ b/internal/gateway/peerwait.go @@ -201,7 +201,7 @@ func classifyPeerWork(res router.ProbeResult, engineLive, engineKnown bool) peer // a replayed nonce, a missing identity (see router.ProbeOutcome). // That is this device failing to ask, not the peer failing to work, // and calling it "the peer vanished" would name the wrong machine in - // the reroute notice. + // the error the person reads. return peerUnknowable default: return peerSilent diff --git a/internal/gateway/probe.go b/internal/gateway/probe.go index 0d4e38992..21206cda5 100644 --- a/internal/gateway/probe.go +++ b/internal/gateway/probe.go @@ -140,11 +140,12 @@ const ( // Waired had served — locally or on a peer (#755). HeaderLocalModel = "X-Waired-Local-Model" // HeaderLocalError carries a machine-readable local error reason - // ("no_model"). The Claude intercept prefixes it with "local_" for - // the fallback reason (=> local_no_model), reading it off the staged - // (uncommitted) response to emit a distinguishable fallback reason; - // the literal is duplicated in internal/proxy/intercept (stdlib-only - // package) — keep them in sync. + // ("no_model", and the LocalError* values below). Nothing on this + // device turns it into a fallback any more — the intercept's mirror of + // these literals went with the auto route (waired-agent#1184) — so its + // readers are the observability ring and log line (rr.fail stages the + // same vocabulary), a relaying waired node (relayPeerContextOverflow), + // and a support capture of the wire. HeaderLocalError = "X-Waired-Local-Error" // LocalErrorContextOverflow is the HeaderLocalError value the Anthropic // messages handler stages on a #623 context-window 400. It says which @@ -165,47 +166,40 @@ const ( HeaderContextWindow = "X-Waired-Context-Window" // LocalErrorPeerTTFBTimeout is the HeaderLocalError value staged when a // peer inference leg produced no response headers within the class's - // TTFB budget (#757). Unlike LocalErrorContextOverflow it IS a normal - // fallback reason — the abort is pre-commit, so the intercept's auto - // mode reroutes the turn. The literal is duplicated in - // internal/proxy/intercept (stdlib-only package) — keep them in sync. + // TTFB budget (#757). The abort is pre-commit, so the client reads a + // 4xx that names the peer rather than a half-written response; nothing + // reroutes the turn + // (docs/decisions/20260903/0333-no-automatic-crossing-to-or-from-anthropic.md). LocalErrorPeerTTFBTimeout = "peer_ttfb_timeout" // LocalErrorEngineRequestShape is the HeaderLocalError value staged // when the engine refused the shape of the body this gateway built for - // it (waired-agent#1035). Like LocalErrorPeerTTFBTimeout it IS a normal - // fallback reason — nothing was committed and the turn is not the - // client's fault — so auto mode reroutes it and the journal names the - // cause instead of a bare local_status_400. The literal is duplicated - // in internal/proxy/intercept (stdlib-only package) — keep them in - // sync. + // it (waired-agent#1035). Nothing was committed and the turn is not + // the client's fault, so the journal names the cause instead of a bare + // 400. LocalErrorEngineRequestShape = "engine_request_shape" // LocalErrorPeerStoppedServing is the HeaderLocalError value staged // when a watched peer leg ended because the peer itself said it is not // working on anything: its /healthz answered with the engine down, or // with no admission slot in use while this request should have been - // holding one (waired-agent#1040). Like LocalErrorPeerTTFBTimeout it IS - // a normal fallback reason — nothing was committed — so auto mode - // reroutes the turn. It is a DIFFERENT reason from that one on purpose: - // a timeout says only that we stopped waiting, and this says the peer - // told us there was nothing left to wait for. The literal is duplicated - // in internal/proxy/intercept (stdlib-only package) — keep in sync. + // holding one (waired-agent#1040). Nothing was committed, so the + // client reads the 4xx. It is a DIFFERENT reason from + // LocalErrorPeerTTFBTimeout on purpose: a timeout says only that we + // stopped waiting, and this says the peer told us there was nothing + // left to wait for. LocalErrorPeerStoppedServing = "peer_stopped_serving" // LocalErrorPeerUnreachable is its sibling for the other way a watched // peer leg ends: consecutive health checks that did not come back, so // the peer is gone rather than idle (waired-agent#1040). Distinct from // LocalErrorPinnedPeerUnreachable, which is about SELECTION — the // operator's pin could not be probed before the turn was dispatched — - // where this is about a peer that accepted work and then vanished. Also - // duplicated in internal/proxy/intercept. + // where this is about a peer that accepted work and then vanished. LocalErrorPeerUnreachable = "peer_unreachable" // LocalErrorPinnedPeerUnreachable is the HeaderLocalError value staged - // when the operator's pinned peer cannot serve the request. Like - // LocalErrorPeerTTFBTimeout it IS a normal fallback reason — nothing was - // committed — so the intercept's auto mode reroutes the turn to the real - // Anthropic API and names the pin in the reroute notice. On the "waired" - // route there is no fallback and the 503 reaches the client. The literal - // is duplicated in internal/proxy/intercept (stdlib-only package) — keep - // them in sync. + // when the operator's pinned peer cannot serve the request. Nothing + // was committed, and the pin is fail-closed on every surface + // (waired-agent#325): the error names the pinned peer and reaches the + // client, and the turn never leaves for the Anthropic API + // (docs/decisions/20260903/0333-no-automatic-crossing-to-or-from-anthropic.md). LocalErrorPinnedPeerUnreachable = "pinned_peer_unreachable" // LocalErrorPinnedPeerBusy is the pin's OTHER refusal: the computer @@ -223,11 +217,9 @@ const ( LocalErrorPeerStillBusy = "peer_still_busy" // LocalErrorModelNotServed is the HeaderLocalError value staged when // no host serves the requested model and none is fetching it - // (waired-agent#788). Like the two above it IS a normal fallback - // reason — nothing was committed — so auto mode reroutes the turn and - // the journal names the cause instead of a bare local_status_404. On - // the waired route there is no fallback and the 404 reaches the - // client, which is the point: a retryable 503 there was answered by + // (waired-agent#788). Nothing was committed, so the journal names the + // cause instead of a bare 404, and the client reads a 4xx it does not + // retry — which is the point: a retryable 503 there was answered by // the Claude CLI with silent, unbounded backoff. LocalErrorModelNotServed = "model_not_served" // LocalErrorModelTooSmall is the HeaderLocalError value staged when @@ -245,10 +237,9 @@ const ( HeaderMinModelSize = "X-Waired-Min-Model-Size" // LocalErrorInferenceDisabled is the HeaderLocalError value staged // when this host's local inference is off and the mesh had nothing to - // take the request either (waired-agent#829). A normal fallback - // reason like the two above — nothing was committed — so auto mode - // reroutes the turn and the journal names the toggle instead of a - // bare local_status_503. + // take the request either (waired-agent#829). Nothing was committed, + // so the journal names the toggle instead of a bare status, and the + // client is told what to turn on. LocalErrorInferenceDisabled = "inference_disabled" // LocalErrorClientDisconnected is the HeaderLocalError value staged when diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 51a27f3c5..aa6229987 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -188,8 +188,8 @@ type Deps struct { // the request ("" when unclassified) so the per-class node policy // (#647) can resolve main-class traffic to whatever model the // operator-selected node serves. The mapping never touches the - // request body, so the intercept's auto-mode fallback replay still - // carries the client's original model id. + // request body: the response echoes the id the client asked for, and + // the id that answered travels in HeaderLocalModel (#755). ResolveUnknownModel func(requested, class string) (mapped string, ok bool) // ClassifyRequest, when non-nil, derives the coding-agent traffic @@ -261,16 +261,20 @@ type Deps struct { // deadline for a PEER inference leg of the given traffic class // ("main" / "sub", "" when unclassified). If the selected peer returns // no response headers within the budget, the leg is aborted BEFORE the - // response commits, so the intercept's auto-mode fallback (#645/#757) - // reroutes the turn instead of hanging on a stalled-but-reachable peer. - // A 0 return disables the deadline for that class. The deadline is a - // generous infinite-hang backstop, NOT a snappy reroute threshold: - // /healthz readiness does not imply the model is loaded, so a cold - // model load legitimately lands inside this window. Armed only for - // peer legs (remote:*) AND only when the intercept authorizes it with - // the X-Waired-Fallback-Allowed request header (auto mode) — a pinned - // local/waired-only leg is never aborted. Wired only on the - // Claude-intercept HandlerSet; nil on every other listener. + // response commits (#757), so the client reads a 4xx that names the + // peer instead of hanging on a stalled-but-reachable one; nothing + // reroutes the turn elsewhere + // (docs/decisions/20260903/0333-no-automatic-crossing-to-or-from-anthropic.md). + // Where PeerWaitCeiling is set for the class, the budget is a grace + // period and the wait goes on while the peer says it is working. A 0 + // return disables the deadline for that class. The deadline is a + // generous infinite-hang backstop, NOT a snappy threshold: /healthz + // readiness does not imply the model is loaded, so a cold model load + // legitimately lands inside this window. Armed for every peer leg + // (remote:*), the pinned one included — the X-Waired-Fallback-Allowed + // gate that once limited it to the auto route went with that route. + // Wired only on the Claude-intercept HandlerSet; nil on every other + // listener. TTFBBudget func(class string) time.Duration // PeerWaitCeiling, when non-nil, returns how long a PEER leg of the diff --git a/internal/gui/tray/state.go b/internal/gui/tray/state.go index bff42693e..0d9a5624f 100644 --- a/internal/gui/tray/state.go +++ b/internal/gui/tray/state.go @@ -1951,9 +1951,9 @@ func workerSummaryLabel(w management.WorkerResponse) string { // A down pin says what it MEANS, not just that it is down // (waired-agent#325): the pin is fail-closed, so nothing runs on // this computer in its place. "not served here" is the accurate - // phrasing for every surface — general inference fails outright, - // while a Claude turn on the auto route leaves for the Anthropic - // API; neither is served by the pinned worker. + // phrasing for every surface — general inference and a Claude turn + // alike fail outright, and neither is served by the pinned worker + // (nothing leaves for the Anthropic API since waired-agent#1184). // // waired#1064 keeps that phrasing and makes the first half // specific: "loading" or "pull failed" is what an operator can diff --git a/internal/integration/claudemanaged/hook.go b/internal/integration/claudemanaged/hook.go index 93fc9e314..64e0bb298 100644 --- a/internal/integration/claudemanaged/hook.go +++ b/internal/integration/claudemanaged/hook.go @@ -6,21 +6,24 @@ import ( "strings" ) -// The Claude Code Stop hook waired installs alongside ANTHROPIC_BASE_URL (#580). -// It fires after every assistant turn and lets `waired claude _fallback-hook` -// surface a user-visible `systemMessage` when that turn was served by the real -// Anthropic API because local inference errored and auto-mode fell back. This is -// the one built-in Claude Code channel (besides the statusline) that shows text -// *in the TUI*, so it is how waired keeps the fallback honest and non-silent -// (see waired/docs/decisions/, feedback: Claude integration must never break silently). +// The Claude Code hooks waired writes into managed-settings.json. // -// It lives in managed-settings.json — not the user's ~/.claude/settings.json — -// because Stop hooks *array-merge* across every settings scope (managed included), +// Today that is one SessionStart entry, `waired claude _picker write +// --from-managed`, which refreshes the /model rows on every `claude` start +// (waired-agent#1185). The Stop hook that used to sit next to it — `waired +// claude _fallback-hook` (#580), announcing a turn that had fallen back to the +// real Anthropic API — is RETIRED together with the fallback +// (docs/decisions/20260903/0333-no-automatic-crossing-to-or-from-anthropic.md); +// its command forms survive below only so Write and Remove can strip a +// leftover from a build that installed it. +// +// The hooks live in managed-settings.json — not the user's ~/.claude/settings.json — +// because hooks *array-merge* across every settings scope (managed included), // so a managed entry fires without clobbering the user's own hooks, needs no // per-user ownership hop, and is removed surgically by matching our command // substring. On the Unixes the command self-guards on `command -v waired`, so an // uninstalled binary leaves it a silent no-op rather than a "command not found" -// per turn; fallbackHookCommandFor says why Windows cannot carry that guard. +// per start; hookCommandFor / hookRunsOn say why Windows cannot carry that guard. const ( // fallbackHookMarker identifies the RETIRED Stop-hook command inside diff --git a/internal/integration/claudemanaged/managedsettings.go b/internal/integration/claudemanaged/managedsettings.go index b20c48edd..651a0d047 100644 --- a/internal/integration/claudemanaged/managedsettings.go +++ b/internal/integration/claudemanaged/managedsettings.go @@ -3,10 +3,13 @@ // proxy, CA, /etc/hosts edit, or shell-env management (#488). // // It sets env.ANTHROPIC_BASE_URL — pointing at waired's plain-HTTP loopback -// Anthropic listener (127.0.0.1:ClaudeGatewayPort) — plus one non-credential -// flag: env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 (#623, populates the -// /model picker from our /v1/models). It deliberately writes NO credential -// variable. Per the Claude Code docs, a base-URL-only managed setting (no auth +// Anthropic listener (127.0.0.1:ClaudeGatewayPort) — and, when the +// model-route directives are on, env.CLAUDE_CODE_MAX_CONTEXT_TOKENS (below). +// The discovery flag #623 used to co-write here, +// env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1, is retired: the /model +// rows come from the documented modelPicker setting instead +// (waired-agent#1185), and Write only scrubs the flag from earlier installs. +// It deliberately writes NO credential variable. Per the Claude Code docs, a base-URL-only managed setting (no auth // token) does not replace the claude.ai subscription, so subscription // auto-mode (opusplan + the Max usage-threshold Opus->Sonnet fallback) is // preserved. @@ -18,17 +21,20 @@ // frozen at process start, so the static 200000 backstop #623 wrote here // capped genuine 1M Anthropic sessions at 200k while adding nothing below // 200k (the value never went under the model default). #771 therefore stops -// writing it — the gateway's per-request "prompt is too long" 400 -// (internal/gateway/anthropic.go) remains the invariant that protects the -// smaller effective local window on the waired/auto routes, and Claude Code's -// own per-model resolution now governs the anthropic route, tracking /model -// switches mid-session. Write scrubs the legacy value from earlier installs. +// writing it — the gateway's per-request context-overflow 400 +// (internal/gateway/anthropic.go, carrying the documented +// `capability_rejected: prompt_too_long` token since waired-agent#1187) +// remains the invariant that protects the smaller effective local window on +// the Waired rows, and Claude Code's own per-model resolution governs a turn +// on an Anthropic model, tracking /model switches mid-session. Write scrubs +// the legacy value from earlier installs. // // The model-route-directives feature (#52), when opted in, additionally writes // env.CLAUDE_CODE_MAX_CONTEXT_TOKENS. That override is honoured ONLY for model -// ids not starting with "claude-", so it sizes the non-"claude-" directive ids -// ("anthropic-waired-local" and "anthropic-waired-auto") while never touching -// real "claude-*" ids — categorically different from the #771 auto-compact +// ids not starting with "claude-", so it sizes the Waired rows (`waired`, +// `waired/local`, `waired/peer`, … — none starts with "claude-" since +// waired-agent#1185; the older `anthropic-waired-*` spellings are honoured as +// legacy ids) while never touching real "claude-*" ids — categorically different from the #771 auto-compact // backstop that capped 1M Anthropic sessions. On by default (opt-out via // agentconfig); WriteWithOptions gates the actual write. // diff --git a/internal/proxy/intercept/server.go b/internal/proxy/intercept/server.go index 89c5fad4f..edfc6a0aa 100644 --- a/internal/proxy/intercept/server.go +++ b/internal/proxy/intercept/server.go @@ -188,8 +188,8 @@ type Deps struct { // response. Used for visibility (the last-served record surfaced by // the statusline, #602; peer attribution since #601 made the Claude // surface mesh-capable — without it a peer-served response would be - // misreported as local). Never invoked on fallback or on responses - // without the model header. Nil == no-op. + // misreported as local). Never invoked on a passthrough to Anthropic + // or on responses without the model header. Nil == no-op. OnServed func(modelID, peerDeviceID string) // OnRequest, if set, is invoked with the model id a turn CARRIED and the @@ -536,11 +536,11 @@ func (s *Server) dispatchLocal(w http.ResponseWriter, r *http.Request) { // observeLocalModel wraps the client ResponseWriter so a committed local // success reports the gateway's mapped model id to Deps.OnLocalServed -// (#602). Wrapping the OUTER writer covers every local-serving shape with -// one mechanism: dispatchLocal writes to it directly, dispatchAuto's -// fallbackRecorder copies its staged headers onto it on commit, and a -// fallback passthrough writes an upstream response that never carries the -// header (so nothing fires). +// (#602). Wrapping the OUTER writer covers every shape with one +// mechanism: dispatchLocal writes to it directly, and a passthrough to +// Anthropic writes an upstream response that never carries the header (so +// nothing fires). It once also covered the auto route's fallbackRecorder, +// gone with that route (waired-agent#1184). func (s *Server) observeLocalModel(w http.ResponseWriter) http.ResponseWriter { if s.deps.OnServed == nil { return w @@ -568,7 +568,8 @@ func (o *localModelObserver) Write(p []byte) (int, error) { } // Flush keeps the gateway's SSE streaming path working through the wrapper -// (fallbackRecorder and ReverseProxy both type-assert http.Flusher). +// (the gateway's stream writer and ReverseProxy both type-assert +// http.Flusher). func (o *localModelObserver) Flush() { o.observe(http.StatusOK) if f, ok := o.ResponseWriter.(http.Flusher); ok { diff --git a/internal/router/endpoint_router.go b/internal/router/endpoint_router.go index 29cb4b4b1..18bdb494b 100644 --- a/internal/router/endpoint_router.go +++ b/internal/router/endpoint_router.go @@ -609,7 +609,7 @@ var ( // PinnedPeerUnreachableError is what the Selector actually returns for // ErrPinnedPeerUnreachable. It carries the pinned peer's identity so the // gateway can name the peer in telemetry, response headers and the -// user-facing reroute notice without re-deriving it from the routing +// user-facing error without re-deriving it from the routing // preference (which the gateway does not see). // // PeerDisplayID follows the same rule as Selection.PeerDisplayID: the grant