feat(opencode): рабочий serve для dev стартует из папки репозитория; пул покрывает внешним сервером подкаталоги
Some checks failed
CI / test (push) Failing after 1m59s
CI / build-and-package (amd64, linux) (push) Failing after 1m2s
CI / build-and-package (amd64, windows) (push) Successful in 26s

This commit is contained in:
ki.sagidullin
2026-08-24 19:36:38 +05:00
parent 6c0b903794
commit 2005421739
4 changed files with 84 additions and 18 deletions

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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 должен исправить именно это).

View File

@@ -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,