internal/opencode: runner (idle/hard timeout, process-group kill, resume-fallback) + verdict/json parsing
All checks were successful
build-test / build (push) Successful in 1m21s

This commit is contained in:
Hermes
2026-08-14 13:29:05 +05:00
parent 53cf901707
commit 4ebafc48d0
8 changed files with 681 additions and 1 deletions

View File

@@ -0,0 +1,86 @@
// Package opencode — запуск opencode-субагентов и разбор их вердиктов.
//
// Единственный компонент, умеющий запускать opencode run и парсить вывод.
// Контракты перенесены 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 (
"encoding/json"
"regexp"
"strings"
)
var (
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`)
)
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
// Поток — NDJSON: последний парт {"type":"text","part":{"text":...}} и есть вердикт.
// Если JSON-партов нет (например, простой текст) — возвращает исходную строку.
func ExtractVerdict(out string) string {
out = strings.TrimSpace(out)
if out == "" {
return ""
}
var last string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "{") {
continue
}
var obj struct {
Type string `json:"type"`
Part struct {
Text string `json:"text"`
} `json:"part"`
}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
continue
}
if obj.Type == "text" && obj.Part.Text != "" {
last = obj.Part.Text
}
}
if last != "" {
return strings.TrimSpace(last)
}
return out
}
// ExtractJSON извлекает JSON из ответа модели: сначала fenced ```json```,
// затем первый {...}. Возвращает (nil, false), если JSON нет или невалиден.
// Это НЕ ошибка — вызывающий решает, как деградировать (класс O3 ErrParse).
func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
if strings.TrimSpace(text) == "" {
return nil, false
}
for _, f := range fenceRe.FindAllStringSubmatch(text, -1) {
if len(f) > 1 {
var obj map[string]json.RawMessage
if err := json.Unmarshal([]byte(strings.TrimSpace(f[1])), &obj); err == nil && obj != nil {
return obj, true
}
}
}
if m := jsonBlockRe.FindString(text); m != "" {
var obj map[string]json.RawMessage
if err := json.Unmarshal([]byte(m), &obj); err == nil && obj != nil {
return obj, true
}
}
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
}