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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user