From 9c5f38698cff3b95a931ba37108882ed0d29e527 Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 17 Aug 2026 10:57:19 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20idle-=D1=82=D0=B0=D0=B9=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=20=D1=81=D0=B1=D1=80=D0=B0=D1=81=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20live-=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D1=83=20LLM=20+=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB?= =?UTF-8?q?=D1=82=205=D0=BC;=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=8B=20open?= =?UTF-8?q?code=20=D0=B2=20agents/=20=D1=81=20mode:=20primary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - opencode ищет кастомных агентов в поддиректории agents/ (как .opencode), а не в корне OPENCODE_CONFIG_DIR → распаковка теперь в /agents/*.md - markdown-агентам добавлен mode: primary (иначе subagent по умолчанию) - idle-детекция сбрасывает таймер при live-строках (text/tool/agent/reasoning), а не только по росту БД → «LLM думает и стримит» не считается зависанием - дефолт idle_timeout поднят с 2м до 5м --- config.yaml.example | 2 +- internal/agents/agents.go | 16 ++++++++++---- internal/agents/agents_test.go | 3 ++- internal/agents/analyst.md | 1 + internal/agents/dev.md | 1 + internal/agents/reviewer.md | 1 + internal/config/config_test.go | 2 +- internal/config/types.go | 2 +- internal/opencode/runner.go | 22 +++++++++++++++---- internal/opencode/runner_test.go | 37 ++++++++++++++++++++++++++++++++ 10 files changed, 75 insertions(+), 12 deletions(-) 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)