Files
ratatoskr-go/internal/opencode/runner.go
2026-08-17 11:53:24 +05:00

293 lines
9.5 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 (
"bufio"
"context"
"database/sql"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
)
// Result — результат запуска opencode run. rc=-1 означает «убит по таймауту»
// (idle/hard): вызывающий НЕ должен ронять задачу, а обязан закоммитить/запушить
// готовую работу и отправить на ревью (класс O2 Timeout — результат, не ошибка).
type Result struct {
RC int
Stdout string
SessionID string
}
// Runner — конфигурация запуска opencode-субагентов.
type Runner struct {
Bin string // путь к opencode (по умолчанию "opencode")
DBPath string // путь к opencode.db (idle-детекция активности)
Config string // путь к opencode.json (OPENCODE_CONFIG)
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
HardTimeout time.Duration // общий лимит на запуск
PollInterval time.Duration
// Заменяемые для тестов:
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
}
func (r *Runner) defaults() {
if r.Bin == "" {
r.Bin = "opencode"
}
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...)
}
// maxDirMsgTS — максимальный time_updated (мс) по всем сообщениям сессий этого
// worktree: сигнал «модель/субагенты ещё активны». nil-nil если БД нет/пуста.
func (r *Runner) maxDirMsgTS(ctx context.Context, worktree string) (int64, bool) {
if r.DBPath == "" {
return 0, false
}
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
if err != nil {
return 0, false
}
defer db.Close()
var ts sql.NullInt64
err = db.QueryRowContext(ctx,
"SELECT MAX(m.time_updated) FROM message m JOIN session s ON s.id = m.session_id WHERE s.directory = ?",
worktree).Scan(&ts)
if err != nil || !ts.Valid {
return 0, false
}
return ts.Int64, true
}
func (r *Runner) latestSession(ctx context.Context, worktree, agent string) (string, bool) {
if r.DBPath == "" {
return "", false
}
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
if err != nil {
return "", false
}
defer db.Close()
q := "SELECT id FROM session WHERE directory = ?"
args := []any{worktree}
if agent != "" {
q += " AND agent = ?"
args = append(args, agent)
}
q += " ORDER BY time_created DESC LIMIT 1"
var id string
if err := db.QueryRowContext(ctx, q, args...).Scan(&id); err != nil {
return "", false
}
return id, id != ""
}
// Run запускает opencode run. Возвращает *Result (rc, stdout, session_id).
// Ошибка — только класс O1 ErrSpawn (не смог запустить бинарь). Таймауты
// дают rc=-1 в Result, а не error (класс O2).
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
r.defaults()
cmd := []string{r.Bin, "run", "--agent", agent, "--format", "json", "--dir", cwd}
if sessionID != "" {
cmd = append(cmd, "--session", sessionID)
}
cmd = append(cmd, prompt)
env := append(os.Environ(),
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DISABLE_MODELS_FETCH=1")
if r.Config != "" {
env = append(env, "OPENCODE_CONFIG="+r.Config)
}
if r.ConfigDir != "" {
env = append(env, "OPENCODE_CONFIG_DIR="+r.ConfigDir)
}
proc := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
proc.Env = env
proc.Dir = cwd
// Убиваем всю process-group, чтобы дочерние процессы (sleep и т.п.) тоже
// умерли и закрыли унаследованные stdout-fd (иначе <-done виснет).
setpgid(proc)
stdout, err := proc.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("opencode: stdout pipe: %w", err)
}
proc.Stderr = proc.Stdout
if err := proc.Start(); err != nil {
return nil, fmt.Errorf("opencode: start %v: %w", cmd[0], err)
}
var buf []string
var mu sync.Mutex
done := make(chan struct{})
// liveSeq — кол-во распознанных live-строк (text/tool/agent/reasoning) в
// NDJSON-потоке. Инкрементится из goroutine чтения; поллинг сравнивает,
// чтобы сбросить idle-таймер «пока LLM стримит» (а не только по БД).
var liveSeq atomic.Uint64
prevLive := liveSeq.Load()
// Живое наблюдение сессии (если задано через WithLive в контексте).
liveReg, liveTask := liveFromContext(ctx)
if liveReg != nil && liveTask != 0 {
liveReg.Start(liveTask, agent)
defer liveReg.Finish(liveTask)
}
go func() {
defer close(done)
sc := bufio.NewScanner(stdout)
// NDJSON opencode пишет каждый объект одной строкой; большой text-парт с
// вердиктом легко превышает дефолтный лимит Scanner в 64КБ → ErrTooLong и
// потеря всего потока после первой строки. Поднимаем до 64МБ.
sc.Buffer(make([]byte, 64*1024), 64*1024*1024)
for sc.Scan() {
line := sc.Text()
mu.Lock()
buf = append(buf, line)
mu.Unlock()
if st := parseLiveStep(line); st != nil {
// «пульс» LLM: что-то стримится/вызывается — сбрасываем idle
liveSeq.Add(1)
if liveReg != nil {
liveReg.Observe(liveTask, *st)
}
}
}
scanErr := sc.Err()
if scanErr != nil {
r.logf("opencode(%s) scan err: %v", agent, scanErr)
}
}()
baseline, _ := r.maxDirMsgTS(ctx, cwd)
lastProgress := time.Now()
launch := time.Now()
killed := false
pollLoop:
for {
select {
case <-done:
// процесс завершился (pipe EOF) — выходим, берём exit code
break pollLoop
case <-ctx.Done():
killGroup(proc)
killed = true
break pollLoop
default:
}
if proc.ProcessState != nil && proc.ProcessState.Exited() {
break pollLoop
}
now := time.Now()
// «Пульс» LLM: если с прошлого поллинга появились live-строки
// (text/tool/agent/reasoning) — LLM реально работает, сбрасываем idle.
if cur := liveSeq.Load(); cur != prevLive {
prevLive = cur
lastProgress = now
}
ts, ok := r.maxDirMsgTS(ctx, cwd)
if ok && ts > baseline {
lastProgress = now
}
if now.Sub(lastProgress) > r.IdleTimeout {
r.logf("opencode(%s) idle %.0fs (нет новых сообщений) — kill", agent, r.IdleTimeout.Seconds())
killGroup(proc)
killed = true
break pollLoop
}
if now.Sub(launch) > r.HardTimeout {
r.logf("opencode(%s) hard timeout %.0fs — kill", agent, r.HardTimeout.Seconds())
killGroup(proc)
killed = true
break pollLoop
}
time.Sleep(r.PollInterval)
}
<-done
procErr := proc.Wait()
rc := proc.ProcessState.ExitCode()
if rc < 0 {
rc = 1
}
if killed {
rc = -1
}
_ = procErr
mu.Lock()
out := strings.Join(buf, "\n")
mu.Unlock()
r.logf("opencode(%s) lines=%d bytes=%d", agent, len(buf), len(out))
sid := sessionID
if s, ok := SessionIDFromOutput(out); ok {
sid = s
}
if rc == -1 && sid == "" {
if s, ok := r.latestSession(ctx, cwd, agent); ok {
sid = s
}
}
r.logf("opencode(%s) rc=%d", agent, rc)
return &Result{RC: rc, Stdout: out, SessionID: sid}, nil
}
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
// падает с rc!=0 (напр. сессия потеряна) — повторяем ОДИН раз свежей сессией
// в том же worktree. rc=-1 (kill по таймауту) НЕ триггерит 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 {
// spawn-ошибку не ретраим fallback'ом — она повторится
return res, false
}
if res.RC != 0 && res.RC != -1 && sessionID != "" {
r.logf("dev resume rc=%d — запускаю заново без --session (worktree сохраняю)", res.RC)
res, _ = r.Run(ctx, prompt+resumeFallbackNote, cwd, "dev", "")
}
return res, res.RC == -1
}
const resumeFallbackNote = "\n\n(Возобновление сессии не удалось; продолжи с учётом уже сделанных изменений в worktree.)"
// --- process-group helpers (Linux) ---
// Ставим процесс в собственную process-group, чтобы killGroup мог убить и
// дочерние процессы (иначе они держат унаследованные stdout-fd и <-done виснет).
func setpgid(proc *exec.Cmd) {
sysProcAttr(proc)
}
func killGroup(proc *exec.Cmd) {
if proc.Process != nil {
killProcGroup(proc.Process.Pid)
}
_ = proc.Process.Kill()
}