feat(opencode): переход на v2 HTTP API opencode (хардпин модели, поллинг вердикта)
- client.go: эндпоинты /api/* (create+model, prompt-admit, message, active, interrupt) - runner.go: неблокирующий prompt + поллинг новых assistant-сообщений; завершение = сессия ушла из активных дренажей + стабильное финальное сообщение - config.go: чтение top-level model из opencode.jsonc (JSONC-стрип) + хардпин в сессию - server.go: healthcheck /api/health, MinVersion=1.18.18, понятная ошибка для старого бинаря - класс O5 WARN: устойчивость к v1-конфигу провайдера (npm/options игнорируются v2) - README: раздел интеграции, минимальная версия opencode, предупреждения - .serena: актуализация памяти (core, tech_stack)
This commit is contained in:
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,11 +20,13 @@ type Result struct {
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// Runner — запуск opencode-субагентов через HTTP API serve.
|
||||
// Runner — запуск opencode-субагентов через v2 HTTP API serve.
|
||||
//
|
||||
// Полный переход на API: Runner ходит к opencode serve через Pool→Client
|
||||
// (нет spawn-модели, нет NDJSON). Агент идёт в сервер пула для своего каталога
|
||||
// (в нём запущен serve → он его project).
|
||||
// Runner ходит к opencode serve через Pool→Client (пути /api/*, см. README,
|
||||
// минимальная версия opencode). Промпт отправляется неблокирующе (durable
|
||||
// admit), вердикт собирается поллингом новых assistant-сообщений; завершение
|
||||
// ответа определяется по схеме «сессия больше не в активных дренажах» + финальное
|
||||
// assistant-сообщение.
|
||||
type Runner struct {
|
||||
Pool *Pool // пул serve-серверов (обязательный)
|
||||
IdleTimeout time.Duration
|
||||
@@ -73,49 +75,63 @@ func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string)
|
||||
}
|
||||
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug}
|
||||
|
||||
// Модель по умолчанию из конфига opencode — хардпиним её в сессии, чтобы
|
||||
// не зависеть от fallback-логики opencode (класс O5 WARN: если модель не
|
||||
// считывается/не задана — предупреждаем и работаем без явного указания).
|
||||
model, mErr := ReadModelRef(srv.Config, srv.ConfigDir)
|
||||
if mErr != nil {
|
||||
r.logf("WARN opencode: не удалось прочитать model из конфига: %v", mErr)
|
||||
} else if model == nil {
|
||||
r.logf("WARN opencode: в конфиге opencode не задан top-level model — модель не хардпинится (риск fallback)")
|
||||
} else {
|
||||
r.logf("opencode(%s) model=%s", agent, model)
|
||||
}
|
||||
|
||||
// Сессия: заданная (resume) или новая.
|
||||
sid := sessionID
|
||||
if sid == "" {
|
||||
sid, err = c.CreateSession(ctx, "ratatoskr-"+agent)
|
||||
sid, err = c.CreateSession(ctx, model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opencode: create session: %w", err)
|
||||
}
|
||||
r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr())
|
||||
}
|
||||
|
||||
// Отправляем промпт (блокирующий Send в горутине; вердикт придёт из него),
|
||||
// параллельно поллим прогресс и контролируем idle/hard таймауты.
|
||||
return r.awaitVerdict(ctx, c, sid, agent, prompt)
|
||||
return r.awaitVerdict(ctx, c, model, sid, agent, prompt)
|
||||
}
|
||||
|
||||
// awaitVerdict запускает блокирующий Send и параллельно поллит прогресс
|
||||
// (рост числа text-частей = агент жив, сбрасывает idle). Возвращается вердикт
|
||||
// из ответа Send, либо rc=-1 при idle/hard таймауте (тогда Abort + отмена ctx).
|
||||
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt string) (*Result, error) {
|
||||
sendCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
type sendOut struct {
|
||||
vd string
|
||||
err error
|
||||
}
|
||||
sendCh := make(chan sendOut, 1)
|
||||
go func() {
|
||||
vd, err := c.Send(sendCtx, sid, prompt)
|
||||
sendCh <- sendOut{vd: vd, err: err}
|
||||
}()
|
||||
// settlePolls — сколько подряд опросов должно подтвердить завершение ответа,
|
||||
// прежде чем считать вердикт финальным (устойчивость к гонке между удалением
|
||||
// сессии из активных дренажей и финализацией последнего сообщения).
|
||||
const settlePolls = 2
|
||||
|
||||
// Прогресс = сумма text-частей во всех assistant-сообщениях сессии. Рост
|
||||
// сбрасывает idle-таймер (LLM стримит = жив).
|
||||
var mu sync.Mutex
|
||||
// awaitVerdict отправляет промпт (неблокирующе) и поллит новые assistant-сообщения,
|
||||
// контролируя idle/hard таймауты. Завершение: сессия ушла из активных дренажей
|
||||
// И есть новое завершённое assistant-сообщение, стабильное в течение settlePolls
|
||||
// опросов. Возвращает вердикт (текст text-партов), либо rc=-1 при таймауте.
|
||||
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, sid, agent, prompt string) (*Result, error) {
|
||||
// admit промпта; граница «новых» сообщений — время создания user-сообщения.
|
||||
admittedAt := time.Now().UnixMilli()
|
||||
adm, err := c.Prompt(ctx, sid, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if adm != nil && adm.TimeCreated > 0 {
|
||||
admittedAt = adm.TimeCreated
|
||||
}
|
||||
|
||||
// Прогресс = число text-партов в новых assistant-сообщениях. Рост сбрасывает
|
||||
// idle-таймер (LLM стримит = жив).
|
||||
lastCount := -1
|
||||
lastProgress := time.Now()
|
||||
launch := time.Now()
|
||||
|
||||
doneSeen, emptySeen := 0, 0
|
||||
|
||||
abortAnd := func(rc int, why string) (*Result, error) {
|
||||
if err := c.Abort(ctx, sid); err != nil {
|
||||
r.logf("opencode(%s) abort %s: %v", agent, why, err)
|
||||
if err := c.Interrupt(ctx, sid); err != nil {
|
||||
r.logf("opencode(%s) interrupt %s: %v", agent, why, err)
|
||||
}
|
||||
cancel()
|
||||
return &Result{RC: rc, Stdout: "", SessionID: sid}, nil
|
||||
}
|
||||
|
||||
@@ -125,43 +141,84 @@ func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
|
||||
count, _ := c.textCount(ctx, sid)
|
||||
mu.Lock()
|
||||
msgs, err := c.Messages(ctx, sid)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
var ce *ClientErr
|
||||
if errors.As(err, &ce) && ce.Op == "connect" {
|
||||
return nil, fmt.Errorf("opencode: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
active, err := c.Active(ctx, sid)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
var ce *ClientErr
|
||||
if errors.As(err, &ce) && ce.Op == "connect" {
|
||||
return nil, fmt.Errorf("opencode: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cur, count := newestAssistant(msgs, admittedAt)
|
||||
if count != lastCount {
|
||||
lastProgress = time.Now()
|
||||
lastCount = count
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(lastProgress) > r.IdleTimeout {
|
||||
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
|
||||
return abortAnd(-1, "idle")
|
||||
}
|
||||
// hard — общий бюджет от старта запуска.
|
||||
if now.Sub(launch) > r.HardTimeout {
|
||||
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
|
||||
return abortAnd(-1, "hard")
|
||||
}
|
||||
|
||||
select {
|
||||
case out := <-sendCh:
|
||||
// Send завершился. Ошибка — connect (сервер недоступен) и ctx жив →
|
||||
// фатально, не таймаут. Если ctx уже отменён — это обрыв, а не ошибка.
|
||||
if out.err != nil {
|
||||
var ce *ClientErr
|
||||
if errors.As(out.err, &ce) && ce.Op == "connect" && ctx.Err() == nil {
|
||||
return nil, fmt.Errorf("opencode: %w", out.err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
return nil, out.err
|
||||
switch {
|
||||
case !active && cur != nil && cur.finished():
|
||||
// ответ закончен — ждём стабильности, затем собираем вердикт
|
||||
doneSeen++
|
||||
emptySeen = 0
|
||||
if doneSeen >= settlePolls {
|
||||
return r.verdict(model, cur, msgs, admittedAt, sid)
|
||||
}
|
||||
r.logf("opencode(%s) вердикт готов (%d байт)", agent, len(out.vd))
|
||||
return &Result{RC: 0, Stdout: out.vd, SessionID: sid}, nil
|
||||
case !active && cur == nil:
|
||||
// сессия завершилась, но нового assistant-сообщения так и нет
|
||||
emptySeen++
|
||||
if emptySeen >= settlePolls {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New("агент не выдал ответ (сессия пуста)")}
|
||||
}
|
||||
default:
|
||||
doneSeen, emptySeen = 0, 0
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(r.PollInterval):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verdict собирает финальный результат из новых assistant-сообщений.
|
||||
// Проверяет фактическую модель ответа и логирует warning при расхождении
|
||||
// с ожидаемой (устойчивость к «не той» модели — класс O5 WARN).
|
||||
func (r *Runner) verdict(model *ModelRef, cur *v2Message, msgs []v2Message, since int64, sid string) (*Result, error) {
|
||||
if model != nil && cur.Model != nil && (model.ProviderID != cur.Model.ProviderID || model.ID != cur.Model.ID) {
|
||||
r.logf("WARN opencode: сессия %s отвечала моделью %s, а не ожидаемой %s — проверь providers в конфиге (v2-схема: provider.api / request, а не npm/options)", sid, cur.Model, model)
|
||||
}
|
||||
if cur.Error != nil && cur.Error.Message != "" {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New(cur.Error.Message)}
|
||||
}
|
||||
texts := assistantText(msgs, since)
|
||||
if len(texts) == 0 {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New("нет text-части в ответе")}
|
||||
}
|
||||
vd := stripFence(strings.Join(texts, "\n"))
|
||||
r.logf("opencode вердикт готов (%d байт)", len(vd))
|
||||
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user