Files
ratatoskr-go/internal/opencode/runner.go
Hermes ad9c2dd522
All checks were successful
CI / test (push) Successful in 41s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 35s
feat(config): уровень логирования log.level (info|debug), по умолчанию info
Отладочные логи API-вызовов к opencode serve теперь включаются только при
log.level=debug (гейт через Client.Debug <- Runner.Debug <- cfg.Log.Debug()).
По умолчанию 'info' — отладочных логов нет.

- config: добавлен LogCfg{Level}, дефолт 'info', валидация (info|debug)
- opencode: Client.Debug и Runner.Debug — гейттят логи do()
- app: Runner.Debug из cfg.Log.Debug()
- config.yaml.example: задокументирован log.level
- тесты: дефолт=info, level=debug, невалидный уровень
2026-08-18 18:44:37 +05:00

212 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package opencode
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sync"
"time"
)
// Result — результат запуска opencode-субагента через HTTP API. rc=-1 означает
// «оборван по таймауту/контексту» (idle/hard): вызывающий НЕ должен ронять
// задачу, а обязан закоммитить/запушить готовую работу и отправить на ревью
// (класс O2 Timeout — результат, не ошибка).
type Result struct {
RC int
Stdout string
SessionID string
}
// Runner — запуск opencode-субагентов через HTTP API serve.
//
// Полный переход на API: Runner ходит к opencode serve через Pool→Client
// (нет spawn-модели, нет NDJSON). Агент идёт в сервер пула для своего каталога
// (в нём запущен serve → он его project).
type Runner struct {
Pool *Pool // пул serve-серверов (обязательный)
IdleTimeout time.Duration
HardTimeout time.Duration
PollInterval time.Duration
Debug bool // отладочные логи API-вызовов (из log.level=debug)
// Заменяемый для тестов:
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
}
func (r *Runner) defaults() {
if r.IdleTimeout == 0 {
r.IdleTimeout = 5 * time.Minute
}
if r.HardTimeout == 0 {
r.HardTimeout = 20 * time.Minute
}
if r.PollInterval == 0 {
r.PollInterval = 2 * time.Second
}
if r.Stdout == nil {
r.Stdout = os.Stderr
}
}
func (r *Runner) logf(format string, args ...any) {
fmt.Fprintf(r.Stdout, format+"\n", args...)
}
// Run запускает opencode-субагента через HTTP API: создаёт/продолжает сессию
// в сервере пула для каталога cwd, отправляет промпт, ждёт вердикт.
//
// Возвращает *Result (rc, stdout=вердикт, session_id). Ошибка — только класс
// O1 ErrRun (не смог обратиться к серверу/сессии). Таймауты дают rc=-1 в
// Result, а не error (класс O2).
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
r.defaults()
if r.Pool == nil {
return nil, fmt.Errorf("opencode: Pool не задан (API-режим обязателен)")
}
srv, err := r.Pool.Ensure(ctx, cwd)
if err != nil {
return nil, err
}
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug}
// Сессия: заданная (resume) или новая.
sid := sessionID
if sid == "" {
sid, err = c.CreateSession(ctx, "ratatoskr-"+agent)
if err != nil {
return nil, fmt.Errorf("opencode: create session: %w", err)
}
r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr())
}
// Отправляем промпт (неблокирующий — сервер начинает выполнение).
if err := c.Send(ctx, sid, prompt); err != nil {
return nil, fmt.Errorf("opencode: prompt: %w", err)
}
return r.awaitVerdict(ctx, c, sid, agent)
}
// awaitVerdict поллит сообщения сессии, пока не появится готовый text-вердикт
// от assistant, либо не истечёт idle/hard таймаут (тогда Abort + rc=-1).
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent string) (*Result, error) {
// Прогресс = сумма text-частей во всех assistant-сообщениях сессии. Рост
// сбрасывает idle-таймер (LLM стримит = жив).
var mu sync.Mutex
lastCount := -1
lastProgress := time.Now()
launch := time.Now()
for {
if ctx.Err() != nil {
if err := c.Abort(ctx, sid); err != nil {
r.logf("opencode(%s) abort (ctx): %v", agent, err)
}
r.logf("opencode(%s) ctx cancelled — обрыв (rc=-1)", agent)
return &Result{RC: -1, Stdout: "", SessionID: sid}, nil
}
count, _ := c.textCount(ctx, sid)
mu.Lock()
if count != lastCount {
lastProgress = time.Now()
lastCount = count
}
mu.Unlock()
// Пробуем вердикт (дешёвый GET). Если готов — выходим.
vd, vErr := c.Verdict(ctx, sid)
if vErr == nil && vd != "" {
r.logf("opencode(%s) вердикт готов (%d байт)", agent, len(vd))
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
}
// Сервер недоступен — фатально (не таймаут). Но если это следствие
// отмены контекста (cancel прилетел прямо во время запроса) — это обрыв
// по контексту, а не недоступность сервера: вернём RC=-1 на следующей
// итерации (проверка ctx.Err() вверху) и не подменяем класс ошибки.
var ce *ClientErr
if errors.As(vErr, &ce) && ce.Op == "connect" && ctx.Err() == nil {
return nil, fmt.Errorf("opencode: %w", vErr)
}
now := time.Now()
if now.Sub(lastProgress) > r.IdleTimeout {
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
if err := c.Abort(ctx, sid); err != nil {
r.logf("opencode(%s) abort idle: %v", agent, err)
}
return &Result{RC: -1, Stdout: "", SessionID: sid}, nil
}
// hard — общий бюджет от старта запуска.
if now.Sub(launch) > r.HardTimeout {
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
if err := c.Abort(ctx, sid); err != nil {
r.logf("opencode(%s) abort hard: %v", agent, err)
}
return &Result{RC: -1, Stdout: "", SessionID: sid}, nil
}
select {
case <-time.After(r.PollInterval):
case <-ctx.Done():
}
}
}
// textCount считает число text-частей в assistant-сообщениях (для progress).
func (c *Client) textCount(ctx context.Context, sessionID string) (int, error) {
msgs, err := c.messages(ctx, sessionID)
if err != nil {
return 0, err
}
n := 0
for _, m := range msgs {
if m.Type != "assistant" {
continue
}
for _, p := range m.Content {
if p.Type == "text" && p.Text != "" {
n++
}
}
}
return n, nil
}
// messages возвращает сырые сообщения сессии (для поллинга прогресса).
func (c *Client) messages(ctx context.Context, sessionID string) ([]sessionMessage, error) {
raw, err := c.do(ctx, "GET", "/api/session/"+sessionID+"/message?order=asc&limit=200", "messages", nil)
if err != nil {
return nil, err
}
var out struct {
Data []sessionMessage `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return out.Data, nil
}
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
// падает с rc!=0 — повторяем ОДИН раз свежей сессией в том же каталоге.
// rc=-1 (обрыв по таймауту) НЕ триггерит fallback.
// Возвращает (result, timedOut).
func (r *Runner) ResumeDev(ctx context.Context, prompt, cwd, sessionID string) (*Result, bool) {
res, err := r.Run(ctx, prompt, cwd, "dev", sessionID)
if err != nil {
return res, false
}
if res.RC != 0 && res.RC != -1 && sessionID != "" {
r.logf("dev resume rc=%d — запускаю заново свежей сессией (каталог сохраняю)", res.RC)
res, _ = r.Run(ctx, prompt+resumeFallbackNote, cwd, "dev", "")
}
return res, res.RC == -1
}
const resumeFallbackNote = "\n\n(Возобновление сессии не удалось; продолжи с учётом уже сделанных изменений в worktree.)"