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