Исправляет баг: воркер захватывал задачу на выполнение по статусу ready ещё до «создавай» (consent был заглушкой). Теперь: - новый статус approved: «создавай» → ready→approved; - воркер (pollAndDispatch + runTask) берёт ТОЛЬКО approved, ready = черновик готов, ждёт одобрения; - правка/текст в approved запрещены (финальное одобрение); - e2e-тест TestE2EWorkerDoesNotTakeUnconfirmed: в ready воркер задачу не трогает, запускает только после «создавай» → success; - обновлены все затронутые тесты (models/core/worker) и retry-фикстуры.
143 lines
5.8 KiB
Go
143 lines
5.8 KiB
Go
package storage
|
||
|
||
import "encoding/json"
|
||
|
||
// Status — статус задачи (state machine).
|
||
type Status string
|
||
|
||
const (
|
||
StatusDraft Status = "draft" // только что создана
|
||
StatusCollecting Status = "collecting" // аналитик собирает детали
|
||
StatusReady Status = "ready" // черновик готов, ждёт одобрения пользователя
|
||
StatusApproved Status = "approved" // пользователь одобрил («создавай») — воркер берёт в работу
|
||
StatusRunning Status = "running" // opencode работает
|
||
StatusSuccess Status = "success" // задача выполнена
|
||
StatusFailed Status = "failed" // ошибка выполнения
|
||
StatusTimeout Status = "timeout" // таймаут opencode
|
||
StatusCancelled Status = "cancelled" // отменена пользователем
|
||
StatusAborted Status = "aborted" // сбой сбора, черновик выброшен
|
||
StatusClosed Status = "closed" // закрыта вручную
|
||
)
|
||
|
||
// AllStatuses — все возможные статусы для валидации.
|
||
var AllStatuses = []Status{
|
||
StatusDraft, StatusCollecting, StatusReady, StatusApproved,
|
||
StatusRunning, StatusSuccess, StatusFailed, StatusTimeout,
|
||
StatusCancelled, StatusAborted, StatusClosed,
|
||
}
|
||
|
||
// validTransitions задаёт разрешённые переходы статусов.
|
||
var validTransitions = map[Status][]Status{
|
||
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
|
||
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
|
||
// ready — черновик готов: «создавай» → approved, либо правка/отмена/закрытие.
|
||
StatusReady: {StatusApproved, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting},
|
||
// approved — финальное одобрение: воркер берёт в running, либо отмена/сбой/закрытие.
|
||
StatusApproved: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed},
|
||
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
|
||
StatusSuccess: {StatusClosed},
|
||
StatusFailed: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
|
||
StatusTimeout: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
|
||
StatusCancelled: {StatusClosed},
|
||
StatusAborted: {StatusClosed},
|
||
StatusClosed: {}, // терминальный
|
||
}
|
||
|
||
// IsValidTransition проверяет, допустим ли переход from → to.
|
||
func IsValidTransition(from, to Status) bool {
|
||
allowed, ok := validTransitions[from]
|
||
if !ok {
|
||
return false
|
||
}
|
||
for _, s := range allowed {
|
||
if s == to {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// IsTerminal возвращает true, если статус терминальный.
|
||
func IsTerminal(s Status) bool {
|
||
return s == StatusSuccess || s == StatusCancelled ||
|
||
s == StatusAborted || s == StatusClosed
|
||
}
|
||
|
||
// Task — запись задачи в БД.
|
||
type Task struct {
|
||
ID int64 `json:"id"`
|
||
ChatID string `json:"chat_id"` // tg://<id>
|
||
Title string `json:"title"`
|
||
Goal string `json:"goal"`
|
||
Repo string `json:"repo"` // обратная совместимость: одиночный репозиторий
|
||
Repos []string `json:"repos"` // список репозиториев (основной)
|
||
Why string `json:"why"`
|
||
AC string `json:"ac"` // acceptance criteria
|
||
TaskTag string `json:"task_tag"` // UUID, стабильный на всю жизнь
|
||
Status Status `json:"status"`
|
||
CreatedAt SQLiteTime `json:"created_at"`
|
||
UpdatedAt SQLiteTime `json:"updated_at"`
|
||
}
|
||
|
||
// ReposJoined возвращает repos как одну строку (JSON-массив) для хранения в БД.
|
||
// Пустой список → пустая строка.
|
||
func (t *Task) ReposJoined() string {
|
||
if len(t.Repos) == 0 {
|
||
return ""
|
||
}
|
||
b, _ := json.Marshal(t.Repos)
|
||
return string(b)
|
||
}
|
||
|
||
// EffectiveRepos возвращает сам репо: если Repos пуст, но Repo задан —
|
||
// подтягивает одиночный (обратная совместимость).
|
||
func (t *Task) EffectiveRepos() []string {
|
||
if len(t.Repos) > 0 {
|
||
return t.Repos
|
||
}
|
||
if t.Repo != "" {
|
||
return []string{t.Repo}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetReposFromDB заполняет Repos из сохранённой строки (JSON), либо
|
||
// из одиночного repo (обратная совместимость).
|
||
func (t *Task) SetReposFromDB(repos string) {
|
||
if repos == "" {
|
||
t.Repos = nil
|
||
return
|
||
}
|
||
_ = json.Unmarshal([]byte(repos), &t.Repos)
|
||
}
|
||
|
||
// Trace — запись трассировки выполнения.
|
||
type TraceStatus string
|
||
|
||
const (
|
||
TraceRunning TraceStatus = "running"
|
||
TraceSuccess TraceStatus = "success"
|
||
TraceFailed TraceStatus = "failed"
|
||
TraceTimeout TraceStatus = "timeout"
|
||
)
|
||
|
||
// Trace — лог одного субагента.
|
||
type Trace struct {
|
||
ID int64 `json:"id"`
|
||
TaskID int64 `json:"task_id"`
|
||
Agent string `json:"agent"` // analyst | researcher | dev | reviewer
|
||
SessionID string `json:"session_id"` // opencode session_id
|
||
Prompt string `json:"prompt"`
|
||
Output string `json:"output"` // полный NDJSON или summary
|
||
Status TraceStatus `json:"status"`
|
||
StartedAt SQLiteTime `json:"started_at"`
|
||
FinishedAt NullSQLiteTime `json:"finished_at,omitempty"`
|
||
}
|
||
|
||
// TaskFilter — параметры фильтрации списка задач.
|
||
type TaskFilter struct {
|
||
ChatID string
|
||
Status Status
|
||
Limit int
|
||
Offset int
|
||
} |