Merge pull request 'refactor: чистка мёртвого кода, лимит ходов D3, HTML-экранирование и UTF-8 обрезка в Telegram' (#4) from feat/83bf5b6fbb8b238c into main
Some checks failed
CI / test (push) Failing after 31s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Successful in 35s

Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
2026-08-18 23:14:50 +05:00
13 changed files with 138 additions and 190 deletions

View File

@@ -24,7 +24,6 @@ type Router struct {
// long-poll цикл канала (Telegram) не блокируется на время долгого
// вызова аналитика и продолжает принимать новые сообщения.
incoming chan Incoming
wg sync.WaitGroup
}
// NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего.
@@ -47,7 +46,6 @@ func NewRouter(onUserMsg func(Incoming)) *Router {
func (r *Router) processLoop() {
for inc := range r.incoming {
r.onUserMsg(inc)
r.wg.Done()
}
}
@@ -80,7 +78,6 @@ func (r *Router) handleIncoming(inc Incoming) {
// Асинхронная обработка: кладём событие в очередь воркера и сразу
// возвращаемся, не блокируя вызывающий long-poll цикл канала.
r.wg.Add(1)
r.incoming <- inc
}

View File

@@ -16,6 +16,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/kamelion/ratatoskr-go/internal/chat"
)
@@ -109,7 +110,7 @@ func (ch *Channel) handleUpdate(ctx context.Context, upd update) {
func (ch *Channel) sendMsg(chatID, text string) error {
body, _ := json.Marshal(map[string]string{
"chat_id": chatID,
"text": text[:min(len(text), 4000)],
"text": truncateUTF8(text, 4000),
"parse_mode": "HTML",
})
url := fmt.Sprintf(ch.apiURL+"sendMessage", ch.token)
@@ -155,10 +156,10 @@ func (ch *Channel) getUpdates(ctx context.Context, offset int64, timeout int) ([
// formatOutgoing собирает Message в HTML-строку: текст + нумерованные Options.
func formatOutgoing(m chat.Message) string {
if len(m.Options) == 0 {
return m.Text
return escapeHTML(m.Text)
}
var buf bytes.Buffer
buf.WriteString(m.Text)
buf.WriteString(escapeHTML(m.Text))
buf.WriteString("\n\n")
for i, opt := range m.Options {
buf.WriteString(fmt.Sprintf("<b>%d.</b> %s\n", i+1, escapeHTML(opt.Label)))
@@ -183,6 +184,18 @@ func escapeHTML(s string) string {
return buf.String()
}
// truncateUTF8 обрезает s до max байт, не разрывая UTF-8 последовательности.
func truncateUTF8(s string, max int) string {
if len(s) <= max {
return s
}
s = s[:max]
for len(s) > 0 && !utf8.ValidString(s) {
s = s[:len(s)-1]
}
return s
}
// ---- Telegram API types ----
type tgResponse struct {

View File

@@ -9,6 +9,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/kamelion/ratatoskr-go/internal/chat"
)
@@ -111,6 +112,36 @@ func TestSendWithOptions(t *testing.T) {
}
}
func TestFormatOutgoingEscapesText(t *testing.T) {
if got := formatOutgoing(chat.Message{Text: "2 < 3 & 4 > 1"}); got != "2 &lt; 3 &amp; 4 &gt; 1" {
t.Errorf("text escape = %q", got)
}
got := formatOutgoing(chat.Message{
Text: "a<b",
Options: []chat.Option{{ID: "x", Label: "l&l"}},
})
if !strings.Contains(got, "a&lt;b") || !strings.Contains(got, "l&amp;l") {
t.Errorf("options escape = %q", got)
}
}
func TestTruncateUTF8(t *testing.T) {
long := strings.Repeat("я", 5000)
tr := truncateUTF8(long, 4000)
if len(tr) != 4000 {
t.Fatalf("len = %d, want 4000", len(tr))
}
if !utf8.ValidString(tr) {
t.Fatal("truncated string is not valid UTF-8")
}
if got := truncateUTF8("привет", 4000); got != "привет" {
t.Fatalf("short text changed: %q", got)
}
if got := truncateUTF8("", 4000); got != "" {
t.Fatalf("empty text changed: %q", got)
}
}
func TestIncoming(t *testing.T) {
f := newFakeTG(t)
ch := New("TOKEN", time.Second)

View File

@@ -66,7 +66,6 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
if task.Status == storage.StatusApproved {
return Result{
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -106,7 +105,6 @@ func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Re
default:
return Result{
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
Action: "send",
TaskID: taskID,
}, nil
}
@@ -128,7 +126,6 @@ func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
}
return Result{
Reply: greeting,
Action: "greeting",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -146,7 +143,6 @@ func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
}
return Result{
Reply: "🚫 Отменил.",
Action: "drop",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -161,7 +157,6 @@ func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
if task.Status == storage.StatusReady {
return Result{
Reply: "Напишите «создавай» — или правьте текст.",
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -192,7 +187,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
if !ok {
return Result{
Reply: "Укажите номер задачи: `/retry 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -204,7 +198,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
if storage.IsTerminal(task.Status) {
return Result{
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -218,7 +211,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
}
return Result{
Reply: "Задача перезапущена. Опишите, что меняем:",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -230,7 +222,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
if !ok {
return Result{
Reply: "Укажите номер задачи: `/status 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -246,7 +237,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
}
return Result{
Reply: reply,
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -258,7 +248,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/continue 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -268,7 +257,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
return Result{
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
". Резюм сессии — пока не реализован.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -278,7 +266,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
return Result{
Reply: "Задача #" + itoa(id) + " не найдена.",
Action: "send",
}, nil
}
@@ -291,7 +278,6 @@ func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, e
}
return Result{
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
Action: "created:" + itoa(task.ID),
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -333,6 +319,23 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
if err != nil {
return Result{}, err
}
if !force && c.MaxTurns > 0 {
userTurns := 0
for _, h := range history {
if h.Role == "user" {
userTurns++
}
}
if userTurns > c.MaxTurns {
return Result{
Reply: "Превышен лимит ходов сбора (" + itoa(int64(c.MaxTurns)) + "). Используйте /skip чтобы сформулировать черновик, или /start для новой задачи.",
TaskID: task.ID,
Status: task.Status,
}, nil
}
}
msgs := make([]Message, 0, len(history))
for _, h := range history {
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
@@ -353,7 +356,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
if reply == "" {
reply = "Недостаточно данных. Начните заново (/start)."
}
return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
case "propose", "ready":
// применяем черновик (для ready — текущий, без изменений)
@@ -365,7 +368,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
return Result{}, err
}
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
task.Status = storage.StatusReady
if err := c.Store.UpdateTask(ctx, task); err != nil {
@@ -373,7 +376,6 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
}
return Result{
Reply: formatSummary(*task),
Action: "summary",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -385,7 +387,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
return Result{}, err
}
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
}

View File

@@ -2,6 +2,7 @@ package core
import (
"context"
"strings"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
@@ -44,13 +45,11 @@ func TestStartCreatesCollecting(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/start")
_, err := c.ProcessTurn(ctx, id, "/start")
if err != nil {
t.Fatalf("ProcessTurn /start: %v", err)
}
if res.Action != "greeting" {
t.Fatalf("action = %q, want greeting", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCollecting {
t.Fatalf("status = %s, want collecting", task.Status)
@@ -61,13 +60,11 @@ func TestCancelSetsCancelled(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/cancel")
_, err := c.ProcessTurn(ctx, id, "/cancel")
if err != nil {
t.Fatalf("ProcessTurn /cancel: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCancelled {
t.Fatalf("status = %s, want cancelled", task.Status)
@@ -84,13 +81,11 @@ func TestSingleTurnPropose(t *testing.T) {
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
_, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusReady {
t.Fatalf("status = %s, want ready", task.Status)
@@ -114,9 +109,7 @@ func TestAskReturnsQuestions(t *testing.T) {
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
t.Fatalf("reply = %q", res.Reply)
}
@@ -133,13 +126,11 @@ func TestAbortReturnsDrop(t *testing.T) {
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "привет")
_, err := c.ProcessTurn(ctx, id, "привет")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusAborted {
t.Fatalf("status = %s, want aborted", task.Status)
@@ -153,13 +144,11 @@ func TestConsentInReady(t *testing.T) {
id := mkTask(t, store, ctx, "u1")
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
res, err := c.ProcessTurn(ctx, id, "создавай")
_, err := c.ProcessTurn(ctx, id, "создавай")
if err != nil {
t.Fatalf("ProcessTurn создавай: %v", err)
}
if res.Action != "created:"+itoa(id) {
t.Fatalf("action = %q, want created:%d", res.Action, id)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusApproved {
t.Fatalf("status после создавай = %s, want approved", task.Status)
@@ -176,13 +165,11 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
_, _ = c.ProcessTurn(ctx, id, "сделай X")
// в ready пишем правку, не согласие
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
_, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
if err != nil {
t.Fatalf("ProcessTurn edit: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
@@ -194,25 +181,59 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
func TestRetryNotFound(t *testing.T) {
c, ctx, _ := setupCore(t, nil)
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
_, err := c.ProcessTurn(ctx, 999, "/retry 999")
if err != nil {
t.Fatalf("ProcessTurn /retry: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
}
func TestUnknownCommand(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/bogus")
_, err := c.ProcessTurn(ctx, id, "/bogus")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
func TestMaxTurnsBlocksExcessCollection(t *testing.T) {
var calls int
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
calls++
return Decision{Phase: "ask", ChatReply: "Ещё вопрос"}, nil
})
c.MaxTurns = 2
id := mkTask(t, store, ctx, "u1")
_, err := c.ProcessTurn(ctx, id, "первый факт")
if err != nil {
t.Fatalf("1-й ход: %v", err)
}
_, err = c.ProcessTurn(ctx, id, "второй факт")
if err != nil {
t.Fatalf("2-й ход: %v", err)
}
res, err := c.ProcessTurn(ctx, id, "третий факт")
if err != nil {
t.Fatalf("3-й ход: %v", err)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
if !strings.Contains(res.Reply, "лимит") {
t.Fatalf("reply = %q, want упоминание лимита", res.Reply)
}
// /skip — принудительный вызов, лимит не мешает
_, err = c.ProcessTurn(ctx, id, "/skip")
if err != nil {
t.Fatalf("/skip: %v", err)
}
if calls != 3 {
t.Fatalf("decide calls after /skip = %d, want 3", calls)
}
}

View File

@@ -29,7 +29,6 @@ type Decider interface {
// Result — результат одного хода.
type Result struct {
Reply string
Action string // send | summary | created:N | abort | drop | greeting
TaskID int64
Status storage.Status
}

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 (
@@ -16,7 +15,6 @@ import (
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.
@@ -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

@@ -165,21 +165,3 @@ func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt
}
}
}
// 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)
}