feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Some checks failed
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Failing after 26s

Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
  выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
  text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
  агент определяется по title сессии
This commit is contained in:
Hermes
2026-08-18 13:36:54 +05:00
parent b60978121d
commit 733e63339a
12 changed files with 943 additions and 494 deletions

View File

@@ -2,68 +2,42 @@ package opencode
import (
"context"
"os"
"path/filepath"
"strings"
"net/http/httptest"
"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 {
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
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
ts := httptestURL(t, f)
p := NewPool(dir)
p.mu.Lock()
p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond}
p.mu.Unlock()
return p, &Client{BaseURL: ts}
}
// httptestURL запускает фейк-API и возвращает его URL.
func httptestURL(t *testing.T, f *fakeAPIServer) string {
t.Helper()
ts := httptest.NewServer(f.handler())
t.Cleanup(ts.Close)
return ts.URL
}
func TestRun_Success(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "ok")
f := &fakeAPIServer{
messages: []sessionMessage{{Type: "assistant", Content: []struct {
Type string `json:"type"`
Text string `json:"text"`
}{textPart("done")}}},
}
p, _ := fakePool(t, f, dir)
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
res, err := r.Run(context.Background(), "task", dir, "dev", "")
if err != nil {
t.Fatalf("Run err: %v", err)
@@ -71,86 +45,39 @@ func TestRun_Success(t *testing.T) {
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 res.SessionID != "sess-fake" {
t.Errorf("SessionID = %q, want sess-fake", 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 (должна быть, т.к. задача завершилась)")
t.Errorf("Stdout = %q, want contain done", res.Stdout)
}
}
func TestRun_IdleTimeout(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "slow")
// сервер никогда не отдаёт text → всегда неготов, прогресс не растёт
f := &fakeAPIServer{}
p, _ := fakePool(t, f, dir)
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
PollInterval: 10 * time.Millisecond}
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
PollInterval: 5 * 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)
t.Errorf("RC = %d, want -1 (idle timeout)", res.RC)
}
}
func TestRun_ContextCancel(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "slow")
f := &fakeAPIServer{}
p, _ := fakePool(t, f, dir)
ctx, cancel := context.WithCancel(context.Background())
r := &Runner{Bin: bin, HardTimeout: time.Minute,
PollInterval: 10 * time.Millisecond}
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
PollInterval: 5 * time.Millisecond}
done := make(chan *Result, 1)
errCh := make(chan error, 1)
go func() {
@@ -169,74 +96,26 @@ func TestRun_ContextCancel(t *testing.T) {
}
}
func TestResumeDev_Fallback(t *testing.T) {
// TestResumeDev_Fallback: resume (sessionID) "падает" rc!=0 только когда самого
// сервера нет; в фейке такого нет, поэтому проверяем, что при успехе
// fallback не срабатывает и таймаут не выставляется.
func TestResumeDev_NoFallbackOnSuccess(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "fail")
f := &fakeAPIServer{
messages: []sessionMessage{{Type: "assistant", Content: []struct {
Type string `json:"type"`
Text string `json:"text"`
}{textPart("ok")}}},
}
p, _ := fakePool(t, f, dir)
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session")
if timedOut {
t.Error("timedOut = true, want false")
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)
}
}
})
if res.RC != 0 {
t.Errorf("RC = %d, want 0", res.RC)
}
}
@@ -251,4 +130,4 @@ func indexOf(s, sub string) int {
}
}
return -1
}
}