From 2005421739002fbb8c17dfda022b895531958ea1 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Mon, 24 Aug 2026 19:36:38 +0500 Subject: [PATCH] =?UTF-8?q?feat(opencode):=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D0=B9=20serve=20=D0=B4=D0=BB=D1=8F=20dev=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D1=82=D1=83=D0=B5=D1=82=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F;=20=D0=BF=D1=83=D0=BB=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=D1=88=D0=BD=D0=B8=D0=BC=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=BC=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/opencode/pool.go | 43 +++++++++++++++++++++++++++++++---- internal/worker/postmortem.go | 22 ++++++++++++++---- internal/worker/prompt.go | 20 +++++++++++----- internal/worker/worker.go | 17 +++++++++++--- 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/internal/opencode/pool.go b/internal/opencode/pool.go index 3618c35..1537447 100644 --- a/internal/opencode/pool.go +++ b/internal/opencode/pool.go @@ -34,6 +34,12 @@ type Pool struct { segs map[string]*Server // dir → сервер (root тоже здесь) used map[int]bool // занятые порты next int // следующий кандидат порта + + // externs — внешние (уже запущенные) серверы, зарегистрированные как + // префикс каталога: Ensure(dir) отдаёт такой сервер и для подкаталогов + // зарегистрированного каталога (используется тестами для эмуляции serve, + // покрывающего всё дерево worktree). + externs map[string]*Server // dir → внешний сервер } // NewPool создаёт пул. rootDir помечен как служебный (не снимается ReleaseTask). @@ -92,11 +98,18 @@ func (p *Pool) EnsureRoot(ctx context.Context) error { // Возвращает сервер; root-сервер для rootDir возвращается как есть. func (p *Pool) Ensure(ctx context.Context, dir string) (*Server, error) { p.mu.Lock() - if s, ok := p.segs[dir]; ok { + abs := filepath.Clean(dir) + if s, ok := p.segs[abs]; ok { + p.mu.Unlock() + return s, nil + } + // Внешний сервер-префикс покрывает дерево каталога (тестовая эмуляция): + // подкаталог обслуживается тем же сервером, spawn не нужен. + if s := p.externalFor(abs); s != nil { + p.segs[abs] = s p.mu.Unlock() return s, nil } - abs := filepath.Clean(dir) s := &Server{ Bin: p.Bin, DBPath: p.DBPath, @@ -129,9 +142,31 @@ func (p *Pool) RegisterExternal(dir, url string) { p.mu.Lock() defer p.mu.Unlock() abs := filepath.Clean(dir) - p.segs[abs] = &Server{URL: url, Host: p.Host, PollInterval: 0} + s := &Server{URL: url, Host: p.Host, PollInterval: 0} + p.segs[abs] = s + if p.externs == nil { + p.externs = map[string]*Server{} + } + p.externs[abs] = s if p.rootDir != "" && abs == p.rootDir { - p.root = p.segs[abs] + p.root = s + } +} + +// externalFor возвращает внешний сервер-префикс для dir: самого каталога или +// его родителя (подкаталоги зарегистрированного каталога обслуживаются тем же +// внешним сервером). Вызывается под блокировкой p.mu. +func (p *Pool) externalFor(dir string) *Server { + cur := dir + for { + if s, ok := p.externs[cur]; ok { + return s + } + parent := filepath.Dir(cur) + if parent == cur { + return nil + } + cur = parent } } diff --git a/internal/worker/postmortem.go b/internal/worker/postmortem.go index ae7e3ea..3a1c9f3 100644 --- a/internal/worker/postmortem.go +++ b/internal/worker/postmortem.go @@ -142,13 +142,25 @@ func (w *Worker) hasPostMortemTrace(ctx context.Context, taskID int64) bool { // // Статус задачи НЕ меняется (failed/timeout остаётся достигнутым); собственные // сбои постмортема не влияют на исход задачи — только логируются. -func (w *Worker) postMortem(ctx context.Context, task *storage.Task) { +// postMortem запускает постмортем-агент для задачи, завершившейся failed/timeout: +// собирает сессии dev/reviewer из трасс, даёт агенту анализ, сохраняет результат +// как trace agent=postmortem и уведомляет владельца задачи резюме. +// +// projectDir — каталог основного репозитория (тот же serve, где жили сессии +// dev/reviewer); fallback на w.Worktree, если папка репо недоступна. +// +// Статус задачи НЕ меняется (failed/timeout остаётся достигнутым); собственные +// сбои постмортема не влияют на исход задачи — только логируются. +func (w *Worker) postMortem(ctx context.Context, task *storage.Task, projectDir string) { if w.Store == nil || w.Runner == nil { return } if w.hasPostMortemTrace(ctx, task.ID) { return } + if projectDir == "" { + projectDir = w.Worktree + } traces, err := w.Store.GetTraces(ctx, task.ID) if err != nil { @@ -172,7 +184,7 @@ func (w *Worker) postMortem(ctx context.Context, task *storage.Task) { if tr.SessionID == "" { continue } - tx, tErr := w.Runner.SessionMessages(w.runCtx(ctx, task.ID), w.Worktree, tr.SessionID) + tx, tErr := w.Runner.SessionMessages(w.runCtx(ctx, task.ID), projectDir, tr.SessionID) if tErr != nil { log.Printf("worker: task %d: постмортем: транскрипт %s (%s): %v", task.ID, tr.Agent, tr.SessionID, tErr) continue @@ -204,7 +216,7 @@ func (w *Worker) postMortem(ctx context.Context, task *storage.Task) { return } - res, rErr := w.Runner.Run(w.runCtx(ctx, task.ID), prompt, w.Worktree, postMortemAgent, "") + res, rErr := w.Runner.Run(w.runCtx(ctx, task.ID), prompt, projectDir, postMortemAgent, "") if rErr != nil { log.Printf("worker: task %d: постмортем: запуск: %v", task.ID, rErr) w.finalizeTrace(ctx, traceID, storage.TraceFailed, rErr.Error()) @@ -234,9 +246,9 @@ func (w *Worker) postMortem(ctx context.Context, task *storage.Task) { // postMortemAfter — defer-хук из runTask: запускает постмортем, если задача // завершилась failed/timeout. Собственные ошибки постмортема не мешают // исходному результату задачи (возвращаемый *error только читается). -func (w *Worker) postMortemAfter(ctx context.Context, task *storage.Task, _ *error) { +func (w *Worker) postMortemAfter(ctx context.Context, task *storage.Task, projectDir string, _ *error) { if task.Status != storage.StatusFailed && task.Status != storage.StatusTimeout { return } - w.postMortem(ctx, task) + w.postMortem(ctx, task, projectDir) } diff --git a/internal/worker/prompt.go b/internal/worker/prompt.go index 21ed5d0..cdda571 100644 --- a/internal/worker/prompt.go +++ b/internal/worker/prompt.go @@ -14,11 +14,14 @@ var devPromptTemplate = template.Must(template.New("dev").Parse(`Ты — dev-а {{if .Title}}Название: {{.Title}}{{end}} {{if .Goal}}Цель: {{.Goal}}{{end}} {{if .Repos}} -Репозитории (доступны как подпапки текущего каталога): +Репозитории: {{- range .Repos}} - {{.}} {{- end}} {{end}} +{{if .Primary}} +Основной репозиторий (рабочий каталог этой сессии): {{.Primary}} +{{end}} {{if .Why}}Зачем: {{.Why}}{{end}} {{if .AC}}Критерии готовности: {{.AC}}{{end}} @@ -30,8 +33,8 @@ var devPromptTemplate = template.Must(template.New("dev").Parse(`Ты — dev-а {{end}} **Инструкции:** -1. Рабочий каталог — общий корень, в котором лежат все репозитории по именам. - Правь файлы внутри нужного репозитория (./имя_репо/...). Связанные репозитории меняй согласованно. +1. Рабочий каталог — сам основной репозиторий ({{.Primary}}): правь файлы в нём прямо, относительными путями (./...). +{{if .Others}} Связанные репозитории задачи лежат рядом, в ../имя_репо/ (здесь cwd — основной репозиторий): меняй их согласованно, если этого требует задача.{{else}} Работа ведётся только в нём — связанных репозиториев у задачи нет.{{end}} 2. Напиши код, реализующий задачу. 3. Убедись, что все acceptance criteria выполнены. 4. Пользуйся встроенными инструментами opencode (чтение файлов, поиск, редактирование). @@ -44,9 +47,14 @@ type DevPromptData struct { Title string Goal string Repos []string - Why string - AC string - Steps []storage.Step + // Primary — основной (первый) репозиторий задачи: рабочий каталог сессии + // dev — это именно его папка (сервер opencode стартует из неё). + Primary string + // Others — остальные репозитории задачи (доступны рядом как ../имя_репо/). + Others []string + Why string + AC string + Steps []storage.Step // ReviewFeedback — замечания ревьюера при повторном прогоне dev // (не пусто → dev должен исправить именно это). diff --git a/internal/worker/worker.go b/internal/worker/worker.go index bd1f110..fb24eba 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -201,7 +201,12 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error { func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) { // 0. постмортем-анализ: если задача завершилась failed/timeout — после // выхода из runTask (всех путей) запускаем анализ сессий dev/reviewer. - defer w.postMortemAfter(ctx, task, &err) + // projectDir (каталог основного репозитория) объявляем заранее — defer + // захватывает переменную, её значение подставится к моменту возврата. + var projectDir string + defer func() { + w.postMortemAfter(ctx, task, projectDir, &err) + }() // 1. проверяем статус if task.Status != storage.StatusApproved { @@ -241,8 +246,12 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) { } } - // 3. cwd — общий каталог (вариант A: один dev видит все репозитории). - cwd := w.Worktree + // 3. cwd — папка основного репозитория: сервер opencode для работы стартует + // из самого репозитория (подхватывает .serena, экономит токены на поиск + // файлов из корня worktree). Служебный root-сервер в worktree остаётся + // для аналитика; пул поднимает свой serve на каждый репозиторий работы. + cwd := w.repoDirOf(repos[0]) + projectDir = cwd // Цикл dev → review, до maxReviewIterations. var feedback []string @@ -253,6 +262,8 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) { Title: task.Title, Goal: task.Goal, Repos: repos, + Primary: repos[0], + Others: repos[1:], Why: task.Why, AC: task.AC, Steps: task.Steps,