LiveRegistry собирает live-шаги из stdout процесса opencode по taskID (через контекст, без изменения интерфейса Runner). /status N для running-задачи прикладывает живое состояние: активный субагент, давность последнего шага, последние text-шаги агента. Ничего не перезапускается и не убивается — только наблюдение по запросу.
269 lines
8.1 KiB
Go
269 lines
8.1 KiB
Go
package opencode
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
"sync"
|
||
"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 // нет новых сообщений в БД → зависание
|
||
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 = 2 * 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{})
|
||
// Живое наблюдение сессии (если задано через 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)
|
||
for sc.Scan() {
|
||
line := sc.Text()
|
||
mu.Lock()
|
||
buf = append(buf, line)
|
||
mu.Unlock()
|
||
if liveReg != nil {
|
||
if st := parseLiveStep(line); st != nil {
|
||
liveReg.Observe(liveTask, *st)
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
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()
|
||
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()
|
||
|
||
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()
|
||
}
|