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

@@ -0,0 +1,75 @@
package opencode
import (
"path/filepath"
"testing"
"time"
)
func TestPool_EnsureRoot_NoSpawn(t *testing.T) {
// без spawn: пул без Bin — EnsureRoot должен упасть (нет бинаря),
// но НЕ упасть на пустой карте. Проверяем, что повтор вызова не паникует.
dir := t.TempDir()
p := NewPool(dir)
// не запускаем — просто проверяем кэш
p.mu.Lock()
p.segs[dir] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Millisecond}
p.mu.Unlock()
s, ok := p.ServerFor(dir)
if !ok || s == nil {
t.Fatal("ServerFor должен найти закэшированный сервер")
}
}
func TestPool_ServerFor_ReleaseTask(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "a")
b := filepath.Join(dir, "b")
p := NewPool(dir)
p.Bin = fakeServeBin(t, dir)
// root не запускаем; добавляем серверы в карту напрямую (как после Ensure).
p.mu.Lock()
p.segs[a] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Nanosecond}
p.segs[b] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Nanosecond}
p.mu.Unlock()
// ReleaseTask: rootDir в карте НЕ занят, значит оба закрываются.
p.ReleaseTask()
if _, ok := p.ServerFor(a); ok {
t.Error("каталог a должен быть снят после ReleaseTask")
}
if _, ok := p.ServerFor(b); ok {
t.Error("каталог b должен быть снят после ReleaseTask")
}
}
func TestPool_AllocPort_Unique(t *testing.T) {
p := NewPool("/tmp/x")
p.Host = "127.0.0.1"
seen := map[int]bool{}
var ports []int
for i := 0; i < 5; i++ {
port, err := p.allocPort()
if err != nil {
t.Fatalf("allocPort err: %v", err)
}
if seen[port] {
t.Fatalf("дубль порта %d", port)
}
seen[port] = true
ports = append(ports, port)
}
// освобождаем и убеждаемся, что порт можно переиспользовать
p.releasePort(ports[0])
if !p.free(ports[0]) {
t.Errorf("порт %d должен быть свободен после releasePort", ports[0])
}
}
func (p *Pool) free(port int) bool {
p.mu.Lock()
defer p.mu.Unlock()
return !p.used[port]
}