Files
yetone c98160c045 gateway: a group's rule can pause one of its models at set hours, days or agents, leaving it out of the group then, not first, not a fallback, not kept by affinity (John on Discord)
John asked for a pause in a group's Rules, e.g. a model paused in its
vendor's peak hours. A time rule could only send those turns to another
member first: the paused-for model stayed behind it for failover, went
first when the other was unready, and was kept by affinity mid-turn.

What changed:
- provider: Rule.Pause (`"pause": true`, typed `pause=<model>`). It holds
  by time/days and agents only; cleanRules and ParseRule refuse any other
  condition, and refuse a pause with none. Pause rules are not send-to rules:
  MatchesBesidesIntent, ruledEntry and Ruled() leave them out.
  PausedOut(g, ms, agent, at) drops members a pause holds for, from the
  group's own rules and each nested group's (a group named = all its
  models; a model named = wherever a nested group has it, #625).
- gateway: serve and embeddings run PausedOut on ruleClock before planning,
  on every request. Every member paused -> 503 naming each rule
  (pausedError). The trace's GroupRef carries Paused.
- GUI: the rule editor's "send to" is now an app-menu action button (send
  to / pause); a pause hides the request conditions (clearing them),
  keeps hours, days and agents, and isn't saved without one. The group
  card's rule reads "→ pause <model>", and the Routing trace says which
  model was paused by which rule. Strings in zh, zh-TW, ja, de.
- CLI/TUI: `magpie group rule add <g> pause=<model> time=… days=…`.
- docs: gateway-routing.md step 2; start.html en/zh/ja "Hours and days".

Run:
- gofmt -l clean; go vet -tags nogui ./...; GOOS=linux and GOOS=windows
  go vet -tags nogui on gateway, provider, tui, root; go build ./...
- go test -tags nogui ./... under a temp HOME with pinned caches: all ok.
- rules_pause_test.go (3 tests) fails with the PausedOut call in serve
  removed (the paused model answers mid-turn, as a fallback, and the
  all-paused case returns 200). grouprule_pause_test.go covers clean,
  parse/Line, Ruled, Pauses and nested PausedOut.
- GUI: routing-pause.test.cjs in Chromium and WebKit, en/zh at 1100px
  and en/zh/zh-TW/ja/de at 420px: 14/14; all 7 Chromium cases fail on
  the old routing.js. routing-*, group-*, i18n-keys, gui-ja, gui-de,
  click-scroll: 618 pass, 4 fail under --test-concurrency=4
  (routing-blocked, -idle-frame, -side-calls, -wb-refused), all of which
  pass run alone; routing-side-calls failed 1 of 6 alone and passes
  with the old routing.js too, so it is a load flake not touched here.
2026-10-10 13:51:09 +08:00

