refactor: чистка мёртвого кода, лимит ходов D3, HTML-экранирование и UTF-8 обрезка в Telegram
Some checks failed
CI / test (pull_request) Failing after 34s
CI / build-and-package (amd64, linux) (pull_request) Successful in 34s
CI / build-and-package (amd64, windows) (pull_request) Successful in 36s

This commit is contained in:
ki.sagidullin
2026-08-18 23:13:04 +05:00
parent 7eb3a0292c
commit 1459670ce9
13 changed files with 138 additions and 190 deletions

View File

@@ -4,7 +4,6 @@
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
// - ExtractJSON: fenced ```json``` → первый {...}
// - Run/ResumeDev: запуск процесса с idle/hard timeout по opencode.db
package opencode
import (
@@ -14,9 +13,8 @@ import (
)
var (
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`)
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
)
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
@@ -76,15 +74,6 @@ func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
return nil, false
}
// SessionIDFromOutput извлекает session_id из текстового вывода opencode.
func SessionIDFromOutput(out string) (string, bool) {
m := sessionRe.FindStringSubmatch(out)
if len(m) > 1 && m[1] != "" {
return m[1], true
}
return "", false
}
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
func stripFence(s string) string {

View File

@@ -104,12 +104,3 @@ func TestExtractJSON(t *testing.T) {
})
}
}
func TestSessionIDFromOutput(t *testing.T) {
if s, ok := SessionIDFromOutput(`{"session_id":"abc123"}`); !ok || s != "abc123" {
t.Fatalf("got %q %v", s, ok)
}
if _, ok := SessionIDFromOutput("no session here"); ok {
t.Fatal("expected no match")
}
}

View File

@@ -2,36 +2,11 @@ package opencode
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
)
// parseLiveStep пытается распарсить одну NDJSON-строку stdout opencode как
// событие (text/tool/agent). Возвращает nil, если строка не является событием.
func parseLiveStep(line string) *LiveStep {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "{") {
return nil
}
var obj struct {
Type string `json:"type"`
Part struct {
Text string `json:"text"`
} `json:"part"`
}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
return nil
}
if obj.Type == "" {
return nil
}
return &LiveStep{Type: obj.Type, Text: obj.Part.Text, At: time.Now()}
}
// LiveStep — один наблюдаемый шаг агента из NDJSON-потока opencode run.
// Собирается из live-строк stdout, не из БД.
// LiveStep — один наблюдаемый шаг агента.
type LiveStep struct {
Type string // "text" | "tool" | "agent" | ...
Text string // содержимое text-парта (для других типов может быть пустым)

View File

@@ -1,23 +0,0 @@
//go:build linux
package opencode
import (
"os/exec"
"syscall"
)
func sysProcAttr(proc *exec.Cmd) {
proc.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// killProcGroup убивает всю process-group по лидеру pid (SIGKILL дочерним и
// SIGTERM лидеру). Игнорирует ошибки: weakest-effort teardown.
func killProcGroup(pid int) {
pgid, err := syscall.Getpgid(pid)
if err != nil {
return
}
_ = syscall.Kill(-pgid, syscall.SIGKILL)
_ = syscall.Kill(pid, syscall.SIGKILL)
}

View File

@@ -1,9 +0,0 @@
//go:build !linux
package opencode
import "os/exec"
func sysProcAttr(_ *exec.Cmd) {}
func killProcGroup(pid int) {}

View File

@@ -164,22 +164,4 @@ func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt
case <-ctx.Done():
}
}
}
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
// падает с rc!=0 — повторяем ОДИН раз свежей сессией в том же каталоге.
// rc=-1 (обрыв по таймауту) НЕ триггерит 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 {
return res, false
}
if res.RC != 0 && res.RC != -1 && sessionID != "" {
r.logf("dev resume rc=%d — запускаю заново свежей сессией (каталог сохраняю)", res.RC)
res, _ = r.Run(ctx, prompt+resumeFallbackNote, cwd, "dev", "")
}
return res, res.RC == -1
}
const resumeFallbackNote = "\n\n(Возобновление сессии не удалось; продолжи с учётом уже сделанных изменений в worktree.)"
}

View File

@@ -93,26 +93,6 @@ func TestRun_ContextCancel(t *testing.T) {
}
}
// TestResumeDev_Fallback: resume (sessionID) "падает" rc!=0 только когда самого
// сервера нет; в фейке такого нет, поэтому проверяем, что при успехе
// fallback не срабатывает и таймаут не выставляется.
func TestResumeDev_NoFallbackOnSuccess(t *testing.T) {
dir := t.TempDir()
f := &fakeAPIServer{
verdictParts: []part{{Type: "text", Text: "ok"}},
}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session")
if timedOut {
t.Error("timedOut = true, want false (успех не должен считаться таймаутом)")
}
if res.RC != 0 {
t.Errorf("RC = %d, want 0", res.RC)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
}