From 4ca517e7c0a0d166ebc87ef6117fdfbbe6844c91 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 16 Jun 2026 18:05:43 +0000 Subject: [PATCH 1/5] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/Payel-git-ol/Octra/issues/89 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..de32076 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-16T18:05:43.572Z for PR creation at branch issue-89-1b8d97369376 for issue https://github.com/Payel-git-ol/Octra/issues/89 \ No newline at end of file From a54eba0c070dfc5310fe1e2c5a96d6c067aa3b7b Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 16 Jun 2026 18:13:29 +0000 Subject: [PATCH 2/5] perf(orchestrator): release memory to OS after each task Issue #89: idle RSS is ~20-30MB but climbs to 400MB-1.1GB during a task and does not come back. Go's GC does not eagerly return freed heap to the OS after a burst of large allocations (whole repos read into memory, big LLM prompts/responses, build-command output). Add internal/memory with: - Configure(): optional soft memory limit via ORCHESTRATOR_MEMORY_LIMIT_MIB (honours an explicit GOMEMLIMIT if already set). - ReleaseToOS(): runtime.GC() + debug.FreeOSMemory(), logging heap before/after. Call Configure() on startup and ReleaseToOS() after every task completes in CreateTaskStream so RSS drops back toward the idle level between tasks. --- orchestrator/cmd/app/main.go | 3 + orchestrator/internal/fetcher/grpc/server.go | 8 +++ orchestrator/internal/memory/memory.go | 76 ++++++++++++++++++++ orchestrator/internal/memory/memory_test.go | 65 +++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 orchestrator/internal/memory/memory.go create mode 100644 orchestrator/internal/memory/memory_test.go diff --git a/orchestrator/cmd/app/main.go b/orchestrator/cmd/app/main.go index 4632fbc..979fcdd 100644 --- a/orchestrator/cmd/app/main.go +++ b/orchestrator/cmd/app/main.go @@ -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" @@ -19,6 +20,8 @@ import ( // через gRPC; теперь все три роли живут в одном процессе и зовут друг друга // напрямую через Go. func main() { + memory.Configure() + database.InitDb() agentsClient, err := agents.NewClient() diff --git a/orchestrator/internal/fetcher/grpc/server.go b/orchestrator/internal/fetcher/grpc/server.go index 5e6603c..e5cbef9 100644 --- a/orchestrator/internal/fetcher/grpc/server.go +++ b/orchestrator/internal/fetcher/grpc/server.go @@ -10,6 +10,7 @@ import ( "time" "orchestrator/internal/fetcher/grpc/bosspb" + "orchestrator/internal/memory" "orchestrator/internal/redis" "orchestrator/internal/service/rules/boss" ) @@ -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 diff --git a/orchestrator/internal/memory/memory.go b/orchestrator/internal/memory/memory.go new file mode 100644 index 0000000..abbc4b8 --- /dev/null +++ b/orchestrator/internal/memory/memory.go @@ -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()) +} diff --git a/orchestrator/internal/memory/memory_test.go b/orchestrator/internal/memory/memory_test.go new file mode 100644 index 0000000..0fe9308 --- /dev/null +++ b/orchestrator/internal/memory/memory_test.go @@ -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) + } +} From eaefcdff31076f2432514c669a07bc5877ec93a5 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 16 Jun 2026 18:13:36 +0000 Subject: [PATCH 3/5] perf(orchestrator): bound command output and skip heavy dirs (#89) Two unbounded in-memory accumulations during task execution: 1. executeToolCommand buffered the FULL stdout+stderr of build commands (nix develop, npm install, cargo build, ...) in a strings.Builder. Such output can reach hundreds of MB and is only used for error diagnostics. Replace with a bounded buffer that retains the last 64KB (the tail, where failures surface) and frees the discarded head. 2. readProjectFiles fallback and detectNewFiles read every changed/untracked file into memory, including node_modules, target/, dist/ and other build artifacts. Filter these via util.IsIgnoredPath and prune ignored directories from the walk with filepath.SkipDir. Add tests for the bounded buffer and ignored-path filtering. --- .../service/rules/worker/tool_executor.go | 64 +++++++++++++- .../rules/worker/tool_executor_memory_test.go | 84 +++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 orchestrator/internal/service/rules/worker/tool_executor_memory_test.go diff --git a/orchestrator/internal/service/rules/worker/tool_executor.go b/orchestrator/internal/service/rules/worker/tool_executor.go index ed8a95f..a87ae6f 100644 --- a/orchestrator/internal/service/rules/worker/tool_executor.go +++ b/orchestrator/internal/service/rules/worker/tool_executor.go @@ -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) @@ -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 { @@ -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 @@ -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) diff --git a/orchestrator/internal/service/rules/worker/tool_executor_memory_test.go b/orchestrator/internal/service/rules/worker/tool_executor_memory_test.go new file mode 100644 index 0000000..94b0532 --- /dev/null +++ b/orchestrator/internal/service/rules/worker/tool_executor_memory_test.go @@ -0,0 +1,84 @@ +package worker + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestBoundedBufferKeepsTail проверяет, что boundedBuffer удерживает не более +// limit байт и сохраняет именно хвост вывода (где обычно лежит причина ошибки). +func TestBoundedBufferKeepsTail(t *testing.T) { + b := newBoundedBuffer(16) + for i := 0; i < 1000; i++ { + b.WriteString("0123456789") + } + out := b.String() + + // Маркер усечения + не больше limit байт самих данных. + if !strings.HasPrefix(out, "[...output truncated...]\n") { + t.Fatalf("expected truncation marker, got: %q", out) + } + data := strings.TrimPrefix(out, "[...output truncated...]\n") + if len(data) > 16 { + t.Fatalf("expected at most 16 bytes retained, got %d", len(data)) + } + // Хвост — это последние записанные символы. + if !strings.HasSuffix("0123456789", data[len(data)-1:]) { + t.Fatalf("retained data should be the tail, got %q", data) + } +} + +// TestBoundedBufferNoTruncationWhenSmall — небольшой вывод не усекается и не +// помечается маркером. +func TestBoundedBufferNoTruncationWhenSmall(t *testing.T) { + b := newBoundedBuffer(1024) + b.WriteString("hello\n") + b.WriteString("world\n") + if got := b.String(); got != "hello\nworld\n" { + t.Fatalf("unexpected output: %q", got) + } +} + +// TestReadProjectFilesSkipsIgnoredDirs — fallback-чтение проекта не должно +// затягивать в память node_modules и прочие тяжёлые артефакты (issue #89). +func TestReadProjectFilesSkipsIgnoredDirs(t *testing.T) { + dir := t.TempDir() + + write := func(rel, content string) { + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + write("main.go", "package main") + write("node_modules/left-pad/index.js", strings.Repeat("x", 10000)) + write("dist/bundle.js", strings.Repeat("y", 10000)) + write(".git/config", "gitstuff") + + files := readProjectFiles(dir) + + if _, ok := files["main.go"]; !ok { + t.Fatalf("expected main.go to be read, got keys: %v", keys(files)) + } + for path := range files { + if strings.HasPrefix(path, "node_modules/") || + strings.HasPrefix(path, "dist/") || + strings.HasPrefix(path, ".git/") { + t.Fatalf("ignored path should not be read into memory: %s", path) + } + } +} + +func keys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} From f2ec0e94c389419a5aa654758eb65fa6472c78f7 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 16 Jun 2026 18:25:09 +0000 Subject: [PATCH 4/5] fix(worker): count short real code, reject stubs in hasRealCode (#89) The pre-existing TestContainsSourceCode failed because hasRealCode required >= 3 non-comment lines, rejecting legitimate short sources (a one-line express server, package main + func main). Rewrite hasRealCode to accept any file with at least one real code line while still rejecting empty, comment-only and stub-only files (TODO/FIXME, pass, ...). Add TestHasRealCode to lock in the behavior. --- .gitignore | 3 +- .../rules/worker/live_code_files_test.go | 27 ++++++++++++++++ .../service/rules/worker/tool_executor.go | 31 ++++++++++++++----- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 6a0cd28..02c2648 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ docs\hive-mind PLAN_FIX.md docs\compose\plans docs\promts -DAY_PROBLEMS_AND_SOLUTION.md \ No newline at end of file +DAY_PROBLEMS_AND_SOLUTION.md +ci-logs/ diff --git a/orchestrator/internal/service/rules/worker/live_code_files_test.go b/orchestrator/internal/service/rules/worker/live_code_files_test.go index a3ab413..f517c94 100644 --- a/orchestrator/internal/service/rules/worker/live_code_files_test.go +++ b/orchestrator/internal/service/rules/worker/live_code_files_test.go @@ -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) { diff --git a/orchestrator/internal/service/rules/worker/tool_executor.go b/orchestrator/internal/service/rules/worker/tool_executor.go index a87ae6f..38dc45e 100644 --- a/orchestrator/internal/service/rules/worker/tool_executor.go +++ b/orchestrator/internal/service/rules/worker/tool_executor.go @@ -394,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 сообщает, есть ли среди файлов хотя бы один непустой файл с From 26ab4695fe87e7e6ac2d0ee2441494db91be2cd5 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 16 Jun 2026 18:31:08 +0000 Subject: [PATCH 5/5] Revert "Initial commit with task details" This reverts commit 4ca517e7c0a0d166ebc87ef6117fdfbbe6844c91. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index de32076..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-16T18:05:43.572Z for PR creation at branch issue-89-1b8d97369376 for issue https://github.com/Payel-git-ol/Octra/issues/89 \ No newline at end of file