108 lines
4.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// peak is a vendor's peak hours, Monday to Friday.
var peak = &provider.TimeWindow{From: "09:00", To: "18:00", Days: []string{"mon", "tue", "wed", "thu", "fri"}}
// A rule that sends a turn to another member in a vendor's peak hours
// still leaves the vendor's model behind it: when the member sent to fails,
// the turn falls back onto it (what John on Discord asked to stop). A pause
// rule keeps it out of the group in those hours: not first, not a
// fallback, not where the conversation was, and back once they end.
func TestPauseKeepsAMemberOutInItsHours(t *testing.T) {
now := time.Date(2026, 9, 29, 10, 0, 0, 0, time.Local) // a Tuesday, in the hours
ruleClock = func() time.Time { return now }
t.Cleanup(func() { ruleClock = time.Now })
// "send to b/big" in the hours: a/small still answers when b fails
s, a, b := ruled(t, provider.Rule{Use: "b/big", Time: peak})
b.fail = 500
if code, out := postAs(t, s, "s0", chat("hello", nil, 0, "")); code != 200 || !strings.Contains(out, "from ka") {
t.Fatalf("send-to rule: %d %s", code, out)
}
// pause a/small in the hours
s, a, b = ruled(t, provider.Rule{Use: "a/small", Pause: true, Time: peak})
// before them, a/small answers a conversation first (the group's order)
now = time.Date(2026, 9, 29, 8, 50, 0, 0, time.Local)
if out, r := postOK(t, s, "s1", chat("hello", nil, 0, "")); !strings.Contains(out, "from ka") || len(r.Group.Paused) != 0 {
t.Fatalf("before the hours: %s %+v", out, r.Group)
}
// in them, the same conversation's next tool round goes to b/big: the
// pause moves it, though a/small answered it last
now = time.Date(2026, 9, 29, 9, 1, 0, 0, time.Local)
out, r := postOK(t, s, "s1", chat("hello", nil, 1, ""))
if !strings.Contains(out, "from kb") {
t.Fatalf("in the hours, mid-turn: %s", out)
}
if p := r.Group.Paused; len(p) != 1 || p[0].Member != "a/small" || p[0].Rule != 1 || strings.Join(p[0].When, " · ") != "time 09:00–18:00 Mon–Fri" {
t.Fatalf("trace: %+v", r.Group.Paused)
}
if strings.Join(r.Group.Members, ",") != "b/big" {
t.Fatalf("members: %v", r.Group.Members)
}
// b/big failing in them: a/small is not asked
b.fail = 500
before := a.n()
if code, out := postAs(t, s, "s2", chat("hi", nil, 0, "")); code == 200 || strings.Contains(out, "from ka") {
t.Fatalf("paused member answered as a fallback: %d %s", code, out)
}
if a.n() != before {
t.Fatalf("a/small asked %d times while paused", a.n()-before)
}
// after them, a/small is back
b.fail = 0
now = time.Date(2026, 9, 29, 18, 0, 0, 0, time.Local)
if out, _ := postOK(t, s, "s3", chat("hi", nil, 0, "")); !strings.Contains(out, "from ka") {
t.Fatalf("after the hours: %s", out)
}
// and on a Saturday's peak hours, as they hold on weekdays only
now = time.Date(2026, 10, 3, 10, 0, 0, 0, time.Local)
if out, _ := postOK(t, s, "s4", chat("hi", nil, 0, "")); !strings.Contains(out, "from ka") {
t.Fatalf("saturday: %s", out)
}
}
// Every model of a group paused: the agent is told so, and nobody is asked.
func TestEveryMemberPaused(t *testing.T) {
ruleClock = func() time.Time { return time.Date(2026, 9, 29, 10, 0, 0, 0, time.Local) }
t.Cleanup(func() { ruleClock = time.Now })
s, a, b := ruled(t, provider.Rule{Use: "a/small", Pause: true, Time: peak}, provider.Rule{Use: "b/big", Pause: true, Time: peak})
code, out := postAs(t, s, "s", chat("hello", nil, 0, ""))
if code != 503 || !strings.Contains(out, "paused now") || !strings.Contains(out, "a/small (rule 1: time 09:00–18:00 Mon–Fri)") || !strings.Contains(out, "b/big (rule 2") {
t.Fatalf("%d %s", code, out)
}
if a.n()+b.n() != 0 {
t.Fatalf("asked a %d b %d", a.n(), b.n())
}
// embeddings go by the same rules
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/embeddings", strings.NewReader(`{"model":"group/r","input":"x"}`)))
if rec.Code != 503 || !strings.Contains(rec.Body.String(), "paused now") {
t.Fatalf("embeddings: %d %s", rec.Code, rec.Body.String())
}
}
// A send-to rule naming the paused member doesn't bring it back: the
// group routes as it would were the member not ready.
func TestPauseOutranksASendToRule(t *testing.T) {
ruleClock = func() time.Time { return time.Date(2026, 9, 29, 10, 0, 0, 0, time.Local) }
t.Cleanup(func() { ruleClock = time.Now })
s, a, _ := ruled(t, provider.Rule{Use: "b/big", Pause: true, Time: peak}, provider.Rule{Use: "b/big", Tokens: 1})
out, r := postOK(t, s, "s", chat("hello", nil, 0, ""))
if !strings.Contains(out, "from ka") || r.Rule == nil || r.Rule.N != 2 || !r.Rule.Unready {
t.Fatalf("%s %+v", out, r.Rule)
}
if a.n() != 1 {
t.Fatalf("a asked %d", a.n())
}
}