Files
gosentry/docs/plans/per-job-timeout.md
T
2026-07-25 23:05:29 +03:00

8.0 KiB
Raw Blame History

План реализации: Per-job command timeout

Реализация пункта «Per-job command timeout» из ROADMAP.md, построенная по образцу уже реализованного overlap_policy — тот же паттерн наследования «пусто → глобальный дефолт».

Рекомендуемая модель

Claude Opus (Opus 4.8 или новее).

Обоснование: это не локальная правка, а сквозное изменение через слои (domain → storage → runner → app seam → ui → tests) со сменой сигнатуры RunJob и seam-типа Service.runJob, что затрагивает ~9 существующих тестов и несколько UI-файлов. Нужна аккуратность в резолве эффективного значения под mu и в сохранении контракта «раннер не знает о глобальном конфиге». Для такой кросс-слойной работы с тестами уместен Opus; Sonnet справится с отдельными шагами, но выше риск упустить обновление одного из вызовов/тестов.

Замечание по окружению: сборка и тесты GUI требуют CGO + MSYS2 UCRT64 (дефолтный Bash-env идёт с cgo off).


Модель данных

src/domain/job.go — добавить поле в Job:

TimeoutSeconds int `json:"timeout_seconds,omitempty"` // 0 = наследовать глобальный дефолт

src/domain/config.go — добавить в Config:

DefaultTimeoutSeconds int `json:"default_timeout_seconds,omitempty"`

Ключевое соглашение (как у OverlapPolicy): 0 на Job означает «наследовать», поэтому normalizeJob не должен затирать 0 глобальным значением.

Дефолты и загрузка

src/storage/store.go — по образцу OverlapPolicy:

  • в литерал дефолтного Config добавить DefaultTimeoutSeconds: 30 (сохраняет текущее поведение — 30 с);
  • в блоке нормализации после загрузки: if config.DefaultTimeoutSeconds <= 0 { config.DefaultTimeoutSeconds = 30 }.

Это переносит нынешнюю константу commandTimeout = 30s из runner.go в конфиг как значение по умолчанию.

Runner (сохранить чистоту контракта)

Раннер не должен знать о глобальном конфиге — эффективный таймаут резолвится в app-слое и передаётся внутрь.

src/runner/runner.go:

  • сигнатура RunJob(ctx, job, trigger, logsDir)RunJob(ctx, job, trigger, logsDir, timeout time.Duration);
  • runCtx, cancel := context.WithTimeout(ctx, timeout) вместо константы;
  • runStateDetail(...) принимает timeout и печатает его в сообщении "Timed out after %s" вместо commandTimeout;
  • StartOnly не трогаем: эта ветка использует jobInvocation(ctx, …) (не runCtx), поэтому run-таймаут её не касается — измерение launch latency сохраняется. Константу commandTimeout можно удалить, commandWaitDelay оставить.

App-слой: резолв и проброс

src/app/run.go — добавить рядом с effectiveOverlapPolicy:

func (s *Service) effectiveTimeout(job *domain.Job) time.Duration {
    secs := job.TimeoutSeconds
    if secs <= 0 {
        secs = s.store.Config.DefaultTimeoutSeconds
    }
    return time.Duration(secs) * time.Second
}

Резолвить под mu в startRunLocked и класть в снапшот runEnv (там же, где logsDir/maxFiles) — новое поле timeout time.Duration. В executeRun передавать env.timeout в s.runJob(...).

src/app/service.go — обновить тип seam-поля runJob (добавить timeout time.Duration); присваивание runJob: runner.RunJob останется валидным после смены сигнатуры.

Валидация

src/app/operations.go:

  • validateJob: if job.TimeoutSeconds < 0 { return errors.New("timeout must be zero (inherit) or a positive number of seconds") };
  • validateConfig: if config.DefaultTimeoutSeconds <= 0 { return errors.New("default timeout must be a positive number of seconds") }.

UI

Диалог задачи src/ui/job_dialog.go — рядом с «Overlap policy»: числовой widget.NewEntry «Timeout (s)» с плейсхолдером- подсказкой про наследование; пусто → TimeoutSeconds = 0, иначе strconv.Atoi с показом ошибки как у schedule.

Настройки src/ui/settings_view.go — в секцию «Queue» добавить поле «Default timeout (s)» рядом с «Default overlap policy»: инициализация из store.Config.DefaultTimeoutSeconds, OnChanged → updateSaveState, запись в config.DefaultTimeoutSeconds при сохранении и учёт в dirty-check.

Панель деталей src/ui/jobs_view_details.go — новая строка «Timeout», отображающая эффективное значение через новый хелпер в src/app/format.go:

func DisplayTimeout(job domain.Job, globalDefault int) string // "45 s" или "30 s (global default)"

по образцу DisplayOverlapPolicy. Прокинуть globalDefault в newDetailsPanel/update так же, как уже прокинут globalOverlapPolicy.

Тесты

  • src/runner/runner_test.go — все 9 вызовов RunJob получают новый аргумент; добавить кейс: короткий per-job таймаут → Failed / Timed out after …; StartOnly с малым таймаутом → не таймаутит.
  • src/app/run_test.go — тест effectiveTimeout: инхерит при TimeoutSeconds==0, собственное значение перекрывает глобальное.
  • src/app/operations_test.go — отрицательный per-job timeout и неположительный default отклоняются.
  • src/app/format_test.goDisplayTimeout (собственное значение vs «(global default)»).
  • settings / mainwindow тесты — при необходимости обновить конструкторы Config.

Документация

Порядок работ

  1. domain → storage-дефолты (компилируется, поведение прежнее);
  2. runner + seam-сигнатура + прогон таймаута через runEnv → чиним компиляцию тестов;
  3. валидация;
  4. UI (диалог, настройки, детали);
  5. тесты + доки.