diff --git a/config.yaml.example b/config.yaml.example
index 35a353e..424d1a2 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -12,7 +12,7 @@ telegram:
# config: "/путь/к/opencode.json" # файл-конфиг модели (OPENCODE_CONFIG)
# config_dir: "./agents" # каталог с агентами (OPENCODE_CONFIG_DIR)
# hard_timeout: "20m"
-# idle_timeout: "2m"
+# idle_timeout: "5m"
# git (источник репозиториев для git clone)
# base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования)
diff --git a/internal/agents/agents.go b/internal/agents/agents.go
index 0d544d4..fc1a961 100644
--- a/internal/agents/agents.go
+++ b/internal/agents/agents.go
@@ -3,9 +3,16 @@
// Агенты зашиты в бинарь через go:embed и распаковываются при запуске
// в каталог OPENCODE_CONFIG_DIR (по умолчанию ./agents рядом с бинарём),
// чтобы opencode находил их независимо от того, где запущен ratatoskr.
+//
+// opencode сканирует OPENCODE_CONFIG_DIR как каталог .opencode: агенты
+// ищутся в поддиректории agents/ (мн.ч.), а не в корне. Поэтому распаковка
+// идёт в
/agents/*.md.
package agents
-import "embed"
+import (
+ "embed"
+ "path/filepath"
+)
//go:embed *.md
var FS embed.FS
@@ -16,15 +23,16 @@ var FS embed.FS
// в FS, но для детерминированной распаковки список лучше дополнять здесь.
var Names = []string{"analyst", "dev", "reviewer"}
-// WriteTo распаковывает всех встроенных агентов в каталог dir (создаёт его).
-// Файлы перезаписываются — встроенная копия всегда актуальна.
+// WriteTo распаковывает всех встроенных агентов в каталог dir/agents
+// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.
func WriteTo(dir string) error {
+ agentsDir := filepath.Join(dir, "agents")
for _, name := range Names {
data, err := FS.ReadFile(name + ".md")
if err != nil {
return err
}
- if err := writeFile(dir, name+".md", data); err != nil {
+ if err := writeFile(agentsDir, name+".md", data); err != nil {
return err
}
}
diff --git a/internal/agents/agents_test.go b/internal/agents/agents_test.go
index 743a1f7..f91da13 100644
--- a/internal/agents/agents_test.go
+++ b/internal/agents/agents_test.go
@@ -13,7 +13,8 @@ func TestWriteTo(t *testing.T) {
t.Fatalf("WriteTo: %v", err)
}
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)
if err != nil {
t.Fatalf("read %s: %v", p, err)
diff --git a/internal/agents/analyst.md b/internal/agents/analyst.md
index 707af52..1ba69da 100644
--- a/internal/agents/analyst.md
+++ b/internal/agents/analyst.md
@@ -1,6 +1,7 @@
---
name: analyst
description: Аналитик Ratatoskr — помогает сформулировать задачу разработки и возвращает структурированное решение в JSON
+mode: primary
---
Ты — аналитик в конвейере Ratatoskr. Пользователь ставит задачу разработки. Ты помогаешь
diff --git a/internal/agents/dev.md b/internal/agents/dev.md
index 09ef01c..f5fa91f 100644
--- a/internal/agents/dev.md
+++ b/internal/agents/dev.md
@@ -1,6 +1,7 @@
---
name: dev
description: Разработчик Ratatoskr — реализует задачу в репозитории по готовым критериям
+mode: primary
---
Ты — dev-агент в конвейере Ratatoskr. Получаешь готовое задание и реализуешь его в коде.
diff --git a/internal/agents/reviewer.md b/internal/agents/reviewer.md
index 7019740..0b8cf07 100644
--- a/internal/agents/reviewer.md
+++ b/internal/agents/reviewer.md
@@ -1,6 +1,7 @@
---
name: reviewer
description: Ревьюер Ratatoskr — строго проверяет ветку работы dev-агента (качество, безопасность, SOLID) и возвращает вердикт в JSON
+mode: primary
---
Ты — ревьюер в конвейере Ratatoskr. Проверяешь работу dev-агента в feature-ветке **жёстко и придирчиво**. Твоя цель — не дать плохому коду попасть в основную ветку.
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 7447014..a1021fe 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -73,7 +73,7 @@ telegram:
if cfg.OpenCode.HardTimeout.Duration() != 20*time.Minute {
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)
}
if cfg.Paths.Worktree != "./worktrees" {
diff --git a/internal/config/types.go b/internal/config/types.go
index d839c36..d13c0fb 100644
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -76,7 +76,7 @@ type OpenCodeCfg struct {
Config string `yaml:"config" env:"OPENCODE_CONFIG"`
ConfigDir string `yaml:"config_dir" env:"OPENCODE_CONFIG_DIR"`
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"`
}
diff --git a/internal/opencode/runner.go b/internal/opencode/runner.go
index c41a104..db8eac6 100644
--- a/internal/opencode/runner.go
+++ b/internal/opencode/runner.go
@@ -10,6 +10,7 @@ import (
"os/exec"
"strings"
"sync"
+ "sync/atomic"
"time"
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
@@ -30,7 +31,7 @@ type Runner struct {
DBPath string // путь к opencode.db (idle-детекция активности)
Config string // путь к opencode.json (OPENCODE_CONFIG)
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
- IdleTimeout time.Duration // нет новых сообщений в БД → зависание
+ IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
HardTimeout time.Duration // общий лимит на запуск
PollInterval time.Duration
@@ -43,7 +44,7 @@ func (r *Runner) defaults() {
r.Bin = "opencode"
}
if r.IdleTimeout == 0 {
- r.IdleTimeout = 2 * time.Minute
+ r.IdleTimeout = 5 * time.Minute
}
if r.HardTimeout == 0 {
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 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 {
@@ -157,8 +163,10 @@ func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string)
mu.Lock()
buf = append(buf, line)
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)
}
}
@@ -186,6 +194,12 @@ pollLoop:
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
diff --git a/internal/opencode/runner_test.go b/internal/opencode/runner_test.go
index 279dccb..5a4b488 100644
--- a/internal/opencode/runner_test.go
+++ b/internal/opencode/runner_test.go
@@ -27,6 +27,17 @@ case "$mode" in
slow)
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)
echo '{"type":"text","part":{"text":"boom"}}'
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) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)