Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ docs\hive-mind
PLAN_FIX.md
docs\compose\plans
docs\promts
DAY_PROBLEMS_AND_SOLUTION.md
DAY_PROBLEMS_AND_SOLUTION.md
ci-logs/
3 changes: 3 additions & 0 deletions orchestrator/cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"

"orchestrator/internal/fetcher/grpc"
"orchestrator/internal/memory"
"orchestrator/internal/redis"
"orchestrator/internal/service/agents"
ctxsvc "orchestrator/internal/service/context"
Expand All @@ -19,6 +20,8 @@ import (
// через gRPC; теперь все три роли живут в одном процессе и зовут друг друга
// напрямую через Go.
func main() {
memory.Configure()

database.InitDb()

agentsClient, err := agents.NewClient()
Expand Down
8 changes: 8 additions & 0 deletions orchestrator/internal/fetcher/grpc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"orchestrator/internal/fetcher/grpc/bosspb"
"orchestrator/internal/memory"
"orchestrator/internal/redis"
"orchestrator/internal/service/rules/boss"
)
Expand Down Expand Up @@ -72,6 +73,13 @@ func (s *Server) CreateTaskStream(req *bosspb.CreateTaskRequest, stream bosspb.B

err := s.boss.ExecuteTask(ctx, bossReq, progress)
sender.flush() // всегда вызываем flush, и при успехе, и при ошибке

// Задача завершена: все промежуточные буферы (результаты воркеров,
// прочитанные файлы, вывод команд) стали мусором. Возвращаем память ОС,
// чтобы RSS опускался к холостому уровню, а не копился между задачами
// (issue #89).
memory.ReleaseToOS()

if err != nil {
log.Printf("ExecuteTask error: %v", err)
return err
Expand Down
76 changes: 76 additions & 0 deletions orchestrator/internal/memory/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Package memory содержит утилиты управления потреблением оперативной памяти
// процессом orchestrator.
//
// Проблема (issue #89): без задач процесс держит ~20-30 МБ, но во время
// выполнения задачи RSS вырастает до 400 МБ — 1.1 ГБ и НЕ возвращается обратно
// после завершения. Причина — поведение сборщика мусора Go: после всплеска
// аллокаций (чтение целых репозиториев в память, большие LLM-промпты/ответы,
// вывод сборочных команд) куча разрастается, а освободившуюся память Go по
// умолчанию не спешит отдавать операционной системе.
//
// Этот пакет даёт два рычага:
// - Configure — опционально выставляет мягкий лимит памяти (GOMEMLIMIT),
// чтобы GC включался агрессивнее и не давал куче разрастаться до гигабайта.
// - ReleaseToOS — форсирует сборку мусора и возвращает свободную память ОС.
// Вызывается после каждой завершённой задачи, чтобы RSS опускался обратно
// к холостому уровню, а не копился от задачи к задаче.
package memory

import (
"log"
"os"
"runtime"
"runtime/debug"
"strconv"
)

// Configure применяет настройки управления памятью на старте процесса.
//
// Если задан ORCHESTRATOR_MEMORY_LIMIT_MIB (в мегабайтах) и стандартный
// GOMEMLIMIT не выставлен, устанавливает мягкий лимит памяти через
// debug.SetMemoryLimit. Это заставляет GC работать активнее при приближении к
// лимиту и удерживает пиковое потребление под контролем. По умолчанию (если
// переменная не задана) поведение рантайма не меняется.
func Configure() {
// GOMEMLIMIT обрабатывается рантаймом Go автоматически — не трогаем его,
// чтобы не перетереть явный выбор оператора.
if os.Getenv("GOMEMLIMIT") != "" {
log.Printf("[memory] GOMEMLIMIT задан через окружение, оставляем как есть")
return
}

raw := os.Getenv("ORCHESTRATOR_MEMORY_LIMIT_MIB")
if raw == "" {
return
}
mib, err := strconv.ParseInt(raw, 10, 64)
if err != nil || mib <= 0 {
log.Printf("[memory] некорректный ORCHESTRATOR_MEMORY_LIMIT_MIB=%q, игнорируем", raw)
return
}
limit := mib * 1024 * 1024
debug.SetMemoryLimit(limit)
log.Printf("[memory] мягкий лимит памяти выставлен: %d МБ", mib)
}

// ReleaseToOS форсирует сборку мусора и возвращает освободившуюся память
// операционной системе. Логирует размер кучи до и после, чтобы эффект был
// виден в логах.
//
// Вызывать после завершения задачи (успешного или нет): к этому моменту все
// промежуточные буферы (результаты воркеров, прочитанные файлы, вывод команд)
// становятся мусором, и их можно вернуть ОС, вместо того чтобы держать RSS на
// пиковом уровне до следующего цикла GC.
func ReleaseToOS() {
var before runtime.MemStats
runtime.ReadMemStats(&before)

runtime.GC()
debug.FreeOSMemory()

var after runtime.MemStats
runtime.ReadMemStats(&after)

log.Printf("[memory] освобождение памяти ОС: heap %d МБ -> %d МБ (goroutines=%d)",
before.HeapAlloc/1024/1024, after.HeapAlloc/1024/1024, runtime.NumGoroutine())
}
65 changes: 65 additions & 0 deletions orchestrator/internal/memory/memory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package memory

import (
"runtime"
"runtime/debug"
"testing"
)

// TestReleaseToOS — освобождение памяти не должно паниковать и должно реально
// уменьшать (или хотя бы не увеличивать) объём кучи после отбрасывания крупной
// аллокации.
func TestReleaseToOS(t *testing.T) {
// Создаём и отбрасываем крупную аллокацию (~64 МБ).
big := make([]byte, 64*1024*1024)
for i := range big {
big[i] = byte(i)
}
_ = big[len(big)-1]
big = nil

var before runtime.MemStats
runtime.ReadMemStats(&before)

ReleaseToOS()

var after runtime.MemStats
runtime.ReadMemStats(&after)

if after.HeapAlloc > before.HeapAlloc {
t.Fatalf("heap grew after release: %d -> %d", before.HeapAlloc, after.HeapAlloc)
}
}

// TestConfigureRespectsExistingLimit — Configure не трогает лимит, выставленный
// через GOMEMLIMIT окружения (имитируем уже выставленным лимитом).
func TestConfigureRespectsExistingLimit(t *testing.T) {
orig := debug.SetMemoryLimit(-1) // прочитать текущий, не меняя
t.Cleanup(func() { debug.SetMemoryLimit(orig) })

t.Setenv("GOMEMLIMIT", "512MiB")
t.Setenv("ORCHESTRATOR_MEMORY_LIMIT_MIB", "128")

Configure()

if got := debug.SetMemoryLimit(-1); got != orig {
t.Fatalf("Configure changed memory limit despite GOMEMLIMIT set: %d -> %d", orig, got)
}
}

// TestConfigureAppliesCustomLimit — при заданном ORCHESTRATOR_MEMORY_LIMIT_MIB
// (и без GOMEMLIMIT) выставляется мягкий лимит памяти.
func TestConfigureAppliesCustomLimit(t *testing.T) {
orig := debug.SetMemoryLimit(-1)
t.Cleanup(func() { debug.SetMemoryLimit(orig) })

t.Setenv("GOMEMLIMIT", "")
t.Setenv("ORCHESTRATOR_MEMORY_LIMIT_MIB", "256")

Configure()

want := int64(256) * 1024 * 1024
if got := debug.SetMemoryLimit(-1); got != want {
t.Fatalf("expected memory limit %d, got %d", want, got)
}
}
27 changes: 27 additions & 0 deletions orchestrator/internal/service/rules/worker/live_code_files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,33 @@ func TestContainsSourceCode(t *testing.T) {
}
}

// TestHasRealCode проверяет, что короткий настоящий код считается реальным
// (issue #75 п.6), а пустые/комментарийные/заглушечные файлы — нет (issue #98).
func TestHasRealCode(t *testing.T) {
cases := []struct {
name string
content string
want bool
}{
{"one-line express", "const express = require('express'); express().listen(3000)", true},
{"two-line go", "package main\nfunc main() {}", true},
{"empty", " \n\t\n", false},
{"only line comments", "// TODO: implement\n// later", false},
{"only hash comments", "# TODO\n# nothing here", false},
{"only block comment", "/* placeholder */\n*", false},
{"python pass stub", "# TODO\npass", false},
{"ellipsis stub", "...", false},
{"real code after comment", "// header\nprint('hi')", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := hasRealCode(tc.content); got != tc.want {
t.Fatalf("hasRealCode(%q) = %v, want %v", tc.content, got, tc.want)
}
})
}
}

// TestLiveCodeFilesPayloadSkipsInfra проверяет issue #75 п.1+п.6: инфраструктурные
// файлы (flake.nix, .octra/context.json) не должны стримиться во вкладку Solution.
func TestLiveCodeFilesPayloadSkipsInfra(t *testing.T) {
Expand Down
95 changes: 84 additions & 11 deletions orchestrator/internal/service/rules/worker/tool_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ func (s *Service) executeToolCommand(ctx context.Context, projectPath, command s
return "", fmt.Errorf("failed to start command: %w", err)
}

var output strings.Builder
// Вывод сборочных команд (nix develop, npm install, cargo build, …) может
// достигать сотен мегабайт. Полностью он нужен только для диагностики
// ошибки, поэтому держим в памяти лишь последний фрагмент — этого хватает,
// чтобы увидеть причину падения, но RSS не разрастается (issue #89).
output := newBoundedBuffer(maxToolOutputBytes)
reader := io.MultiReader(stdout, stderr)
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 1024*64), 1024*64)
Expand All @@ -205,6 +209,44 @@ func (s *Service) executeToolCommand(ctx context.Context, projectPath, command s
return output.String(), err
}

