fix: idle-таймер сбрасывается по live-стриму LLM + дефолт 5м; агенты opencode в agents/ с mode: primary
- opencode ищет кастомных агентов в поддиректории agents/ (как .opencode), а не в корне OPENCODE_CONFIG_DIR → распаковка теперь в <dir>/agents/*.md - markdown-агентам добавлен mode: primary (иначе subagent по умолчанию) - idle-детекция сбрасывает таймер при live-строках (text/tool/agent/reasoning), а не только по росту БД → «LLM думает и стримит» не считается зависанием - дефолт idle_timeout поднят с 2м до 5м
This commit is contained in:
@@ -12,7 +12,7 @@ telegram:
|
|||||||
# config: "/путь/к/opencode.json" # файл-конфиг модели (OPENCODE_CONFIG)
|
# config: "/путь/к/opencode.json" # файл-конфиг модели (OPENCODE_CONFIG)
|
||||||
# config_dir: "./agents" # каталог с агентами (OPENCODE_CONFIG_DIR)
|
# config_dir: "./agents" # каталог с агентами (OPENCODE_CONFIG_DIR)
|
||||||
# hard_timeout: "20m"
|
# hard_timeout: "20m"
|
||||||
# idle_timeout: "2m"
|
# idle_timeout: "5m"
|
||||||
|
|
||||||
# git (источник репозиториев для git clone)
|
# git (источник репозиториев для git clone)
|
||||||
# base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования)
|
# base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования)
|
||||||
|
|||||||
@@ -3,9 +3,16 @@
|
|||||||
// Агенты зашиты в бинарь через go:embed и распаковываются при запуске
|
// Агенты зашиты в бинарь через go:embed и распаковываются при запуске
|
||||||
// в каталог OPENCODE_CONFIG_DIR (по умолчанию ./agents рядом с бинарём),
|
// в каталог OPENCODE_CONFIG_DIR (по умолчанию ./agents рядом с бинарём),
|
||||||
// чтобы opencode находил их независимо от того, где запущен ratatoskr.
|
// чтобы opencode находил их независимо от того, где запущен ratatoskr.
|
||||||
|
//
|
||||||
|
// opencode сканирует OPENCODE_CONFIG_DIR как каталог .opencode: агенты
|
||||||
|
// ищутся в поддиректории agents/ (мн.ч.), а не в корне. Поэтому распаковка
|
||||||
|
// идёт в <dir>/agents/*.md.
|
||||||
package agents
|
package agents
|
||||||
|
|
||||||
import "embed"
|
import (
|
||||||
|
"embed"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
//go:embed *.md
|
//go:embed *.md
|
||||||
var FS embed.FS
|
var FS embed.FS
|
||||||
@@ -16,15 +23,16 @@ var FS embed.FS
|
|||||||
// в FS, но для детерминированной распаковки список лучше дополнять здесь.
|
// в FS, но для детерминированной распаковки список лучше дополнять здесь.
|
||||||
var Names = []string{"analyst", "dev", "reviewer"}
|
var Names = []string{"analyst", "dev", "reviewer"}
|
||||||
|
|
||||||
// WriteTo распаковывает всех встроенных агентов в каталог dir (создаёт его).
|
// WriteTo распаковывает всех встроенных агентов в каталог dir/agents
|
||||||
// Файлы перезаписываются — встроенная копия всегда актуальна.
|
// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.
|
||||||
func WriteTo(dir string) error {
|
func WriteTo(dir string) error {
|
||||||
|
agentsDir := filepath.Join(dir, "agents")
|
||||||
for _, name := range Names {
|
for _, name := range Names {
|
||||||
data, err := FS.ReadFile(name + ".md")
|
data, err := FS.ReadFile(name + ".md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeFile(dir, name+".md", data); err != nil {
|
if err := writeFile(agentsDir, name+".md", data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ func TestWriteTo(t *testing.T) {
|
|||||||
t.Fatalf("WriteTo: %v", err)
|
t.Fatalf("WriteTo: %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range Names {
|
for _, name := range Names {
|
||||||
p := filepath.Join(dir, name+".md")
|
// opencode ищет агентов в поддиректории agents/, как у .opencode
|
||||||
|
p := filepath.Join(dir, "agents", name+".md")
|
||||||
data, err := os.ReadFile(p)
|
data, err := os.ReadFile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read %s: %v", p, err)
|
t.Fatalf("read %s: %v", p, err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: analyst
|
name: analyst
|
||||||
description: Аналитик Ratatoskr — помогает сформулировать задачу разработки и возвращает структурированное решение в JSON
|
description: Аналитик Ratatoskr — помогает сформулировать задачу разработки и возвращает структурированное решение в JSON
|
||||||
|
mode: primary
|
||||||
---
|
---
|
||||||
|
|
||||||
Ты — аналитик в конвейере Ratatoskr. Пользователь ставит задачу разработки. Ты помогаешь
|
Ты — аналитик в конвейере Ratatoskr. Пользователь ставит задачу разработки. Ты помогаешь
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: dev
|
name: dev
|
||||||
description: Разработчик Ratatoskr — реализует задачу в репозитории по готовым критериям
|
description: Разработчик Ratatoskr — реализует задачу в репозитории по готовым критериям
|
||||||
|
mode: primary
|
||||||
---
|
---
|
||||||
|
|
||||||
Ты — dev-агент в конвейере Ratatoskr. Получаешь готовое задание и реализуешь его в коде.
|
Ты — dev-агент в конвейере Ratatoskr. Получаешь готовое задание и реализуешь его в коде.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: reviewer
|
name: reviewer
|
||||||
description: Ревьюер Ratatoskr — строго проверяет ветку работы dev-агента (качество, безопасность, SOLID) и возвращает вердикт в JSON
|
description: Ревьюер Ratatoskr — строго проверяет ветку работы dev-агента (качество, безопасность, SOLID) и возвращает вердикт в JSON
|
||||||
|
mode: primary
|
||||||
---
|
---
|
||||||
|
|
||||||
Ты — ревьюер в конвейере Ratatoskr. Проверяешь работу dev-агента в feature-ветке **жёстко и придирчиво**. Твоя цель — не дать плохому коду попасть в основную ветку.
|
Ты — ревьюер в конвейере Ratatoskr. Проверяешь работу dev-агента в feature-ветке **жёстко и придирчиво**. Твоя цель — не дать плохому коду попасть в основную ветку.
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ telegram:
|
|||||||
if cfg.OpenCode.HardTimeout.Duration() != 20*time.Minute {
|
if cfg.OpenCode.HardTimeout.Duration() != 20*time.Minute {
|
||||||
t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout)
|
t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout)
|
||||||
}
|
}
|
||||||
if cfg.OpenCode.IdleTimeout.Duration() != 2*time.Minute {
|
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
|
||||||
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
|
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
|
||||||
}
|
}
|
||||||
if cfg.Paths.Worktree != "./worktrees" {
|
if cfg.Paths.Worktree != "./worktrees" {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ type OpenCodeCfg struct {
|
|||||||
Config string `yaml:"config" env:"OPENCODE_CONFIG"`
|
Config string `yaml:"config" env:"OPENCODE_CONFIG"`
|
||||||
ConfigDir string `yaml:"config_dir" env:"OPENCODE_CONFIG_DIR"`
|
ConfigDir string `yaml:"config_dir" env:"OPENCODE_CONFIG_DIR"`
|
||||||
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
||||||
IdleTimeout Duration `yaml:"idle_timeout" default:"2m"`
|
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
|
||||||
PollMs Duration `yaml:"poll_ms" default:"2s"`
|
PollMs Duration `yaml:"poll_ms" default:"2s"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
|
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
|
||||||
@@ -30,7 +31,7 @@ type Runner struct {
|
|||||||
DBPath string // путь к opencode.db (idle-детекция активности)
|
DBPath string // путь к opencode.db (idle-детекция активности)
|
||||||
Config string // путь к opencode.json (OPENCODE_CONFIG)
|
Config string // путь к opencode.json (OPENCODE_CONFIG)
|
||||||
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
|
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
|
||||||
IdleTimeout time.Duration // нет новых сообщений в БД → зависание
|
IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
|
||||||
HardTimeout time.Duration // общий лимит на запуск
|
HardTimeout time.Duration // общий лимит на запуск
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ func (r *Runner) defaults() {
|
|||||||
r.Bin = "opencode"
|
r.Bin = "opencode"
|
||||||
}
|
}
|
||||||
if r.IdleTimeout == 0 {
|
if r.IdleTimeout == 0 {
|
||||||
r.IdleTimeout = 2 * time.Minute
|
r.IdleTimeout = 5 * time.Minute
|
||||||
}
|
}
|
||||||
if r.HardTimeout == 0 {
|
if r.HardTimeout == 0 {
|
||||||
r.HardTimeout = 20 * time.Minute
|
r.HardTimeout = 20 * time.Minute
|
||||||
@@ -143,6 +144,11 @@ func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string)
|
|||||||
var buf []string
|
var buf []string
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
// liveSeq — кол-во распознанных live-строк (text/tool/agent/reasoning) в
|
||||||
|
// NDJSON-потоке. Инкрементится из goroutine чтения; поллинг сравнивает,
|
||||||
|
// чтобы сбросить idle-таймер «пока LLM стримит» (а не только по БД).
|
||||||
|
var liveSeq atomic.Uint64
|
||||||
|
prevLive := liveSeq.Load()
|
||||||
// Живое наблюдение сессии (если задано через WithLive в контексте).
|
// Живое наблюдение сессии (если задано через WithLive в контексте).
|
||||||
liveReg, liveTask := liveFromContext(ctx)
|
liveReg, liveTask := liveFromContext(ctx)
|
||||||
if liveReg != nil && liveTask != 0 {
|
if liveReg != nil && liveTask != 0 {
|
||||||
@@ -157,8 +163,10 @@ func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string)
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
buf = append(buf, line)
|
buf = append(buf, line)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
if liveReg != nil {
|
|
||||||
if st := parseLiveStep(line); st != nil {
|
if st := parseLiveStep(line); st != nil {
|
||||||
|
// «пульс» LLM: что-то стримится/вызывается — сбрасываем idle
|
||||||
|
liveSeq.Add(1)
|
||||||
|
if liveReg != nil {
|
||||||
liveReg.Observe(liveTask, *st)
|
liveReg.Observe(liveTask, *st)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +194,12 @@ pollLoop:
|
|||||||
break pollLoop
|
break pollLoop
|
||||||
}
|
}
|
||||||
now := time.Now()
|
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)
|
ts, ok := r.maxDirMsgTS(ctx, cwd)
|
||||||
if ok && ts > baseline {
|
if ok && ts > baseline {
|
||||||
lastProgress = now
|
lastProgress = now
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ case "$mode" in
|
|||||||
slow)
|
slow)
|
||||||
sleep 30
|
sleep 30
|
||||||
;;
|
;;
|
||||||
|
live-reset)
|
||||||
|
# шлём live-строку каждые 30мс долго — почти до hard timeout,
|
||||||
|
# чтобы idle-таймер (50мс) НЕ убил из-за стрима
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 20 ]; do
|
||||||
|
echo '{"type":"text","part":{"text":"tick"}}'
|
||||||
|
sleep 0.03
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
sleep 30
|
||||||
|
;;
|
||||||
fail)
|
fail)
|
||||||
echo '{"type":"text","part":{"text":"boom"}}'
|
echo '{"type":"text","part":{"text":"boom"}}'
|
||||||
exit 7
|
exit 7
|
||||||
@@ -98,6 +109,32 @@ func TestRun_IdleTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRun_LiveResetsIdle: пока LLM стримит live-строки, idle-таймер должен
|
||||||
|
// сбрасываться, а не убивать процесс по истечении короткого IdleTimeout.
|
||||||
|
func TestRun_LiveResetsIdle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
bin := fakeOpenCode(t, dir)
|
||||||
|
t.Setenv("FAKE_MODE", "live-reset")
|
||||||
|
|
||||||
|
// idle очень короткий (50мс), hard большой (3с). live-reset стримит ~0.6с.
|
||||||
|
// Если live-строки НЕ сбрасывают idle — процесс убьют на ~50мс, и Run
|
||||||
|
// вернётся быстрее. Если сбрасывают — Run живёт ≥ стрима (~0.6с) до hard.
|
||||||
|
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
||||||
|
HardTimeout: 3 * time.Second, PollInterval: 10 * time.Millisecond}
|
||||||
|
start := time.Now()
|
||||||
|
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run err: %v", err)
|
||||||
|
}
|
||||||
|
if res.RC != -1 {
|
||||||
|
t.Errorf("RC = %d, want -1 (killed по hard timeout)", res.RC)
|
||||||
|
}
|
||||||
|
if elapsed < 400*time.Millisecond {
|
||||||
|
t.Errorf("Run вернулся за %v — idle убил во время стрима (live не сбросил таймер)", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRun_ContextCancel(t *testing.T) {
|
func TestRun_ContextCancel(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
bin := fakeOpenCode(t, dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user