From e7056a6342eff00fba2ef45ef4eac4b33a562658 Mon Sep 17 00:00:00 2001 From: gen16k Date: Sun, 6 Sep 2026 18:22:39 +0900 Subject: [PATCH 1/2] router: the Public Share refusal names which switch declined it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peerOnlyMissNote had three arms and publicGateFor collapsed four causes into one zero value, so a turn refused because the operator's own Public Share posture is off came back as "no public machine is reachable right now" — not vague but false, since the grant acquirer releases every held grant while the posture is off. publicGate now records WHICH switch refused (never consented / switched off / main-agent turns / sub-agent turns), and the settings arms are ordered ahead of the reachability arm. Consent is tested before mode because EffectiveMode already folds "never consented" into "off" before the policy reaches the router. The reason is taken from the gate the attempt actually used rather than re-read from the policy, which is republished under an atomic pointer while a selection runs. The consumer's own Public Share floor is counted separately from the operator's routing floor: two settings, two commands, and folding them would send an operator to a switch they did not set. It deliberately does not feed the SizeFloorError wrapper. The refusal also leads with the entry that declined instead of with the mesh. The person picked one /model row; "no mesh peer is available (...); local state=..." answered with two Waired-internal facts about a machine the turn was never going to run on. Related: the operator's routing floor no longer answers for a public-only turn at all. That floor disqualified this host's engine, which this route was never going to use, so `waired worker set --min-model-size` is the wrong switch to name — narrowing waired-agent#1128's floor-first order by this one case. Separately (waired-agent#1252), ModelIsArriving keyed on this host's local model state even on branches that refuse to run here, so a host midway through a download answered every peer-only, pinned and public refusal with 503 + Retry-After and the reason never reached the client. ModelNotReadyError now states whether local arrival is evidence at all, false by default so a future branch that forgets cannot restore #788 silently. Refs #1201, #1252 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HJUUSmAfbRdjER1seDdjXm Signed-off-by: gen16k --- internal/gateway/anthropic_models.go | 11 +- internal/gateway/anthropic_test.go | 9 +- internal/gateway/openai_test.go | 5 +- internal/gateway/public_share_refusal_test.go | 107 ++++++ internal/gateway/selection_error_test.go | 18 +- .../management/inference_handlers_test.go | 5 +- internal/router/endpoint_router.go | 135 ++++++- internal/router/model_not_ready_test.go | 99 +++++ internal/router/public_candidates.go | 177 +++++++-- internal/router/public_only_test.go | 50 ++- internal/router/public_refusal_note_test.go | 341 ++++++++++++++++++ 11 files changed, 902 insertions(+), 55 deletions(-) create mode 100644 internal/gateway/public_share_refusal_test.go create mode 100644 internal/router/public_refusal_note_test.go diff --git a/internal/gateway/anthropic_models.go b/internal/gateway/anthropic_models.go index 1190bdca7..ff13a843e 100644 --- a/internal/gateway/anthropic_models.go +++ b/internal/gateway/anthropic_models.go @@ -86,9 +86,14 @@ const ( // It does NOT override the consumer's standing Public Share posture // (owner ruling 2026-08-20): with the posture on `auto`, a public // machine still has to beat this host's own best tier, so the entry can - // legitimately decline. It is offered only on a host that has consented - // and enabled Public Share, so the case "offered but the posture forbids - // everything" does not arise. + // legitimately decline. + // + // The picker leaves the row out on a host that has not enabled Public + // Share, but "offered but the posture forbids everything" DOES arise + // and this comment used to deny it (waired-agent#1201): a session keeps + // the id it last picked in its own settings, and the legacy spelling + // below is still routed for exactly that reason. The router words those + // refusals by naming which switch declined. ModelWairedPublic = "waired/public" ) diff --git a/internal/gateway/anthropic_test.go b/internal/gateway/anthropic_test.go index 7f533b9d0..a66109977 100644 --- a/internal/gateway/anthropic_test.go +++ b/internal/gateway/anthropic_test.go @@ -439,7 +439,10 @@ func TestAnthropicMessages_ModelNotReadyMaps503Overloaded(t *testing.T) { // "downloading" is the point of the fixture — a model on its way keeps // the retryable shape (waired-agent#788), so the state has to be on the // error rather than only in the wrapped message. - sel := &fakeSelector{err: &router.ModelNotReadyError{ModelID: "waired/default", State: "downloading"}} + // LocalArrivalAnswers: a LOCAL branch produced this, so the download really + // is what the client would be waiting for (waired-agent#1252). + sel := &fakeSelector{err: &router.ModelNotReadyError{ + ModelID: "waired/default", State: "downloading", LocalArrivalAnswers: true}} gw := anthropicGatewayUnderTest(t, sel, "http://unused") body := `{"model":"waired/default","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}` r := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", bytes.NewBufferString(body)) @@ -772,7 +775,9 @@ func TestAnthropicMessages_MappedModelNotReady503(t *testing.T) { // must win over any blanket no-model shape. sel := &scriptedSelector{errs: []error{ wrap(router.ErrModelNotFound, "alias claude-fable-5[1m] not found"), - &router.ModelNotReadyError{ModelID: "qwen3-8b-instruct", State: "downloading"}, + // LocalArrivalAnswers: a LOCAL branch produced this (waired-agent#1252). + &router.ModelNotReadyError{ + ModelID: "qwen3-8b-instruct", State: "downloading", LocalArrivalAnswers: true}, }} gw := anthropicGatewayWithResolver(t, sel, "http://unused", func(string) (string, bool) { return "qwen3-8b-instruct", true diff --git a/internal/gateway/openai_test.go b/internal/gateway/openai_test.go index 790da0b3e..8066d8790 100644 --- a/internal/gateway/openai_test.go +++ b/internal/gateway/openai_test.go @@ -293,7 +293,10 @@ func TestOpenAIChatCompletions_ModelNotReady503(t *testing.T) { // The fixture says "downloading", so it must carry that state: since // waired-agent#788 the retryable shape is reserved for a model on its // way, and the bare sentinel no longer stands in for one. - sel := &fakeSelector{err: &router.ModelNotReadyError{ModelID: "waired/default", State: "downloading"}} + // LocalArrivalAnswers: a LOCAL branch produced this, so the download really + // is what the client would be waiting for (waired-agent#1252). + sel := &fakeSelector{err: &router.ModelNotReadyError{ + ModelID: "waired/default", State: "downloading", LocalArrivalAnswers: true}} gw := newGatewayUnderTest(t, sel, "http://unused") body := `{"model":"waired/default","messages":[]}` r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(body)) diff --git a/internal/gateway/public_share_refusal_test.go b/internal/gateway/public_share_refusal_test.go new file mode 100644 index 000000000..69976064f --- /dev/null +++ b/internal/gateway/public_share_refusal_test.go @@ -0,0 +1,107 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/waired-ai/waired-agent/internal/catalog" + "github.com/waired-ai/waired-agent/internal/router" + "github.com/waired-ai/waired-agent/internal/runtime" +) + +// The reason the router worded has to survive the trip to the client +// (waired-agent#1201). Nothing pinned that before this file: the router +// tests stop at err.Error(), and the gateway tests that touch +// ErrModelNotReady are about the arriving/not-arriving split. +// +// PIN: product contract. #1201 is a message that never reached the person +// who could act on it, so "the router says the right thing" is not the +// property under test — "the client is shown it" is. + +func newPublicRefusalGateway(t *testing.T, sel SelectorIface) *Server { + t.Helper() + return NewServer(ServerConfig{}, Deps{ + Selector: sel, + Runtimes: runtime.NewRegistry(), + ListManifests: func() []catalog.Manifest { return []catalog.Manifest{qwenManifest()} }, + HTTPClient: http.DefaultClient, + AllowOpenAI: true, + AllowAnthropic: true, + }) +} + +func TestPublicShareRefusal_ReachesTheClientVerbatim(t *testing.T) { + const reason = "this computer is set not to use other people's public machines; " + + "turn it on with `waired public use --auto` or `--explicit`" + // The shape the peer-only branch builds for the public entry: no local + // state, and LocalArrivalAnswers left false because that branch never + // intended to run here (waired-agent#1252). + sel := &fakeSelector{err: &router.ModelNotReadyError{ + Note: reason, Mesh: true, PublicShare: true, + }} + gw := newPublicRefusalGateway(t, sel) + r := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", + strings.NewReader(`{"model":"waired/public","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`)) + r.RemoteAddr = "127.0.0.1:1" + w := httptest.NewRecorder() + gw.Handler().ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String()) + } + // A refusal the operator's own setting caused is not a wait. + if ra := w.Header().Get("Retry-After"); ra != "" { + t.Errorf("Retry-After = %q, want none — nothing about this resolves by waiting", ra) + } + var body struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("body is not valid JSON: %v\n%s", err, w.Body.String()) + } + want := "Waired public share declined this turn: " + reason + "." + failClosedExits + if body.Error.Message != want { + t.Errorf("message drifted\n got: %s\nwant: %s", body.Error.Message, want) + } + if body.Error.Type != "not_found_error" { + t.Errorf("error.type = %q, want not_found_error", body.Error.Type) + } + if got := w.Header().Get(HeaderLocalError); got != LocalErrorModelNotServed { + t.Errorf("%s = %q, want %q", HeaderLocalError, got, LocalErrorModelNotServed) + } +} + +// The client must not be sent away to wait because THIS computer happens +// to be downloading something the turn was never going to use. +// +// PIN: product contract (waired-agent#1252). +func TestPublicShareRefusal_IsNotAWaitWhileThisHostDownloads(t *testing.T) { + sel := &fakeSelector{err: &router.ModelNotReadyError{ + Note: "no public machine is reachable right now", + State: catalog.ModelStateDownloading, + Mesh: true, PublicShare: true, + }} + gw := newPublicRefusalGateway(t, sel) + r := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", + strings.NewReader(`{"model":"waired/public","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`)) + r.RemoteAddr = "127.0.0.1:1" + w := httptest.NewRecorder() + gw.Handler().ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 — a local download is not what this turn is waiting for (body=%s)", + w.Code, w.Body.String()) + } + if ra := w.Header().Get("Retry-After"); ra != "" { + t.Errorf("Retry-After = %q, want none", ra) + } + if !strings.Contains(w.Body.String(), "no public machine is reachable right now") { + t.Errorf("the reason did not reach the body: %s", w.Body.String()) + } +} diff --git a/internal/gateway/selection_error_test.go b/internal/gateway/selection_error_test.go index ace27c783..7b7d30198 100644 --- a/internal/gateway/selection_error_test.go +++ b/internal/gateway/selection_error_test.go @@ -242,7 +242,10 @@ func TestSelectionRecord_MatchesWhatTheClientReceives(t *testing.T) { }, { name: "model not ready, weights on their way", - err: &router.ModelNotReadyError{ModelID: "qwen3.5-9b", State: "downloading"}, + // LocalArrivalAnswers: the local branch, where the download is + // evidence the wait ends (waired-agent#1252). + err: &router.ModelNotReadyError{ + ModelID: "qwen3.5-9b", State: "downloading", LocalArrivalAnswers: true}, want: http.StatusServiceUnavailable, }, { @@ -250,6 +253,19 @@ func TestSelectionRecord_MatchesWhatTheClientReceives(t *testing.T) { err: &router.ModelNotReadyError{ModelID: "qwen3.5-9b", State: "not_present"}, want: http.StatusNotFound, wantAnthropic: http.StatusBadRequest, }, + { + // waired-agent#1252: the SAME arriving state on a branch that + // deliberately never consulted this host. The download is not + // what the client would be waiting for, so the row that says + // 503 above says 404 here — response and record together + // (waired-agent#740). + name: "the public entry declined while this host downloads", + err: &router.ModelNotReadyError{ + State: "downloading", Mesh: true, PublicShare: true, + Note: "this computer is set not to use other people's public machines", + }, + want: http.StatusNotFound, wantAnthropic: http.StatusBadRequest, + }, { // waired-agent#829: local inference off with nothing in the // mesh. 503 on both wires and in the record — the same status diff --git a/internal/management/inference_handlers_test.go b/internal/management/inference_handlers_test.go index de1b1a0ed..d59582b3c 100644 --- a/internal/management/inference_handlers_test.go +++ b/internal/management/inference_handlers_test.go @@ -577,7 +577,10 @@ func TestMapRouterStatus_AgreesWithServingSurfaces(t *testing.T) { }, { name: "model not ready, weights on their way", - err: &router.ModelNotReadyError{ModelID: "qwen3.5-9b", State: "downloading"}, + // LocalArrivalAnswers: the local branch, where the download is + // evidence the wait ends (waired-agent#1252). + err: &router.ModelNotReadyError{ + ModelID: "qwen3.5-9b", State: "downloading", LocalArrivalAnswers: true}, want: 503, gateway: 503, }, { diff --git a/internal/router/endpoint_router.go b/internal/router/endpoint_router.go index 5c9a79549..4caec0133 100644 --- a/internal/router/endpoint_router.go +++ b/internal/router/endpoint_router.go @@ -637,9 +637,40 @@ type ModelNotReadyError struct { // it is still the next fact an operator reads; it stops being the // headline. Mesh bool + // PublicShare marks a miss on the "Waired public share" entry, whose + // refusal leads with its reason instead of with the mesh + // (waired-agent#1201). The person reading it picked one row in + // /model and wants to know which of their own settings declined; + // "no mesh peer is available … local state=…" answered with two + // Waired-internal facts about a machine the turn was never going to + // run on. Note carries the reason, and is empty when the attempt + // learned nothing true to say. + PublicShare bool + // LocalArrivalAnswers states that this host's own weights finishing + // their download WOULD make this turn servable — the only condition + // under which ModelIsArriving may read State as evidence. + // + // False on the branches that deliberately never consult this host's + // engine (peer-only, "Waired public share", pinned). There State is + // carried for the operator reading a journal (waired-agent#828) and + // is not a fact about this turn: a host that happened to be + // downloading a model answered every such refusal with 503 + + // Retry-After, so the message naming the real reason never reached + // the client (waired-agent#1252, the defect class of + // waired-agent#788). + // + // Zero value false, so a branch that does not state the fact never + // buys a retry loop on no evidence. + LocalArrivalAnswers bool } func (e *ModelNotReadyError) Error() string { + if e.PublicShare { + if e.Note == "" { + return "router: Waired public share declined this turn" + } + return "router: Waired public share declined this turn: " + e.Note + } if e.Mesh { if e.ModelID == "" { // The request named no model, so nothing resolved. Name the @@ -684,12 +715,20 @@ func ModelIsArriving(err error) bool { if !errors.As(err, &e) { return false } + // State is this host's local state, which is only evidence about this + // turn on a branch that would have run here (waired-agent#1252). + if !e.LocalArrivalAnswers { + return false + } return arrivingModelStates[e.State] } -// modelNotReady builds the ModelNotReadyError for a selection branch. +// modelNotReady builds the ModelNotReadyError for a LOCAL selection +// branch. Its sentence is a local claim ("model is not in ready state on +// disk"), so every honest use of it is one — which is why the local +// arrival is evidence here (waired-agent#1252). func modelNotReady(modelID, state, note string) error { - return &ModelNotReadyError{ModelID: modelID, State: state, Note: note} + return &ModelNotReadyError{ModelID: modelID, State: state, Note: note, LocalArrivalAnswers: true} } // SizeFloorError wraps whatever a selection branch returned when the @@ -801,18 +840,38 @@ func (s *Selector) localMiss(modelID, state, note string) error { // network could take the request. Same sentinel — every gateway mapping // keys on it — with the sentence written for the branch that produced it // (waired-agent#828). +// +// LocalArrivalAnswers stays false. The branches that reach here (peer-only, +// pinned) refused to use this host's engine by construction, so its weights +// finishing changes nothing about why the turn was refused +// (waired-agent#1252). func meshMiss(modelID, state, note string) error { return &ModelNotReadyError{ModelID: modelID, State: state, Note: note, Mesh: true} } +// publicShareDeclined is the terminal error for the "Waired public share" +// entry. reason is empty when the attempt learned nothing true to say, and +// the sentence then stops after the headline (waired-agent#1201). +func publicShareDeclined(reason string) error { + return &ModelNotReadyError{Note: reason, Mesh: true, PublicShare: true} +} + // meshMissAfterLocal is meshMiss for a branch that would have accepted a // local candidate too (peer-preferred). The toggle wins the naming there // for the reason it does in localMiss: it is what removed the fallback. +// +// Built here rather than delegated to meshMiss, because the one field that +// differs is exactly what separates the two: this branch WOULD have run on +// local weights, so their arrival is evidence that waiting ends +// (waired-agent#1252). func (s *Selector) meshMissAfterLocal(modelID, state, note string) error { if s.in.LocalServingOff { return ErrLocalInferenceOff } - return meshMiss(modelID, state, note) + return &ModelNotReadyError{ + ModelID: modelID, State: state, Note: note, + Mesh: true, LocalArrivalAnswers: true, + } } // Candidate is one option SelectK returns to the caller before any @@ -1040,6 +1099,15 @@ func (s *Selector) SelectK(_ context.Context, req Request, k int) (cands []Candi if err == nil || (!localBelowFloor && short.belowFloor == 0) { return } + // "Waired public share" never intended to run here, so a floor that + // disqualified THIS host's engine is not why the turn was refused, + // and `waired worker set --min-model-size` is not the switch to + // send the operator to. Owner ruling 2026-09-06 narrowing + // waired-agent#1128's floor-first order by this one case + // (docs/decisions/20260906/0410-...). + if s.publicOnly() { + return + } err = &SizeFloorError{ Err: err, Floor: s.in.MinModelSize, @@ -1178,8 +1246,14 @@ func (s *Selector) SelectK(_ context.Context, req Request, k int) (cands []Candi // (the overlay-side Selector, where this mode should never have // been set) fails rather than degrading to local. if s.in.MeshSnapshotFn == nil { - return nil, modelNotReady(manifest.ModelID, - modelStateOf(modelState, present), "routing=peer-only, no mesh snapshot") + // Not modelNotReady: peer-only never consults this host, so its + // local state is not evidence that waiting helps + // (waired-agent#1252). + return nil, &ModelNotReadyError{ + ModelID: manifest.ModelID, + State: modelStateOf(modelState, present), + Note: "routing=peer-only, no mesh snapshot", + } } cands, err := s.tryMeshFallbackK(req, want, meshReasons, k, &short) if err != nil { @@ -1188,8 +1262,13 @@ func (s *Selector) SelectK(_ context.Context, req Request, k int) (cands []Candi if len(cands) > 0 { return cands, nil } + if s.publicOnly() { + // The operator picked one /model row, so the refusal names what + // declined it rather than the mesh (waired-agent#1201). + return nil, publicShareDeclined(publicShareDeclineReason(short)) + } return nil, meshMiss(want.modelID, - modelStateOf(modelState, present), s.peerOnlyMissNote(short)) + modelStateOf(modelState, present), "routing=peer-only") case state.RoutingModePinned: // Pin to a specific peer. tryMeshFallbackK handles the // strict / soft semantics: pin-unreachable returns @@ -1482,12 +1561,19 @@ func (s *Selector) tryMeshFallbackK(req Request, want meshWant, reasons []string // buildMeshCandidates, only if a grant-tagged peer actually appears. gate := s.publicGateFor(req.Class) - raw, belowFloor := s.buildMeshCandidates(snap, req.Class, req.MinContextWindow, wantOllama, wantVLLM, &gate) - if belowFloor > 0 { + raw, drops := s.buildMeshCandidates(snap, req.Class, req.MinContextWindow, wantOllama, wantVLLM, &gate) + if drops.belowOperatorFloor > 0 { reasons = withReason(reasons, fmt.Sprintf( - "%d peer(s) excluded: their model is smaller than %q (routing floor)", belowFloor, s.in.MinModelSize)) + "%d peer(s) excluded: their model is smaller than %q (routing floor)", drops.belowOperatorFloor, s.in.MinModelSize)) if short != nil { - short.belowFloor += belowFloor + short.belowFloor += drops.belowOperatorFloor + } + } + if drops.belowPublicFloor > 0 { + reasons = withReason(reasons, fmt.Sprintf( + "%d public peer(s) excluded: their model is smaller than %q (Public Share floor)", drops.belowPublicFloor, gate.minSize)) + if short != nil { + short.belowPublicFloor += drops.belowPublicFloor } } if len(raw) == 0 { @@ -2039,7 +2125,7 @@ func (s *Selector) buildMeshCandidates( minWindow int, wantOllama, wantVLLM map[string]wantEntry, gate *publicGate, -) (cands []meshCandidate, belowFloor int) { +) (cands []meshCandidate, drops meshDrops) { minSize := s.in.MinModelSize var ( rtts map[string]uint32 @@ -2100,7 +2186,15 @@ func (s *Selector) buildMeshCandidates( // common no-public-peers path never pays for the scan. s.ensureBeat(gate, snap) } - if !gate.admits(tier, s.peerSize(p.InferenceState.Type, p.InferenceState.Models)) { + switch gate.admits(tier, s.peerSize(p.InferenceState.Type, p.InferenceState.Models)) { + case publicAdmitYes: + case publicAdmitBelowMinSize: + // The consumer's own Public Share floor. Counted separately + // from the operator's routing floor so the refusal can name + // the right command (waired-agent#1201). + drops.belowPublicFloor++ + continue + default: continue } displayID, isPublic = pseudonym, true @@ -2152,7 +2246,7 @@ func (s *Selector) buildMeshCandidates( // held to the stricter of this and PublicPolicy.MinModelSize. size := hostfit.VariantSize(v) if minSize != "" && hostfit.SizeRank(size) < hostfit.SizeRank(minSize) { - belowFloor++ + drops.belowOperatorFloor++ continue } c := meshCandidate{ @@ -2196,7 +2290,20 @@ func (s *Selector) buildMeshCandidates( break } } - return out, belowFloor + return out, drops +} + +// meshDrops is what one buildMeshCandidates pass had to throw away for a +// reason a refusal may have to name. Two floors, two settings, two +// commands: belowOperatorFloor is Inputs.MinModelSize +// (`waired worker set --min-model-size`, waired-agent#1128) and +// belowPublicFloor is PublicPolicy.MinModelSize +// (`waired public use --min-model-size`, waired-agent#1201). Keeping them +// apart is the point — folding them would send an operator to the switch +// they did not set. +type meshDrops struct { + belowOperatorFloor int + belowPublicFloor int } // acquireSlot returns (release, true) when the candidate is eligible diff --git a/internal/router/model_not_ready_test.go b/internal/router/model_not_ready_test.go index 0c847fff1..6958994de 100644 --- a/internal/router/model_not_ready_test.go +++ b/internal/router/model_not_ready_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/waired-ai/waired-agent/internal/catalog" + "github.com/waired-ai/waired-agent/internal/runtime/state" ) // Telling "on its way" from "nobody has it" (waired-agent#788). @@ -82,3 +83,101 @@ func TestModelNotReadyError_IsStillTheSentinel(t *testing.T) { t.Errorf("message drifted\n got: %s\nwant: %s", got, want) } } + +// State is THIS host's local state, so it is only evidence about this turn +// on a branch that would have run here (waired-agent#1252). +// +// PIN: product contract. A host that happened to be downloading a model +// answered every peer-only, pinned and "Waired public share" refusal with +// 503 + Retry-After, so the message naming the real reason never reached +// the client — waired-agent#788's defect, reintroduced through a field +// that means something different on the branch that set it. +func TestModelIsArriving_NeedsTheBranchToHaveLookedLocally(t *testing.T) { + s := NewSelector(Inputs{}) + for _, tc := range []struct { + name string + mk func(state string) error + want bool + }{ + { + name: "the local branch", + mk: func(st string) error { return modelNotReady("qwen3.5-9b", st, "") }, + want: true, + }, + { + name: "peer-preferred, which would have taken a local candidate", + mk: func(st string) error { + return s.meshMissAfterLocal("qwen3.5-9b", st, "routing=peer-preferred") + }, + want: true, + }, + { + name: "a mesh branch that refused to run here", + mk: func(st string) error { return meshMiss("qwen3.5-9b", st, "routing=peer-only") }, + want: false, + }, + { + name: "the public entry, which carries no local state at all", + mk: func(string) error { return publicShareDeclined("no public machine is reachable right now") }, + want: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for _, st := range []string{ + catalog.ModelStateQueued, catalog.ModelStateDownloading, catalog.ModelStateVerifying, + } { + if got := ModelIsArriving(tc.mk(st)); got != tc.want { + t.Errorf("ModelIsArriving(state=%q) = %v, want %v", st, got, tc.want) + } + } + }) + } +} + +// The whole design in one assertion: a peer-only turn refused while this +// computer happens to be downloading a model must NOT be reported as a +// wait, and a peer-PREFERRED one still must. +// +// PIN: product contract (waired-agent#1252). The middle row is the +// regression guard — peer-preferred would have run on those weights, so +// their arrival really is what the client is waiting for. +func TestPeerOnlyMiss_DoesNotBorrowALocalDownload(t *testing.T) { + downloading := func() catalog.State { + st := emptyState() + st.Models["qwen3-8b-instruct"] = catalog.ModelState{ + VariantID: "q4-gguf", + OllamaTag: "qwen3:8b-q4_K_M", + State: catalog.ModelStateDownloading, + } + return st + } + for _, tc := range []struct { + name string + mode state.RoutingMode + publicOnly bool + want bool + }{ + {name: "peer-only", mode: state.RoutingModePeerOnly, want: false}, + {name: "peer-preferred", mode: state.RoutingModePeerPreferred, want: true}, + // The public entry carries no local state at all, so this row + // pins the SHAPE rather than the flag; the flag itself is pinned + // per-constructor above. Pinned routing is not driven from here: + // an absent pin answers ErrPinnedPeerUnreachable long before the + // mesh miss, and its meshMiss use is the same constructor row. + {name: "the public entry", mode: state.RoutingModePeerOnly, publicOnly: true, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + s, _, _ := publicSelector(t, allowAll()) + s.in.LocalState = downloading() + s.in.RoutingMode = tc.mode + s.in.PublicOnly = tc.publicOnly + _, err := s.SelectK(t.Context(), Request{Model: "waired/default"}, 5) + if err == nil { + t.Fatal("an empty mesh with no local candidate must refuse") + } + if got := ModelIsArriving(err); got != tc.want { + t.Errorf("ModelIsArriving(%v) = %v, want %v", err, got, tc.want) + } + }) + } +} diff --git a/internal/router/public_candidates.go b/internal/router/public_candidates.go index 5fc1f9c21..576dae163 100644 --- a/internal/router/public_candidates.go +++ b/internal/router/public_candidates.go @@ -114,12 +114,72 @@ const ( NudgeReasonAllOverloaded = "all_overloaded" ) +// publicDenial names WHICH consumer-side switch refused, for the one +// caller that has to say so (waired-agent#1201). It annotates: admit +// still decides, and the zero value claims nothing. +// +// Recorded on the gate rather than re-read from the policy where the +// message is built, because the policy is republished under an atomic +// pointer while a selection runs (publicUseController). A +// `waired public use --off` landing between the attempt and its error +// would otherwise explain the refusal with a posture that did not cause +// it — the same hazard effectivePref avoids by reading the routing +// preference once and handing it down. peerOnlyMissNote's successor also +// has no request class in hand, and the class is what decides which +// toggle to name. +type publicDenial int + +const ( + // publicDenialUnrecorded is the zero value: no cause was recorded, so + // the refusal names no switch. A gate that did not say why must not + // invent somewhere to send the operator. + publicDenialUnrecorded publicDenial = iota + // publicDenialNone means the gate admits. Nothing refused. + publicDenialNone + // publicDenialNotConsented: the first-use security and privacy + // warning has never been accepted on this computer. + publicDenialNotConsented + // publicDenialModeOff: consented once, and then switched off. + publicDenialModeOff + // publicDenialMainOff, publicDenialSubOff and publicDenialBothOff are + // the per-class toggles (PublicPolicy.Main / .Sub). Both-off is the + // answer for an empty class, which classAllowsPublic admits on either. + publicDenialMainOff + publicDenialSubOff + publicDenialBothOff +) + +// publicAdmit is admits' verdict. +// +// A verdict rather than a bool because the size floor is the one +// rejection the refusal has to be able to name: "somebody is lending a +// machine and it is below the smallest model you accept" is a different +// fact from "nobody is lending" (waired-agent#1201). +type publicAdmit int + +const ( + // publicAdmitNo is the zero value and fails closed. + publicAdmitNo publicAdmit = iota + // publicAdmitBelowMinSize: the peer's model is smaller than + // PublicPolicy.MinModelSize. + publicAdmitBelowMinSize + // publicAdmitNotBetter: auto mode, and the peer does not beat this + // host's own best tier. + publicAdmitNotBetter + // publicAdmitYes admits the peer. + publicAdmitYes +) + // publicGate is the resolved public-candidate admission decision for // one Select. The zero value admits nothing. type publicGate struct { // admit is the policy-level verdict: mode is not off and this // request's class is enabled. False short-circuits everything. admit bool + // denial says which switch made admit false, so a refused turn can + // name it (waired-agent#1201). Annotation only — nothing on the + // admission path reads it. + denial publicDenial // auto requires a candidate to strictly beat beat. auto bool // minSize is the resolved size floor — PublicPolicy.MinModelSize, or @@ -146,17 +206,17 @@ type publicGate struct { // an own tier; an empty size ranks below every real class, so any floor // excludes it. Either way the peer only survives explicit mode with no // floor — matching proto/catalog.BestTier's documented contract. -func (g *publicGate) admits(tier int, size string) bool { +func (g *publicGate) admits(tier int, size string) publicAdmit { if !g.admit { - return false + return publicAdmitNo } if g.minSize != "" && hostfit.SizeRank(size) < hostfit.SizeRank(g.minSize) { - return false + return publicAdmitBelowMinSize } if g.auto && tier <= g.beat { - return false + return publicAdmitNotBetter } - return true + return publicAdmitYes } // publicOnly reports whether this selection may use ONLY public machines. @@ -171,33 +231,58 @@ func (g *publicGate) admits(tier int, size string) bool { // set the policy had already allowed public ones into. func (s *Selector) publicOnly() bool { return s.in.PublicOnly } -// peerOnlyMissNote is the routing note on a peer-only miss. +// publicShareDeclineReason is the sentence a "Waired public share" turn is +// refused with: which of the operator's own Public Share settings declined, +// or which fact about the world did. +// +// Empty when the attempt learned nothing that would be true to say. The +// caller then leaves the reason off rather than guessing one. // -// For an ordinary peer-only request there is one way to fail and the note -// names the mode. For "Waired public share" there are two, and telling them -// apart is the difference between "nobody is lending a machine right now" and -// "your own hardware is already the better one" — the second is the posture -// working as configured, and reporting it as unavailability would read as a -// fault (waired-agent#901). +// Order is the most specific thing the operator can act on first, and the +// SETTINGS arms come before the reachability arm. That order is the defect +// this function was rewritten for (waired-agent#1201): with the posture off +// the grant acquirer releases every held grant, so no provider is in the map, +// and the reachability arm reported "nobody is lending" about a refusal the +// operator's own switch had caused. Reachability still comes before the auto +// comparison — with nobody lending, no comparison ran, so "none was better" +// would be equally untrue. // -// short carries the snapshot and the gate from the attempt that found nothing, -// which is what makes the distinction available here at all; without a -// recorded shortfall there is nothing to distinguish and the note stays plain. -func (s *Selector) peerOnlyMissNote(short publicShortfall) string { - if !s.publicOnly() { - return "routing=peer-only" - } - const base = "routing=public-share-only" +// short carries the snapshot, the gate and the floor count from the attempt +// that found nothing, which is what makes any of this available here. +func publicShareDeclineReason(short publicShortfall) string { if !short.hit { - return base + return "" + } + switch short.gate.denial { + case publicDenialNotConsented: + return "Public Share has not been turned on here — its security and privacy warning has not been accepted; accept it with `waired public use --auto`" + case publicDenialModeOff: + return "this computer is set not to use other people's public machines; turn it on with `waired public use --auto` or `--explicit`" + case publicDenialMainOff: + return "Public Share is turned off for main-agent turns; turn it on with `waired public use --main on`" + case publicDenialSubOff: + return "Public Share is turned off for sub-agent turns; turn it on with `waired public use --sub on`" + case publicDenialBothOff: + return "Public Share is turned off for both main-agent and sub-agent turns; turn them on with `waired public use --main on --sub on`" + case publicDenialUnrecorded: + // Nothing wired the policy in. That is not a fact about the + // operator's settings, so name no switch. + return "" + } + // A peer counted here was in the map, so the reachability arm below is + // false by construction; and admits tests the size floor before the tier + // comparison, so a peer dropped for size never reached that one. + if short.belowPublicFloor > 0 { + return "no public machine runs " + ModelSizePhrase(short.gate.minSize) + + ", which is the smallest you accept; change it with `waired public use --min-model-size`" } if !snapshotHasPublicProvider(short.snap) { - return base + ": no public machine is reachable right now" + return "no public machine is reachable right now" } if short.gate.auto { - return base + ": Public Share is set to use another machine only when it beats this one, and none does" + return "Public Share is set to use another machine only when it beats this one, and none does; use one anyway with `waired public use --explicit`" } - return base + ": no public machine can serve this request" + return "no public machine can serve this request" } // publicGateFor resolves the policy and the request class into a gate. @@ -209,19 +294,51 @@ func (s *Selector) publicGateFor(class string) publicGate { return publicGate{} } p := s.in.PublicPolicyFn() + // Consent BEFORE mode. agentconfig.PublicUse.EffectiveMode already + // collapses "never consented" into "off" before the policy reaches the + // router, so testing mode first would leave the unconsented case + // permanently unnameable. + // + // Known imprecision, recorded rather than fixed: a public_use.json the + // daemon could not read also publishes the zero policy, so this arm can + // say "not consented" about an unreadable file. The daemon logs the real + // cause, and the switch to look at is the same one either way. + if !p.Consented { + return publicGate{denial: publicDenialNotConsented} + } if p.Mode == PublicModeOff { - return publicGate{} + return publicGate{denial: publicDenialModeOff} } - if !classAllowsPublic(class, p) { - return publicGate{} + if d := classDenial(class, p); d != publicDenialNone { + return publicGate{denial: d} } return publicGate{ admit: true, + denial: publicDenialNone, auto: p.Mode == PublicModeAuto, minSize: s.resolveMinModelSize(p), } } +// classDenial names which per-class toggle refused, or publicDenialNone when +// the class is allowed. classAllowsPublic keeps the single definition of the +// rule; this only says which switch a refusal should send the operator to. +func classDenial(class string, p PublicPolicy) publicDenial { + if classAllowsPublic(class, p) { + return publicDenialNone + } + switch class { + case state.ClaudeClassMain: + return publicDenialMainOff + case state.ClaudeClassSub: + return publicDenialSubOff + default: + // An empty class is admitted on EITHER toggle, so reaching here + // means both are off. + return publicDenialBothOff + } +} + // resolveMinModelSize is the size floor to enforce, migrating a floor // stored as a tier before #537. // @@ -469,6 +586,12 @@ type publicShortfall struct { // error has to be able to say — and because the alternative is // mutating the Selector, which several requests share. belowFloor int + // belowPublicFloor counts peers dropped by the CONSUMER's Public Share + // floor (PublicPolicy.MinModelSize) — a different setting, changed with + // a different command, and deliberately kept out of belowFloor so it + // never triggers the SizeFloorError wrapper, which names the operator's + // `waired worker set --min-model-size` (waired-agent#1201). + belowPublicFloor int } // record keeps the FIRST shortfall seen. There is at most one mesh diff --git a/internal/router/public_only_test.go b/internal/router/public_only_test.go index 6419cc91d..5304083cf 100644 --- a/internal/router/public_only_test.go +++ b/internal/router/public_only_test.go @@ -63,17 +63,55 @@ func TestPublicOnly_OffLeavesTheOrderAlone(t *testing.T) { // nothing — selecting the entry cannot reach a machine the operator never // consented to. func TestPublicOnly_CannotWidenPastThePosture(t *testing.T) { + // INVERTED by waired-agent#1201. This asserted only `err != nil`, and + // that is how the wrong sentence shipped: every way of admitting + // nothing produced the same error, and the message reported the + // operator's own switch as "no public machine is reachable right now". + // Each cause now has to name its own switch. t.Run("posture off admits nothing at all", func(t *testing.T) { - s, _, _ := publicSelector(t, PublicPolicy{Mode: PublicModeOff}, - mkPublicPeer(publicPeerDeviceID, publicPeerAlias, "qwen3:8b-q4_K_M")) - s.in.PublicOnly = true - s.in.RoutingMode = state.RoutingModePeerOnly + for _, tc := range []struct { + name string + policy PublicPolicy + want string + }{ + { + name: "never consented", + policy: PublicPolicy{Mode: PublicModeExplicit, Main: true, Sub: true}, + want: "warning has not been accepted", + }, + { + name: "consented and switched off", + policy: PublicPolicy{Mode: PublicModeOff, Consented: true, Main: true, Sub: true}, + want: "set not to use other people's public machines", + }, + { + name: "every traffic class switched off", + policy: PublicPolicy{Mode: PublicModeExplicit, Consented: true}, + want: "both main-agent and sub-agent turns", + }, + } { + t.Run(tc.name, func(t *testing.T) { + s, _, _ := publicSelector(t, tc.policy, + mkPublicPeer(publicPeerDeviceID, publicPeerAlias, "qwen3:8b-q4_K_M")) + s.in.PublicOnly = true + s.in.RoutingMode = state.RoutingModePeerOnly - if _, err := s.SelectK(t.Context(), Request{Model: "waired/default"}, 5); err == nil { - t.Fatal("public-only with the posture off must not select a public machine") + _, err := s.SelectK(t.Context(), Request{Model: "waired/default"}, 5) + if err == nil { + t.Fatal("public-only with the posture off must not select a public machine") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want it to name the switch (%q)", err, tc.want) + } + if strings.Contains(err.Error(), "reachable") { + t.Errorf("err = %v, must not report the operator's own switch as unreachability", err) + } + }) } }) + // "Two" was the count before waired-agent#1201 added the arms that + // name a switch; these two remain the world-state ones. t.Run("the two ways of finding nothing are told apart", func(t *testing.T) { // Nobody is lending a machine. none, _, _ := publicSelector(t, allowAll(), diff --git a/internal/router/public_refusal_note_test.go b/internal/router/public_refusal_note_test.go new file mode 100644 index 000000000..bcba5c186 --- /dev/null +++ b/internal/router/public_refusal_note_test.go @@ -0,0 +1,341 @@ +package router + +import ( + "strings" + "testing" + + "github.com/waired-ai/waired-agent/internal/inferencemesh" + "github.com/waired-ai/waired-agent/internal/runtime/state" + "github.com/waired-ai/waired-agent/proto/hostfit" +) + +// A refused "Waired public share" turn says WHICH switch declined it +// (waired-agent#1201). +// +// PIN: product contract. Before this, publicGateFor collapsed four causes +// into one zero value, and the refusal reported the operator's own posture +// as "no public machine is reachable right now" — a statement that is not +// merely vague but false, since the grant acquirer releases every held +// grant while the posture is off. + +func TestPublicGateFor_NamesTheSwitchThatRefused(t *testing.T) { + consented := func(m PublicMode, main, sub bool) PublicPolicy { + return PublicPolicy{Mode: m, Consented: true, Main: main, Sub: sub} + } + for _, tc := range []struct { + name string + policy PublicPolicy + unwired bool + class string + wantAdmit bool + wantDenial publicDenial + }{ + { + name: "nothing wired the policy in", unwired: true, + wantAdmit: false, wantDenial: publicDenialUnrecorded, + }, + { + // Consent is tested BEFORE mode. EffectiveMode already folds + // "never consented" into "off" before the policy reaches the + // router, so a mode-first order would leave this unnameable. + name: "never consented, even with a mode set", + policy: PublicPolicy{Mode: PublicModeAuto, Main: true, Sub: true}, + class: state.ClaudeClassMain, + wantAdmit: false, wantDenial: publicDenialNotConsented, + }, + { + name: "consented and then switched off", + policy: consented(PublicModeOff, true, true), class: state.ClaudeClassMain, + wantAdmit: false, wantDenial: publicDenialModeOff, + }, + { + name: "main-agent turns are switched off", + policy: consented(PublicModeExplicit, false, true), class: state.ClaudeClassMain, + wantAdmit: false, wantDenial: publicDenialMainOff, + }, + { + name: "sub-agent turns are switched off", + policy: consented(PublicModeExplicit, true, false), class: state.ClaudeClassSub, + wantAdmit: false, wantDenial: publicDenialSubOff, + }, + { + // An empty class is admitted on EITHER toggle, so reaching a + // denial means both are off. + name: "both toggles off, general inference", + policy: consented(PublicModeExplicit, false, false), class: "", + wantAdmit: false, wantDenial: publicDenialBothOff, + }, + { + name: "admitted", + policy: allowAll(), class: state.ClaudeClassMain, + wantAdmit: true, wantDenial: publicDenialNone, + }, + } { + t.Run(tc.name, func(t *testing.T) { + s, _, _ := publicSelector(t, tc.policy) + if tc.unwired { + s.in.PublicPolicyFn = nil + } + got := s.publicGateFor(tc.class) + if got.admit != tc.wantAdmit { + t.Errorf("admit = %v, want %v", got.admit, tc.wantAdmit) + } + if got.denial != tc.wantDenial { + t.Errorf("denial = %d, want %d", got.denial, tc.wantDenial) + } + }) + } +} + +// Every reason has its own sentence, and the settings arms come first. +// +// PIN: product contract for the arms that name a switch (waired-agent#1201). +// The three world-state arms at the bottom are a record of today's wording; +// two of them had no test at all before this. +func TestPublicShareDeclineReason_EveryReasonHasItsOwnSentence(t *testing.T) { + // A map that carries a provider grant, so the "nobody is lending" arm + // is false and the arms below it are reachable. + snapshotWithPublicProvider := func() inferencemesh.Snapshot { + return inferencemesh.Snapshot{Peers: []inferencemesh.PeerView{ + mkPublicPeer(publicPeerDeviceID, publicPeerAlias, "qwen3:8b-q4_K_M"), + }} + } + lending := publicShortfall{ + hit: true, + snap: snapshotWithPublicProvider(), + gate: publicGate{admit: true, denial: publicDenialNone}, + } + withGate := func(g publicGate) publicShortfall { + s := lending + s.gate = g + return s + } + for _, tc := range []struct { + name string + short publicShortfall + want string + }{ + { + name: "nothing was recorded", + short: publicShortfall{}, + want: "", + }, + { + name: "the policy was never wired in", + short: withGate(publicGate{denial: publicDenialUnrecorded}), + want: "", + }, + { + name: "never consented", + short: withGate(publicGate{denial: publicDenialNotConsented}), + want: "security and privacy warning has not been accepted", + }, + { + name: "switched off", + short: withGate(publicGate{denial: publicDenialModeOff}), + want: "set not to use other people's public machines", + }, + { + name: "main-agent turns off", + short: withGate(publicGate{denial: publicDenialMainOff}), + want: "turned off for main-agent turns", + }, + { + name: "sub-agent turns off", + short: withGate(publicGate{denial: publicDenialSubOff}), + want: "turned off for sub-agent turns", + }, + { + name: "both classes off", + short: withGate(publicGate{denial: publicDenialBothOff}), + want: "both main-agent and sub-agent turns", + }, + { + name: "everything lent is below the Public Share floor", + short: func() publicShortfall { + s := withGate(publicGate{admit: true, denial: publicDenialNone, minSize: hostfit.ModelSizeLarge}) + s.belowPublicFloor = 1 + return s + }(), + want: "no public machine runs a large model, which is the smallest you accept", + }, + { + name: "nobody is lending", + short: publicShortfall{ + hit: true, + gate: publicGate{admit: true, denial: publicDenialNone}, + }, + want: "no public machine is reachable right now", + }, + { + name: "lending, but auto says none is better", + short: withGate(publicGate{admit: true, denial: publicDenialNone, auto: true}), + want: "only when it beats this one", + }, + { + name: "lending, and none of them fits", + short: lending, + want: "no public machine can serve this request", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := publicShareDeclineReason(tc.short) + if tc.want == "" { + if got != "" { + t.Fatalf("reason = %q, want none — an attempt that learned nothing must claim nothing", got) + } + return + } + if !strings.Contains(got, tc.want) { + t.Fatalf("reason = %q, want it to contain %q", got, tc.want) + } + }) + } + + // The property the arms exist for: told apart, not merely worded. + t.Run("no two causes produce the same sentence", func(t *testing.T) { + seen := map[string]string{} + for _, d := range []publicDenial{ + publicDenialNotConsented, publicDenialModeOff, + publicDenialMainOff, publicDenialSubOff, publicDenialBothOff, + } { + got := publicShareDeclineReason(withGate(publicGate{denial: d})) + if prev, dup := seen[got]; dup { + t.Errorf("denial %d and %s produce the same sentence %q", d, prev, got) + } + seen[got] = "another" + } + }) +} + +// The settings arms must outrank the reachability arm. This is the defect +// itself: with the posture off the acquirer has released every grant, so +// the map holds no provider and the old order reported the operator's own +// switch as somebody else's outage. +// +// PIN: product contract (waired-agent#1201). +func TestPublicShareRefusal_DoesNotBlameTheWorldForTheOperatorsSwitch(t *testing.T) { + for _, tc := range []struct { + name string + policy PublicPolicy + class string + want string + }{ + { + name: "never consented", + policy: PublicPolicy{Mode: PublicModeExplicit, Main: true, Sub: true}, + want: "security and privacy warning has not been accepted", + }, + { + name: "consented and switched off", + policy: PublicPolicy{Mode: PublicModeOff, Consented: true, Main: true, Sub: true}, + want: "set not to use other people's public machines", + }, + { + name: "sub-agent turns switched off", + policy: PublicPolicy{Mode: PublicModeExplicit, Consented: true, Main: true}, + class: state.ClaudeClassSub, + want: "turned off for sub-agent turns", + }, + } { + // Two maps, because the posture and the map move at different + // speeds. "released" is what a host with the posture off actually + // looks like — the grant acquirer drops every held grant while the + // posture is off — and it is the shape that produced the false + // sentence this test exists for. "still held" is the propagation + // window just after the switch. + for _, world := range []struct { + name string + peers []inferencemesh.PeerView + }{ + {name: "grants released", peers: []inferencemesh.PeerView{ + mkPeer("dev_own00000001", "qwen3:8b-q4_K_M", true, false)}}, + {name: "a grant still held", peers: []inferencemesh.PeerView{ + mkPublicPeer(publicPeerDeviceID, publicPeerAlias, "qwen3:8b-q4_K_M")}}, + } { + t.Run(tc.name+", "+world.name, func(t *testing.T) { + s, _, _ := publicSelector(t, tc.policy, world.peers...) + s.in.PublicOnly = true + s.in.RoutingMode = state.RoutingModePeerOnly + + _, err := s.SelectK(t.Context(), Request{Model: "waired/default", Class: tc.class}, 5) + if err == nil { + t.Fatal("a posture that admits nothing must refuse") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want it to contain %q", err, tc.want) + } + if strings.Contains(err.Error(), "reachable") { + t.Errorf("err = %v, must not report the operator's own switch as unreachability", err) + } + // The row the operator picked leads the sentence, not the mesh. + if !strings.HasPrefix(err.Error(), "router: Waired public share declined this turn") { + t.Errorf("err = %v, want it to lead with the entry that declined", err) + } + if strings.Contains(err.Error(), "local state=") { + t.Errorf("err = %v, must not report this host's state on a turn that never intended to run here", err) + } + }) + } + } +} + +// The consumer's Public Share floor is its own shortfall, with its own +// command — and must NOT reach the SizeFloorError wrapper, which names the +// operator's `waired worker set --min-model-size`. +// +// PIN: product contract (waired-agent#1201; the second half cites +// waired-agent#1128's ruling that the floor names the switch that was set). +func TestPublicMinModelSize_IsCountedAsItsOwnShortfall(t *testing.T) { + policy := allowAll() + policy.MinModelSize = hostfit.ModelSizeLarge + // 4.7 GB of weights prices as "small", so the large floor excludes it. + s, _, _ := publicSelectorWith(t, policy, qwenSized(50, 4.7), + mkPublicPeer(publicPeerDeviceID, publicPeerAlias, "qwen3:8b-q4_K_M")) + s.in.PublicOnly = true + s.in.RoutingMode = state.RoutingModePeerOnly + + _, err := s.SelectK(t.Context(), Request{Model: "waired/default"}, 5) + if err == nil { + t.Fatal("a floor above every lent machine must refuse") + } + if !strings.Contains(err.Error(), "which is the smallest you accept") { + t.Errorf("err = %v, want it to name the Public Share floor", err) + } + if !strings.Contains(err.Error(), "waired public use --min-model-size") { + t.Errorf("err = %v, want it to name the command that changes THIS floor", err) + } + if BelowModelSizeFloor(err) { + t.Errorf("err = %v, must not be reported as the operator's routing floor", err) + } +} + +// The operator's routing floor does not get to answer for a turn that was +// never going to run on this computer's engine. +// +// PIN: product contract — owner ruling 2026-09-06, narrowing +// waired-agent#1128's floor-first order by this one case. See +// docs/decisions/20260906/0410-the-public-entry-answers-for-its-own-refusal.md. +func TestPublicShareRefusal_OutranksTheOperatorFloor(t *testing.T) { + // The operator's floor, not the consumer's: it drops the lent machine + // and would otherwise wrap the miss as "no computer runs a large model". + s, _, _ := publicSelectorWith(t, allowAll(), qwenSized(50, 4.7), + mkPublicPeer(publicPeerDeviceID, publicPeerAlias, "qwen3:8b-q4_K_M")) + s.in.MinModelSize = hostfit.ModelSizeLarge + s.in.PublicOnly = true + s.in.RoutingMode = state.RoutingModePeerOnly + + _, err := s.SelectK(t.Context(), Request{Model: "waired/default"}, 5) + if err == nil { + t.Fatal("nothing was admitted, so the turn must be refused") + } + if BelowModelSizeFloor(err) { + t.Errorf("err = %v, want the public entry to answer for its own refusal", err) + } + if strings.Contains(err.Error(), "waired worker set --min-model-size") { + t.Errorf("err = %v, must not send the operator to a switch this turn never used", err) + } + if !strings.HasPrefix(err.Error(), "router: Waired public share declined this turn") { + t.Errorf("err = %v, want the public headline", err) + } +} From 25d28c78363087d67c8d5ce20a1a0e9fa1028586 Mon Sep 17 00:00:00 2001 From: gen16k Date: Sun, 6 Sep 2026 18:25:34 +0900 Subject: [PATCH 2/2] docs: the public entry says which of your settings declined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page promised the entry names "which of the two reasons it was", and the two it named were the two that are not about the operator's own settings. It also implied the posture-off case could not arise, because the picker leaves the row out — but a session keeps the entry it last picked, and the pre-#1185 spelling is still routed. troubleshooting gains two rows in the table that is already keyed on how the message starts: one for the settings that decline, one for the two world-state reasons that are not a fault. Refs #1201 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HJUUSmAfbRdjER1seDdjXm Signed-off-by: gen16k --- .../src/content/docs/guides/claude-code.mdx | 18 +++-- .../content/docs/ja/guides/claude-code.mdx | 17 +++-- .../src/content/docs/ja/troubleshooting.md | 2 + docs-site/src/content/docs/troubleshooting.md | 2 + ...ublic-entry-answers-for-its-own-refusal.md | 76 +++++++++++++++++++ 5 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 docs/decisions/20260906/0410-the-public-entry-answers-for-its-own-refusal.md diff --git a/docs-site/src/content/docs/guides/claude-code.mdx b/docs-site/src/content/docs/guides/claude-code.mdx index 214178be1..30d8dbfe6 100644 --- a/docs-site/src/content/docs/guides/claude-code.mdx +++ b/docs-site/src/content/docs/guides/claude-code.mdx @@ -209,12 +209,18 @@ here to run. computer](/guides/public-share/) — a machine another Waired user is lending out, rather than one of yours. It appears only once you have turned Public Share on and accepted its warning; until then it is simply not in the list. - -It does not change the setting you made there. If you set Public Share to use -another person's machine only when it is better than your own, that still -applies, so this entry can decline — and when it does it says which of the two -reasons it was: no public machine was reachable, or none was better than what -you already have. +It can still be *chosen* on a computer that does not offer it: a session keeps +whichever entry you last picked, and an old one may still hold the name this +entry used to have. + +It does not change the settings you made there — it uses them, so this entry +can decline. When it does, it says which of your settings declined and the +command that changes that one: Public Share is off here, it is off for +main-agent or sub-agent turns, or every machine on offer runs a model smaller +than the smallest you accept. Two of the reasons are not about your settings +at all: nobody is lending a machine right now, or — if you set Public Share to +use another person's machine only when it is better than your own — none of +them is. **Your other computers are listed by name too.** Below the entries above, `/model` carries a row for each computer currently able to answer, named after diff --git a/docs-site/src/content/docs/ja/guides/claude-code.mdx b/docs-site/src/content/docs/ja/guides/claude-code.mdx index 80b4be5d1..b5077cfdd 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.mdx +++ b/docs-site/src/content/docs/ja/guides/claude-code.mdx @@ -198,12 +198,17 @@ Claude Code を接続したあとに表示され、一度 Claude Code を再起 **Waired public share** は同じ考えを[他人のコンピュータ](/ja/guides/public-share/)に 広げたものです。自分のマシンではなく、ほかの Waired ユーザーが貸し出しているマシンを 使います。この項目は Public Share を有効にして警告に同意したあとにだけ出ます。 -それまでは一覧に現れません。 - -そこで設定した内容を上書きすることはありません。「自分のマシンより良いときだけ他人の -マシンを使う」設定にしているなら、それはそのまま効くので、この項目を選んでも見送られる -ことがあります。見送られたときは、2 つの理由のどちらだったかを伝えます。届く -パブリックマシンが 1 台も無かったのか、いま持っているものより良いマシンが無かったのか、です。 +それまでは一覧に現れません。ただし、この項目を出していないコンピュータでも**選ばれる +ことはあります**。セッションは最後に選んだ項目を持ち続けますし、古いセッションは +この項目の以前の名前をまだ持っていることがあります。 + +そこで設定した内容を上書きすることはありません。むしろその設定を使うので、この項目を +選んでも見送られることがあります。見送られたときは、**自分のどの設定が見送ったのか**と、 +それを変えるコマンドを伝えます。このコンピュータで Public Share を切っている、 +メイン会話またはサブエージェントのターンについて切っている、貸し出されているマシンが +どれも受け入れる最小のモデルより小さい、のいずれかです。自分の設定とは関係のない理由も +2 つあります。いま貸してくれているマシンが 1 台も無い場合と、「自分のマシンより良いときだけ +他人のマシンを使う」設定にしていて、良いマシンが 1 台も無い場合です。 **ほかのコンピュータは名前でも並びます。** 上の項目の下に、いま応答できる コンピュータごとに 1 行ずつ、コンピュータの名前を行名に、動かしているモデルを diff --git a/docs-site/src/content/docs/ja/troubleshooting.md b/docs-site/src/content/docs/ja/troubleshooting.md index cd68cdc14..3cdd2718f 100644 --- a/docs-site/src/content/docs/ja/troubleshooting.md +++ b/docs-site/src/content/docs/ja/troubleshooting.md @@ -776,6 +776,8 @@ Claude Code の中で `API Error: 400` と、何が答えられなかったか | `The computer this turn is pinned to, <名前>, is not answering.` | `waired worker` で固定したそのパソコンが、電源オフ・スリープ・共有オフのいずれか。 | → [パソコンを固定したらリクエストが通らなくなった](#requests-stopped-working-after-i-pinned-a-computer) | | `The peer <名前> stopped answering after <時間>.` / `The peer <名前> stopped working on this request after <時間>.` | 前者は、答えていたパソコンが応答しなくなった。後者は、止まったと報告したか、エンジンは動いているのに答えなくなった — 固まっているだけで、パソコンが消えたわけではない。 | `waired peers list` と、そのパソコンでの `waired doctor` で確認する。 | | ``No computer on Waired runs a medium model or larger. Change the floor with `waired worker set --min-model-size`.`` | 自分で設定したルーティングの下限が、このパソコンを含む全パソコンを除外した。 | 下限を下げるか外す → [`--min-model-size`](/ja/reference/cli/#setting-a-minimum-model-size) | +| `Waired public share declined this turn:` に続いて `this computer is set not to use other people's public machines` / `Public Share has not been turned on here` / `Public Share is turned off for … turns` / `no public machine runs …, which is the smallest you accept`。 | 自分の Public Share の設定が見送った。この項目は、もう出していないパソコンでも選ばれることがある。 | 変えるコマンドはメッセージが名指しする。`waired public status` でこれらの設定をまとめて確認し、`waired public use` で変更する。 | +| `Waired public share declined this turn:` に続いて `no public machine is reachable right now` / `Public Share is set to use another machine only when it beats this one, and none does`。 | いま使えるマシンを貸してくれている人がいないか、どれも自分のマシンより良くない。どちらも故障ではない。 | 待つか、`/model` で別の項目を選ぶ。後者を効かなくするには `waired public use --explicit`。 | たいていはフッターが先に伝えます。赤い `⚠ waired: Waired cannot answer (local disabled, no peer)` は、自分のどのパソコンも次のターンを受けられないと Waired が diff --git a/docs-site/src/content/docs/troubleshooting.md b/docs-site/src/content/docs/troubleshooting.md index b4db9c8a3..c5fd221a6 100644 --- a/docs-site/src/content/docs/troubleshooting.md +++ b/docs-site/src/content/docs/troubleshooting.md @@ -766,6 +766,8 @@ and send again. The start of the message says which fix applies: | `The computer this turn is pinned to, , is not answering.` | You pinned that computer with `waired worker` and it is off, asleep or not sharing. | See [Requests stopped working after I pinned a computer](#requests-stopped-working-after-i-pinned-a-computer). | | `The peer stopped answering after