// maxToolOutputBytes — сколько байт вывода команды держим в памяти. Хранится
// «хвост» (последние байты), потому что причина падения сборки обычно в конце.
const maxToolOutputBytes = 64 * 1024

// boundedBuffer накапливает текст, но удерживает в памяти не более limit
// последних байт. При переполнении старые данные отбрасываются с начала.
type boundedBuffer struct {
buf []byte
limit int
truncated bool
}

func newBoundedBuffer(limit int) *boundedBuffer {
if limit <= 0 {
limit = maxToolOutputBytes
}
return &boundedBuffer{limit: limit}
}

func (b *boundedBuffer) WriteString(s string) {
b.buf = append(b.buf, s...)
if len(b.buf) > b.limit {
// Копируем хвост в свежий слайс, чтобы старый backing-массив (с
// отброшенными байтами) собрался сборщиком мусора, а не держался в RSS.
tail := make([]byte, b.limit)
copy(tail, b.buf[len(b.buf)-b.limit:])
b.buf = tail
b.truncated = true
}
}

func (b *boundedBuffer) String() string {
if b.truncated {
return "[...output truncated...]\n" + string(b.buf)
}
return string(b.buf)
}

// detectNewFiles находит файлы, созданные инструментами, через git status --porcelain.
// Парсит staged (A/M), unstaged modified (M) и untracked (??) файлы.
func detectNewFiles(projectPath string) map[string]string {
Expand Down Expand Up @@ -243,6 +285,12 @@ func detectNewFiles(projectPath string) map[string]string {
path = strings.TrimSpace(parts[1])
}
}
// Не читаем в память артефакты сборки и менеджеры пакетов
// (node_modules, target, dist, …): для npm/cargo-проектов это сотни
// мегабайт мусора, который никому не нужен (issue #89).
if util.IsIgnoredPath(path) {
continue
}
content, readErr := os.ReadFile(filepath.Join(projectPath, path))
if readErr != nil {
continue
Expand All @@ -257,14 +305,24 @@ func detectNewFiles(projectPath string) map[string]string {
func readProjectFiles(projectPath string) map[string]string {
files := make(map[string]string)
_ = filepath.Walk(projectPath, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
if err != nil {
return nil
}
rel, err := filepath.Rel(projectPath, path)
if err != nil {
return nil
}
if strings.HasPrefix(rel, ".git") || strings.HasPrefix(rel, ".octra") {
rel = filepath.ToSlash(rel)
// Артефакты сборки и менеджеры пакетов (node_modules, target, dist, …)
// не спускаемся внутрь целиком — это сотни мегабайт мусора, который
// иначе целиком оказался бы в памяти (issue #89).
if info.IsDir() {
if rel != "." && util.IsIgnoredPath(rel) {
return filepath.SkipDir
}
return nil
}
if util.IsIgnoredPath(rel) {
return nil
}
content, err := os.ReadFile(path)
Expand Down Expand Up @@ -336,23 +394,38 @@ func liveCodeFilesPayload(role, path, content string) string {
return string(data)
}

// содержит ли файл реальный код, а не только TODO-заглушки и комментарии.
// hasRealCode сообщает, содержит ли файл хотя бы одну строку настоящего кода, а
// не только пустые строки, комментарии и TODO-заглушки (issue #98). Даже короткий
// исходник (однострочный express-сервер, `package main` + `func main`) считается
// реальным кодом (issue #75 п.6) — поэтому достаточно одной непустой строки,
// которая не является комментарием или пустой заглушкой вроде `pass`/`...`.
func hasRealCode(content string) bool {
cleaned := strings.TrimSpace(content)
if cleaned == "" {
return false
}
lines := strings.Split(cleaned, "\n")
nonStubLines := 0
for _, line := range lines {
for _, line := range strings.Split(cleaned, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") {
if trimmed == "" || isStubLine(trimmed) {
continue
}
nonStubLines++
return true
}
// В файле должно быть хотя бы 3 строки реального кода (не комментариев)
return nonStubLines >= 3
return false
}

// isStubLine — строка, которая не несёт реального кода: комментарий или типовая
// пустая заглушка (TODO/FIXME, `pass`, `...`, голые скобки).
func isStubLine(trimmed string) bool {
switch trimmed {
case "pass", "...", "{", "}", "{}", "()", ";":
return true
}
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "#") ||
strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") {
return true
}
return false
}

// containsSourceCode сообщает, есть ли среди файлов хотя бы один непустой файл с
Expand Down
Loading
Loading