diff --git a/cmd/mavend/sttseam_test.go b/cmd/mavend/sttseam_test.go new file mode 100644 index 0000000..007e3cb --- /dev/null +++ b/cmd/mavend/sttseam_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + + "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/stt" +) + +// A box with no workstation.stt block transcribes exactly as it did before the +// seam existed: the floor is handed back untouched, and nothing probes. +func TestSttSeamWithNoBlockIsTheFloor(t *testing.T) { + floor := stt.NewStub() + got, pair := sttSeam(&config.Config{}, floor) + if pair != nil { + t.Fatal("no block must build no pair") + } + if got != stt.Transcriber(floor) { + t.Fatal("no block must hand back the floor itself") + } +} + +func TestSttSeamPrefersTheWorkstation(t *testing.T) { + cfg := &config.Config{Workstation: &config.WorkstationConfig{ + URL: "http://127.0.0.1:1", + Stt: &config.WorkstationSttConfig{ + URL: "http://127.0.0.1:2/transcribe", + Health: "http://127.0.0.1:2/health", + }, + }} + got, pair := sttSeam(cfg, stt.NewStub()) + if pair == nil { + t.Fatal("a configured block must build a pair") + } + defer pair.Stop() + if got != stt.Transcriber(pair) { + t.Fatal("the pair is what callers must transcribe through") + } + // Nothing answers on port 2, so the seam is the floor until it does. + if pair.Available() { + t.Fatal("an unreachable workstation must not be available") + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 9e308e9..65acddf 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -53,7 +53,11 @@ type voiceWiring struct { // unless a `workstation` block names an address. Held here only so the // prober is stopped on shutdown; callers were handed it at build time. pair *llm.Pair - mcp *mcpWiring + // sttPair — CrisperWhisper 2.0 on the workstation with mavsttd as the + // floor, nil unless the `workstation.stt` block names an address. Held for + // the same reason as pair: to stop its prober on shutdown. + sttPair *stt.Pair + mcp *mcpWiring // home — the Home Assistant client, nil unless the `smarthome` block is // enabled (Vikunja #256). Its devices land in the same allowlist as every // other act, so nothing else here has to know about it. @@ -84,6 +88,9 @@ func (w *voiceWiring) close() { if w.pair != nil { w.pair.Stop() } + if w.sttPair != nil { + w.sttPair.Stop() + } w.mcp.close() } @@ -112,6 +119,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem } else { transcriber = stt.NewStub() } + transcriber, w.sttPair = sttSeam(cfg, transcriber) w.transcriber = transcriber // ----- tts (Stub in-process OR Remote) ----- @@ -366,6 +374,44 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm return pair, pair } +// sttSeam builds the transcription seam the voice path and the meeting +// recorder share. It is modelSeam for audio and follows the same rule. +// +// With no `workstation.stt` block it hands back the floor untouched, which is +// today's deploy exactly. With one, it is an stt.Pair preferring CrisperWhisper +// 2.0 on workpc, which scores 10.4% WER in Russian against the floor's 27.5% +// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md). +// +// Only the silent half of the degradation rule applies here. A worse transcript +// is still a turn, so there is nothing to name a gap about and the fallback is +// never spoken. That is why stt.Pair has no TranscribeRemote. +func sttSeam(cfg *config.Config, floor stt.Transcriber) (stt.Transcriber, *stt.Pair) { + if cfg.Workstation == nil || cfg.Workstation.Stt == nil { + return floor, nil + } + s := cfg.Workstation.Stt + lang := "" + if cfg.Voice != nil { + lang = cfg.Voice.Lang + if cfg.Voice.Stt != nil && cfg.Voice.Stt.Lang != "" { + lang = cfg.Voice.Stt.Lang + } + } + pair := stt.NewPair( + stt.NewHTTPTranscriber(s.URL, s.Token, lang, time.Duration(s.Timeout)), + floor, + s.Health, + time.Duration(s.Probe), + ) + pair.Start(context.Background()) + if s.Token == "" { + log.Print("voice: the workstation transcriber has no token, so anything on the LAN can post audio to it") + } + log.Printf("voice: workstation transcriber at %s, probed every %s, mavsttd as the floor", + s.URL, time.Duration(s.Probe)) + return pair, pair +} + func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter { if !enabled { return nil diff --git a/internal/config/workstation.go b/internal/config/workstation.go index 7a79813..6fab688 100644 --- a/internal/config/workstation.go +++ b/internal/config/workstation.go @@ -1,6 +1,7 @@ package config import ( + "net/url" "strings" "time" ) @@ -35,12 +36,54 @@ type WorkstationConfig struct { // 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than // the resident one, and a request that overruns falls back to the floor. Timeout Duration `json:"timeout,omitempty"` + + // Stt — CrisperWhisper 2.0 on the same machine, a separate service on its + // own port. Absent ⇒ every utterance goes to mavsttd, which is today. + Stt *WorkstationSttConfig `json:"stt,omitempty"` +} + +// WorkstationSttConfig — speech-to-text on the workstation. +// +// It is a second service and not a second endpoint on mavgpud: whisper.cpp +// cannot load CrisperWhisper 2.0 at all, because it derives its language count +// from the vocabulary size and CW2's 51897 tokens shift seven special token +// ids. So CW2 runs under transformers, and this block addresses it. +// +// Worth the trouble: CW2 turbo scores 10.4% WER in Russian against 27.5% for +// the ggml-small.bin homesrv loads +// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md). +type WorkstationSttConfig struct { + // URL — the transcribe endpoint, e.g. + // "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised + // to nil and mavsttd takes every turn. + URL string `json:"url,omitempty"` + + // Health — the admission endpoint. Empty ⇒ the URL's origin + "/health". + // It answers 503 while the card is held, and that is the signal. + Health string `json:"health,omitempty"` + + // Token — the bearer token the service checks. Audio is the most sensitive + // thing that crosses this seam, so a LAN deployment should set one. Write + // it as ${MAVEN_STT_TOKEN} and keep the value in deploy/telegram.env, the + // way every other secret in this file is written. + Token string `json:"token,omitempty"` + + // Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe. + Probe Duration `json:"probe,omitempty"` + + // Timeout — the per-request budget for one utterance. 0 ⇒ + // DefaultWorkstationSttTimeout. A request that overruns falls back to + // mavsttd, which costs a worse transcript and not the turn. + Timeout Duration `json:"timeout,omitempty"` } // Workstation defaults, applied in normaliseWorkstation. const ( DefaultWorkstationProbe = 15 * time.Second DefaultWorkstationTimeout = 90 * time.Second + // One utterance, not one completion. A voice turn waits on this, so the + // budget is a few seconds and not a minute and a half. + DefaultWorkstationSttTimeout = 10 * time.Second ) // normaliseWorkstation applies the block's defaults. No address, no preferred @@ -63,4 +106,36 @@ func (c *Config) normaliseWorkstation() { if w.Timeout <= 0 { w.Timeout = Duration(DefaultWorkstationTimeout) } + normaliseWorkstationStt(w) +} + +// normaliseWorkstationStt applies the speech-to-text block's defaults. No +// address, no remote: mavsttd then takes every utterance, which is today. +func normaliseWorkstationStt(w *WorkstationConfig) { + if w.Stt != nil && strings.TrimSpace(w.Stt.URL) == "" { + w.Stt = nil + } + if w.Stt == nil { + return + } + s := w.Stt + if strings.TrimSpace(s.Health) == "" { + s.Health = healthOrigin(s.URL) + } + if s.Probe <= 0 { + s.Probe = Duration(DefaultWorkstationProbe) + } + if s.Timeout <= 0 { + s.Timeout = Duration(DefaultWorkstationSttTimeout) + } +} + +// healthOrigin derives the admission endpoint from the transcribe endpoint. +// The URL names a path, so appending to it would ask for /transcribe/health. +func healthOrigin(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return strings.TrimRight(raw, "/") + "/health" + } + return u.Scheme + "://" + u.Host + "/health" } diff --git a/internal/stt/http.go b/internal/stt/http.go new file mode 100644 index 0000000..adf04fd --- /dev/null +++ b/internal/stt/http.go @@ -0,0 +1,92 @@ +package stt + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/kami/maven/internal/audio" +) + +// HTTPTranscriber — speech-to-text on another host, over HTTP. +// +// mavsttd is whisper.cpp linked into a Go daemon and reached over a unix +// socket. CrisperWhisper 2.0 cannot be reached that way: whisper.cpp derives +// its language count from the vocabulary size, and CW2's 51897 tokens shift +// seven special token ids. It runs under transformers instead, as a service +// beside the model on workpc. See docs/evals/2026-08-09-crisperwhisper2-russian-wer.md. +// +// So this is the second transport for the same seam, not a second seam. The +// caller still sees stt.Transcriber and one method. +type HTTPTranscriber struct { + url string + token string + lang string + http *http.Client +} + +// NewHTTPTranscriber builds the remote client. token may be empty for a +// service on a trusted socket, but audio is the most sensitive thing that +// crosses this seam, so a LAN deployment should always set one. +func NewHTTPTranscriber(url, token, lang string, timeout time.Duration) *HTTPTranscriber { + return &HTTPTranscriber{ + url: url, + token: token, + lang: lang, + http: &http.Client{Timeout: timeout}, + } +} + +// ErrFormat — the audio is not the one canonical shape. Refused at the seam +// rather than sent to a model that expects something else. +var ErrFormat = errors.New("stt: audio is not 16kHz mono pcm_s16le") + +type httpTranscript struct { + Text string `json:"text"` + Confidence float64 `json:"confidence"` +} + +// Transcribe posts the raw PCM and reads back the text. +// +// The body is the PCM bytes themselves rather than JSON. A minute of 16kHz +// mono is under 2MB raw and about 2.6MB base64, and the format is fixed by +// audio.PCM16kMono, so a header carries it more cheaply than an envelope. +func (t *HTTPTranscriber) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) { + if !a.Format.IsValid() { + return "", 0, ErrFormat + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(a.Bytes)) + if err != nil { + return "", 0, fmt.Errorf("stt: build request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("X-Sample-Rate", strconv.Itoa(a.Format.SampleRate)) + req.Header.Set("X-Channels", strconv.Itoa(a.Format.Channels)) + req.Header.Set("X-Sample-Bits", strconv.Itoa(a.Format.SampleBits)) + req.Header.Set("X-Language", t.lang) + if t.token != "" { + req.Header.Set("Authorization", "Bearer "+t.token) + } + + resp, err := t.http.Do(req) + if err != nil { + return "", 0, fmt.Errorf("stt: post audio: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("stt: remote returned %d", resp.StatusCode) + } + + var out httpTranscript + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", 0, fmt.Errorf("stt: decode transcript: %w", err) + } + return out.Text, out.Confidence, nil +} + +var _ Transcriber = (*HTTPTranscriber)(nil) diff --git a/internal/stt/http_test.go b/internal/stt/http_test.go new file mode 100644 index 0000000..85d3289 --- /dev/null +++ b/internal/stt/http_test.go @@ -0,0 +1,89 @@ +package stt + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/kami/maven/internal/audio" +) + +func TestHTTPTranscriberSendsRawPCM(t *testing.T) { + t.Parallel() + var gotBody []byte + var gotHeader http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotHeader = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"text":"привет","confidence":0.82}`) + })) + defer srv.Close() + + a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("pcm-bytes")} + tr := NewHTTPTranscriber(srv.URL, "s3cret", "ru", 2*time.Second) + text, conf, err := tr.Transcribe(context.Background(), a) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if text != "привет" || conf != 0.82 { + t.Fatalf("got %q %v", text, conf) + } + if string(gotBody) != "pcm-bytes" { + t.Fatalf("body should be the PCM itself, got %q", gotBody) + } + if got := gotHeader.Get("X-Sample-Rate"); got != strconv.Itoa(audio.PCM16kMono.SampleRate) { + t.Fatalf("X-Sample-Rate = %q", got) + } + if got := gotHeader.Get("X-Language"); got != "ru" { + t.Fatalf("X-Language = %q", got) + } + // Audio is the most sensitive thing crossing this seam. + if got := gotHeader.Get("Authorization"); got != "Bearer s3cret" { + t.Fatalf("Authorization = %q", got) + } +} + +func TestHTTPTranscriberOmitsEmptyToken(t *testing.T) { + t.Parallel() + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + _, _ = io.WriteString(w, `{"text":"x"}`) + })) + defer srv.Close() + a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")} + if _, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second).Transcribe(context.Background(), a); err != nil { + t.Fatalf("Transcribe: %v", err) + } + if auth != "" { + t.Fatalf("Authorization should be absent, got %q", auth) + } +} + +func TestHTTPTranscriberRefusesWrongFormat(t *testing.T) { + t.Parallel() + a := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}} + _, _, err := NewHTTPTranscriber("http://example.invalid", "", "ru", time.Second).Transcribe(context.Background(), a) + if !errors.Is(err, ErrFormat) { + t.Fatalf("want ErrFormat, got %v", err) + } +} + +func TestHTTPTranscriberErrorsOnBadStatus(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")} + _, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second).Transcribe(context.Background(), a) + if err == nil { + t.Fatal("a 401 must be an error, so the Pair falls back") + } +} diff --git a/internal/stt/pair.go b/internal/stt/pair.go new file mode 100644 index 0000000..2001ed6 --- /dev/null +++ b/internal/stt/pair.go @@ -0,0 +1,157 @@ +package stt + +import ( + "context" + "errors" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/kami/maven/internal/audio" +) + +// Pair — a preferred transcriber on the workstation, with mavsttd as the floor. +// +// Same arrangement as llm.Pair and for the same reason. The microphone is at +// workpc, the card there has 16GB, and CrisperWhisper 2.0 turbo scores 10.4% +// WER in Russian against 27.5% for the ggml-small.bin homesrv loads +// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md). The workstation is +// never assumed up: it sleeps, and the card is often held by a training run. +// +// Speech-to-text has only the silent half of the degradation rule. A worse +// transcript is still a turn, and there is nothing to name a gap about, so +// Transcribe always falls back. That is the whole difference from llm.Pair, +// which also carries CompleteRemote for callers that must refuse instead. +type Pair struct { + remote Transcriber + floor Transcriber + + // up — the cached admission answer, written only by the prober and read by + // every turn. A voice turn must never wait on a machine that may be asleep. + up atomic.Bool + + health string + interval time.Duration + http *http.Client + stop chan struct{} + stopOnce sync.Once +} + +const ( + probeTimeout = 2 * time.Second + defaultProbeInterval = 15 * time.Second +) + +// ErrNoFloor — a Pair was built with no local transcriber to fall back to. A +// configuration mistake: the floor is what makes the remote optional. +var ErrNoFloor = errors.New("stt: no floor transcriber") + +// NewPair builds the two-transcriber arrangement. remote may be nil, which is +// the unconfigured deploy: every turn goes to the floor and nothing probes. +func NewPair(remote, floor Transcriber, health string, interval time.Duration) *Pair { + if interval <= 0 { + // The config normalises this, so a zero here is a caller that built the + // Pair directly. Panicking in a ticker is the wrong way to say so. + interval = defaultProbeInterval + } + return &Pair{ + remote: remote, + floor: floor, + health: health, + interval: interval, + http: &http.Client{Timeout: probeTimeout}, + stop: make(chan struct{}), + } +} + +// Start begins probing. The first probe runs before the first tick, so a +// workstation that is already up serves the first utterance rather than the +// second. Safe with a nil remote. +func (p *Pair) Start(ctx context.Context) { + if p.remote == nil || p.health == "" { + return + } + go func() { + p.probe(ctx) + t := time.NewTicker(p.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-p.stop: + return + case <-t.C: + p.probe(ctx) + } + } + }() +} + +// Stop ends the prober. Idempotent and safe from two goroutines. +func (p *Pair) Stop() { + p.stopOnce.Do(func() { close(p.stop) }) +} + +// Available reports whether the workstation will transcribe right now. +func (p *Pair) Available() bool { + return p.remote != nil && p.up.Load() +} + +func (p *Pair) probe(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.health, nil) + if err != nil { + p.set(false) + return + } + resp, err := p.http.Do(req) + if err != nil { + p.set(false) + return + } + defer resp.Body.Close() + p.set(resp.StatusCode == http.StatusOK) +} + +// set records the admission answer and logs only transitions. A machine that +// sleeps nightly would otherwise write one line per interval forever. +func (p *Pair) set(up bool) { + if p.up.Swap(up) == up { + return + } + if up { + log.Printf("stt: workstation transcriber available at %s", p.health) + } else { + log.Print("stt: workstation transcriber unavailable, falling back to mavsttd") + } +} + +// Transcribe sends the audio to the workstation when it will take work, and to +// mavsttd otherwise. A remote that fails mid-request falls back too, because +// the admission answer is a cache and can be one interval out of date. +// +// Killing the remote mid-session must not drop the turn. That is the whole +// point of the floor, and it is what TestPairFallsBackWhenRemoteFails pins. +func (p *Pair) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) { + if p.floor == nil { + return "", 0, ErrNoFloor + } + if p.Available() { + text, conf, err := p.remote.Transcribe(ctx, a) + if err == nil { + log.Print("stt: transcribed on the workstation") + return text, conf, nil + } + // The cached answer was wrong. Correct it now rather than sending the + // next utterance into the same hole, then fall back. + p.set(false) + log.Printf("stt: workstation failed mid-request, falling back: %v", err) + } + return p.floor.Transcribe(ctx, a) +} + +var _ Transcriber = (*Pair)(nil) diff --git a/internal/stt/pair_test.go b/internal/stt/pair_test.go new file mode 100644 index 0000000..b14b241 --- /dev/null +++ b/internal/stt/pair_test.go @@ -0,0 +1,143 @@ +package stt + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/kami/maven/internal/audio" +) + +// scripted — a Transcriber that answers with a fixed text, or fails. +type scripted struct { + text string + err error + calls atomic.Int32 +} + +func (s *scripted) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) { + s.calls.Add(1) + if s.err != nil { + return "", 0, s.err + } + return s.text, 0.9, nil +} + +func sample() audio.Audio { + return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)} +} + +// up builds a Pair whose admission answer is already true, without probing. +func up(remote, floor Transcriber) *Pair { + p := NewPair(remote, floor, "", time.Minute) + p.up.Store(true) + return p +} + +func TestPairPrefersTheWorkstation(t *testing.T) { + t.Parallel() + remote := &scripted{text: "с рабочей станции"} + floor := &scripted{text: "с homesrv"} + text, _, err := up(remote, floor).Transcribe(context.Background(), sample()) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if text != "с рабочей станции" { + t.Fatalf("want the remote transcript, got %q", text) + } + if floor.calls.Load() != 0 { + t.Fatalf("floor was called %d times, want 0", floor.calls.Load()) + } +} + +// The turn is what matters. A remote that dies mid-session must cost a worse +// transcript and nothing else. This is the V-486 bar. +func TestPairFallsBackWhenRemoteFails(t *testing.T) { + t.Parallel() + remote := &scripted{err: errors.New("connection refused")} + floor := &scripted{text: "с homesrv"} + p := up(remote, floor) + + text, conf, err := p.Transcribe(context.Background(), sample()) + if err != nil { + t.Fatalf("a failed remote must not fail the turn: %v", err) + } + if text != "с homesrv" { + t.Fatalf("want the floor transcript, got %q", text) + } + if conf != 0.9 { + t.Fatalf("want the floor confidence, got %v", conf) + } + if p.Available() { + t.Fatal("a failed request must correct the cached admission answer") + } + + // The next utterance goes straight to the floor rather than into the + // same hole. + if _, _, err := p.Transcribe(context.Background(), sample()); err != nil { + t.Fatalf("second turn: %v", err) + } + if remote.calls.Load() != 1 { + t.Fatalf("remote called %d times, want 1", remote.calls.Load()) + } +} + +func TestPairWithNoRemoteIsTheFloor(t *testing.T) { + t.Parallel() + floor := &scripted{text: "с homesrv"} + p := NewPair(nil, floor, "", time.Minute) + p.Start(context.Background()) // no health url, so this is a no-op + if p.Available() { + t.Fatal("an unconfigured remote is never available") + } + text, _, err := p.Transcribe(context.Background(), sample()) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if text != "с homesrv" { + t.Fatalf("want the floor transcript, got %q", text) + } +} + +func TestPairWithNoFloorRefuses(t *testing.T) { + t.Parallel() + _, _, err := NewPair(nil, nil, "", time.Minute).Transcribe(context.Background(), sample()) + if !errors.Is(err, ErrNoFloor) { + t.Fatalf("want ErrNoFloor, got %v", err) + } +} + +func TestPairProbeReadsHealth(t *testing.T) { + t.Parallel() + var ok atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if !ok.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + p := NewPair(&scripted{text: "remote"}, &scripted{text: "floor"}, srv.URL, time.Minute) + p.probe(context.Background()) + if p.Available() { + t.Fatal("a 503 means the card is busy, so the workstation is not available") + } + ok.Store(true) + p.probe(context.Background()) + if !p.Available() { + t.Fatal("a 200 means the workstation will take work") + } +} + +func TestPairStopIsIdempotent(t *testing.T) { + t.Parallel() + p := NewPair(nil, &scripted{}, "", time.Minute) + p.Stop() + p.Stop() +}