Супервайзер Server spawn'ит opencode serve (или ходит на внешний URL),
Runner ходит к нему через 'opencode run --attach <url>'. По умолчанию
serve.enabled=false — историческая spawn-модель сохранена; наличие
serve.url переключает на внешний сервер.
- config: ServeCfg (enabled/hostname/port/url/password) + дефолты; лоадер
научился int/bool (раньше только string/Duration/struct).
- opencode: Server (Start/Run/Close, /global/health, рестарт упавшего,
reaper-горутина владеет Wait; .Exited() непригоден для SIGKILL).
- Runner.AttachURL: run --attach <url> при заданном URL, иначе как раньше.
- app: composition root — при включённом serve запускает супервайзер.
- тесты: TestRun_AttachMode, TestServer_{ExternalURL,OwnProcess,Restart}.
255 lines
7.5 KiB
Go
255 lines
7.5 KiB
Go
package opencode
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// fakeOpenCode создаёт shell-скрипт, имитирующий opencode run:
|
||
//
|
||
// $FAKE_MODE=ok -> мгновенный успех, печатает NDJSON c session_id
|
||
// $FAKE_MODE=slow-> спит долго (для idle/hard timeout)
|
||
// $FAKE_MODE=fail-> exit 7 (resume-fallback)
|
||
func fakeOpenCode(t *testing.T, workdir string) string {
|
||
t.Helper()
|
||
bin := filepath.Join(workdir, "opencode")
|
||
script := `#!/bin/sh
|
||
mode="${FAKE_MODE:-ok}"
|
||
case "$mode" in
|
||
ok)
|
||
echo '{"type":"text","part":{"text":"done"}}'
|
||
echo '{"session_id":"sess-123"}'
|
||
exit 0
|
||
;;
|
||
slow)
|
||
sleep 30
|
||
;;
|
||
live-reset)
|
||
# шлём live-строку каждые 30мс долго — почти до hard timeout,
|
||
# чтобы idle-таймер (50мс) НЕ убил из-за стрима
|
||
i=0
|
||
while [ $i -lt 20 ]; do
|
||
echo '{"type":"text","part":{"text":"tick"}}'
|
||
sleep 0.03
|
||
i=$((i+1))
|
||
done
|
||
sleep 30
|
||
;;
|
||
fail)
|
||
echo '{"type":"text","part":{"text":"boom"}}'
|
||
exit 7
|
||
;;
|
||
args)
|
||
# печатаем аргументы в $FAKE_ARGS_FILE (тест читает) и успешно завершаемся
|
||
printf '%s\n' "$@" > "${FAKE_ARGS_FILE:-/dev/null}"
|
||
echo '{"type":"text","part":{"text":"ok"}}'
|
||
echo '{"session_id":"sess-args"}'
|
||
exit 0
|
||
;;
|
||
esac
|
||
`
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatalf("write fake opencode: %v", err)
|
||
}
|
||
return bin
|
||
}
|
||
|
||
func TestRun_Success(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := fakeOpenCode(t, dir)
|
||
t.Setenv("FAKE_MODE", "ok")
|
||
|
||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||
if err != nil {
|
||
t.Fatalf("Run err: %v", err)
|
||
}
|
||
if res.RC != 0 {
|
||
t.Errorf("RC = %d, want 0", res.RC)
|
||
}
|
||
if res.SessionID != "sess-123" {
|
||
t.Errorf("SessionID = %q, want sess-123", res.SessionID)
|
||
}
|
||
if !contains(res.Stdout, "done") {
|
||
t.Errorf("Stdout = %q, want to contain done", res.Stdout)
|
||
}
|
||
}
|
||
|
||
func TestRun_LiveRegistry(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := fakeOpenCode(t, dir)
|
||
t.Setenv("FAKE_MODE", "ok")
|
||
|
||
reg := NewLiveRegistry()
|
||
ctx := WithLive(context.Background(), reg, 42)
|
||
|
||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
||
res, err := r.Run(ctx, "task", dir, "dev", "")
|
||
if err != nil {
|
||
t.Fatalf("Run err: %v", err)
|
||
}
|
||
if res.RC != 0 {
|
||
t.Fatalf("RC = %d, want 0", res.RC)
|
||
}
|
||
// После завершения Finish удаляет сессию → Snap не найден.
|
||
if _, ok := reg.Snap(42); ok {
|
||
t.Error("сессия не удалена после Finish (должна быть, т.к. задача завершилась)")
|
||
}
|
||
}
|
||
|
||
func TestRun_IdleTimeout(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := fakeOpenCode(t, dir)
|
||
t.Setenv("FAKE_MODE", "slow")
|
||
|
||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
||
PollInterval: 10 * time.Millisecond}
|
||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||
if err != nil {
|
||
t.Fatalf("Run err: %v", err)
|
||
}
|
||
if res.RC != -1 {
|
||
t.Errorf("RC = %d, want -1 (timeout kill)", res.RC)
|
||
}
|
||
}
|
||
|
||
// TestRun_LiveResetsIdle: пока LLM стримит live-строки, idle-таймер должен
|
||
// сбрасываться, а не убивать процесс по истечении короткого IdleTimeout.
|
||
func TestRun_LiveResetsIdle(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := fakeOpenCode(t, dir)
|
||
t.Setenv("FAKE_MODE", "live-reset")
|
||
|
||
// idle очень короткий (50мс), hard большой (3с). live-reset стримит ~0.6с.
|
||
// Если live-строки НЕ сбрасывают idle — процесс убьют на ~50мс, и Run
|
||
// вернётся быстрее. Если сбрасывают — Run живёт ≥ стрима (~0.6с) до hard.
|
||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
||
HardTimeout: 3 * time.Second, PollInterval: 10 * time.Millisecond}
|
||
start := time.Now()
|
||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||
elapsed := time.Since(start)
|
||
if err != nil {
|
||
t.Fatalf("Run err: %v", err)
|
||
}
|
||
if res.RC != -1 {
|
||
t.Errorf("RC = %d, want -1 (killed по hard timeout)", res.RC)
|
||
}
|
||
if elapsed < 400*time.Millisecond {
|
||
t.Errorf("Run вернулся за %v — idle убил во время стрима (live не сбросил таймер)", elapsed)
|
||
}
|
||
}
|
||
|
||
func TestRun_ContextCancel(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := fakeOpenCode(t, dir)
|
||
t.Setenv("FAKE_MODE", "slow")
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
r := &Runner{Bin: bin, HardTimeout: time.Minute,
|
||
PollInterval: 10 * time.Millisecond}
|
||
done := make(chan *Result, 1)
|
||
errCh := make(chan error, 1)
|
||
go func() {
|
||
res, err := r.Run(ctx, "task", dir, "dev", "")
|
||
done <- res
|
||
errCh <- err
|
||
}()
|
||
time.Sleep(30 * time.Millisecond)
|
||
cancel()
|
||
res := <-done
|
||
if err := <-errCh; err != nil {
|
||
t.Fatalf("Run err: %v", err)
|
||
}
|
||
if res.RC != -1 {
|
||
t.Errorf("RC = %d, want -1", res.RC)
|
||
}
|
||
}
|
||
|
||
func TestResumeDev_Fallback(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := fakeOpenCode(t, dir)
|
||
t.Setenv("FAKE_MODE", "fail")
|
||
|
||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
||
res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session")
|
||
if timedOut {
|
||
t.Error("timedOut = true, want false")
|
||
}
|
||
// fake fail всегда exit 7, fallback тоже 7 — проверяем что RC от fallback-вызова
|
||
if res.RC != 7 {
|
||
t.Errorf("RC = %d, want 7 (fallback повтор с тем же кодом)", res.RC)
|
||
}
|
||
}
|
||
|
||
// TestRun_AttachMode проверяет, что при заданном AttachURL команда opencode run
|
||
// получает флаг `--attach <url>`, и что без AttachURL — не получает.
|
||
func TestRun_AttachMode(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := fakeOpenCode(t, dir)
|
||
t.Setenv("FAKE_MODE", "args")
|
||
|
||
cases := []struct {
|
||
name string
|
||
attach string
|
||
wantFlag bool
|
||
}{
|
||
{"attach задан", "http://127.0.0.1:4096", true},
|
||
{"attach пуст (spawn-модель)", "", false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
argsFile := filepath.Join(dir, "args_"+strings.ReplaceAll(tc.name, " ", "_")+".txt")
|
||
t.Setenv("FAKE_ARGS_FILE", argsFile)
|
||
|
||
r := &Runner{Bin: bin, AttachURL: tc.attach, PollInterval: 20 * time.Millisecond}
|
||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||
if err != nil {
|
||
t.Fatalf("Run err: %v", err)
|
||
}
|
||
if res.RC != 0 {
|
||
t.Fatalf("RC = %d, want 0", res.RC)
|
||
}
|
||
data, err := os.ReadFile(argsFile)
|
||
if err != nil {
|
||
t.Fatalf("читать args-файл: %v", err)
|
||
}
|
||
args := strings.Fields(string(data))
|
||
hasAttach := false
|
||
for i, a := range args {
|
||
if a == "--attach" {
|
||
hasAttach = true
|
||
if i+1 >= len(args) || args[i+1] != tc.attach {
|
||
t.Fatalf("--attach URL = %q, want %q", args[min(i+1, len(args)-1)], tc.attach)
|
||
}
|
||
}
|
||
}
|
||
if hasAttach != tc.wantFlag {
|
||
t.Errorf("--attach присутствует = %v, want %v; args=%v", hasAttach, tc.wantFlag, args)
|
||
}
|
||
if hasAttach && tc.attach != "" {
|
||
// --dir должен идти следом за --attach
|
||
if !contains(string(data), "--dir") {
|
||
t.Errorf("ожидался --dir в args: %v", args)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func contains(s, sub string) bool {
|
||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
|
||
}
|
||
|
||
func indexOf(s, sub string) int {
|
||
for i := 0; i+len(sub) <= len(s); i++ {
|
||
if s[i:i+len(sub)] == sub {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|