fix(tools): forgejo fetch timeout + retries #40

Closed
opened 2026-08-11 15:20:37 +03:00 by slaid098 · 0 comments
Owner

Контекст

create-issue tool висит ~5 минут после инвокации. Исследование показало:

  • .opencode/tools/_shared.ts:86 — fetch() к Forgejo API вызывается без signal/AbortController/таймаута. Рантайм — Bun 1.2 (opencode — Bun-compiled binary), Bun.fetch() не имеет дефолтного таймаута (бесконечный).
  • opencode не оборачивает кастомные TS-тулы в execution-timeout — голый await u.execute(...).
  • FORGEJO_URL = https://git.slaid098.dev за Nginx. Forgejo использует SQLite (write-lock contention). 5 минут — серверсайд-таймаут, пока fetch покорно ждёт.
  • .opencode/tools/_shared.ts:34 — spawnSync("git", ...) без timeout — тоже беззащитен.
  • context.abort (SDK ToolContext.abort: AbortSignal) существует, но нигде не используется — пользовательская отмена tool-call из UI не работает.

Симптом: агент вызывает create-issue, инструмент уже выполняется, но висит ~5 минут, issue не создаётся.

Задача

  1. В callForgejo (_shared.ts:86) обернуть fetch() в retry-цикл с таймаутом через AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)]).
  2. В resolveForgejoRepo (_shared.ts:34) добавить timeout: 5000 к spawnSync.
  3. Пробросить context.abort из всех tool'ов (create-issue.ts, create-pr.ts, merge-pr.ts) через runGh → callForgejoGh → callForgejo.
  4. Добавить env vars: OPENCODE_FORGEJO_TIMEOUT (10с), OPENCODE_FORGEJO_RETRY (3), OPENCODE_FORGEJO_RETRY_INTERVAL (1с).
  5. Логировать ретраи и ошибки в process.stderr.write с префиксом [forgejo] (конвенция репо, NOT console.*).
  6. Тесты на timeout/retry/4xx/no-retry/user-abort (Bun test).
  7. Документировать env vars в .env.example.

Контракты

  • callForgejo(method, path, opts, userSignal?) — добавляется 4-й опциональный параметр userSignal?: AbortSignal.
  • callForgejoGh(args, repo, opts, signal?) — добавляется signal?: AbortSignal.
  • runGh(args, repo, opts, signal?) — добавляется signal?: AbortSignal.
  • Retry: на AbortError (timeout) и HTTP 5xx; НЕ на 4xx (клиентская ошибка — повтор не поможет).
  • Skip sleep на первой попытке (как pipeline-status.py:596).
  • Дефолты: OPENCODE_FORGEJO_TIMEOUT=10, OPENCODE_FORGEJO_RETRY=3, OPENCODE_FORGEJO_RETRY_INTERVAL=1. Худший кейс: 10×3 + 2×1 = 32с.

Инварианты

  • Существующие вызовы callForgejo/callForgejoGh/runGh без signal продолжают работать (параметр опциональный).
  • 4xx ошибки падают мгновенно (без ретраев) — повтор запроса с теми же данными не поможет.
  • context.abort отменяет fetch немедленно, без ретрая.
  • Логи только при ретрае/ошибке (успешные вызовы не логируются — не засорять stderr).
  • resolveForgejoRepo при timeout spawnSync возвращает читаемую ошибку, не hang.

Граничные случаи

  • AbortSignal.any существует в Bun 1.2 (подтверждено через Context7 — встроенный Web API, C++ реализация в AbortSignal.cpp).
  • AbortSignal.timeout(0) — edge case, должен сразу фейлиться.
  • 4xx + retry-exhausted — возвращаем 4xx (не timeout), чтобы агент видел реальную клиентскую ошибку.
  • resolveLabelIds делает доп. fetch'и (GET /labels, POST /labels) — должны тоже идти через retry-логику (используют тот же callForgejo).
  • OPENCODE_FORGEJO_RETRY=0 — мгновенный фейл без retry (валидный edge case).
  • OPENCODE_FORGEJO_TIMEOUT=0 — невалидный, fallback на дефолт (логировать warning).

Влияние на связанные компоненты

  • create-issue.ts, create-pr.ts, merge-pr.ts — проброс context.abort (1 строка на каждый).
  • .env.example — 3 новые переменные.
  • Новая конвенция: context.abort начинает использоваться впервые в репо — задел для других tool'ов.
  • Не влияет на pipeline-status.py (там своя retry-логика, конвенция совпадает — берём как референс).

Вне scope

  • Server-side Forgejo (SQLite PRAGMA busy_timeout, webhook sync/async, search indexer, Nginx proxy_read_timeout) — отдельный issue в forgejo-infra после merge этого.
  • Глобальный tool-execution-timeout в opencode.json — opencode core, не этот репо.
  • Forgejo MCP-сервер — overkill, не решает проблему.
  • Ретраи на 4xx — намеренно исключены (клиентская ошибка).
  • Экспоненциальный backoff — намеренно исключён (репо использует линейный, pipeline-status.py).

Критерии приемки

  • callForgejo использует AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)]).
  • callForgejo ретраит на timeout и 5xx, НЕ ретраит на 4xx.
  • resolveForgejoRepo spawnSync имеет timeout: 5000.
  • create-issue.ts, create-pr.ts, merge-pr.ts пробрасывают context.abort.
  • Env vars OPENCODE_FORGEJO_TIMEOUT/RETRY/RETRY_INTERVAL работают с дефолтами 10/3/1.
  • Логи ретраев/ошибок в process.stderr.write с [forgejo] prefix, успешные вызовы не логируются.
  • Тесты (_shared.test.ts, Bun test): timeout triggers retry, 5xx triggers retry, 4xx no-retry, retries-exhausted readable error, user-abort no-retry, resolveLabelIds respects timeout, resolveForgejoRepo spawnSync timeout.
  • .env.example задокументирован.
  • Существующие вызовы без signal продолжают работать (backward compatible).
  • Худший кейс latency ≤ 32с (вместо 5 минут).
## Контекст `create-issue` tool висит ~5 минут после инвокации. Исследование показало: - `.opencode/tools/_shared.ts:86` — `fetch()` к Forgejo API вызывается **без `signal`/`AbortController`/таймаута**. Рантайм — Bun 1.2 (opencode — Bun-compiled binary), `Bun.fetch()` не имеет дефолтного таймаута (бесконечный). - opencode не оборачивает кастомные TS-тулы в execution-timeout — голый `await u.execute(...)`. - `FORGEJO_URL = https://git.slaid098.dev` за Nginx. Forgejo использует SQLite (write-lock contention). 5 минут — серверсайд-таймаут, пока fetch покорно ждёт. - `.opencode/tools/_shared.ts:34` — `spawnSync("git", ...)` без `timeout` — тоже беззащитен. - `context.abort` (SDK `ToolContext.abort: AbortSignal`) существует, но нигде не используется — пользовательская отмена tool-call из UI не работает. Симптом: агент вызывает `create-issue`, инструмент уже выполняется, но висит ~5 минут, issue не создаётся. ## Задача 1. В `callForgejo` (`_shared.ts:86`) обернуть `fetch()` в retry-цикл с таймаутом через `AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)])`. 2. В `resolveForgejoRepo` (`_shared.ts:34`) добавить `timeout: 5000` к `spawnSync`. 3. Пробросить `context.abort` из всех tool'ов (`create-issue.ts`, `create-pr.ts`, `merge-pr.ts`) через `runGh` → `callForgejoGh` → `callForgejo`. 4. Добавить env vars: `OPENCODE_FORGEJO_TIMEOUT` (10с), `OPENCODE_FORGEJO_RETRY` (3), `OPENCODE_FORGEJO_RETRY_INTERVAL` (1с). 5. Логировать ретраи и ошибки в `process.stderr.write` с префиксом `[forgejo]` (конвенция репо, NOT `console.*`). 6. Тесты на timeout/retry/4xx/no-retry/user-abort (Bun test). 7. Документировать env vars в `.env.example`. ## Контракты - `callForgejo(method, path, opts, userSignal?)` — добавляется 4-й опциональный параметр `userSignal?: AbortSignal`. - `callForgejoGh(args, repo, opts, signal?)` — добавляется `signal?: AbortSignal`. - `runGh(args, repo, opts, signal?)` — добавляется `signal?: AbortSignal`. - Retry: на `AbortError` (timeout) и HTTP 5xx; **НЕ** на 4xx (клиентская ошибка — повтор не поможет). - Skip sleep на первой попытке (как `pipeline-status.py:596`). - Дефолты: `OPENCODE_FORGEJO_TIMEOUT=10`, `OPENCODE_FORGEJO_RETRY=3`, `OPENCODE_FORGEJO_RETRY_INTERVAL=1`. Худший кейс: `10×3 + 2×1 = 32с`. ## Инварианты - Существующие вызовы `callForgejo`/`callForgejoGh`/`runGh` без `signal` продолжают работать (параметр опциональный). - 4xx ошибки падают мгновенно (без ретраев) — повтор запроса с теми же данными не поможет. - `context.abort` отменяет fetch немедленно, без ретрая. - Логи только при ретрае/ошибке (успешные вызовы не логируются — не засорять stderr). - `resolveForgejoRepo` при timeout `spawnSync` возвращает читаемую ошибку, не hang. ## Граничные случаи - `AbortSignal.any` существует в Bun 1.2 (подтверждено через Context7 — встроенный Web API, C++ реализация в `AbortSignal.cpp`). - `AbortSignal.timeout(0)` — edge case, должен сразу фейлиться. - 4xx + retry-exhausted — возвращаем 4xx (не timeout), чтобы агент видел реальную клиентскую ошибку. - `resolveLabelIds` делает доп. fetch'и (`GET /labels`, `POST /labels`) — должны тоже идти через retry-логику (используют тот же `callForgejo`). - `OPENCODE_FORGEJO_RETRY=0` — мгновенный фейл без retry (валидный edge case). - `OPENCODE_FORGEJO_TIMEOUT=0` — невалидный, fallback на дефолт (логировать warning). ## Влияние на связанные компоненты - `create-issue.ts`, `create-pr.ts`, `merge-pr.ts` — проброс `context.abort` (1 строка на каждый). - `.env.example` — 3 новые переменные. - Новая конвенция: `context.abort` начинает использоваться впервые в репо — задел для других tool'ов. - Не влияет на `pipeline-status.py` (там своя retry-логика, конвенция совпадает — берём как референс). ## Вне scope - **Server-side Forgejo** (SQLite `PRAGMA busy_timeout`, webhook sync/async, search indexer, Nginx `proxy_read_timeout`) — отдельный issue в `forgejo-infra` после merge этого. - Глобальный tool-execution-timeout в `opencode.json` — opencode core, не этот репо. - Forgejo MCP-сервер — overkill, не решает проблему. - Ретраи на 4xx — намеренно исключены (клиентская ошибка). - Экспоненциальный backoff — намеренно исключён (репо использует линейный, `pipeline-status.py`). ## Критерии приемки - [ ] `callForgejo` использует `AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)])`. - [ ] `callForgejo` ретраит на timeout и 5xx, НЕ ретраит на 4xx. - [ ] `resolveForgejoRepo` `spawnSync` имеет `timeout: 5000`. - [ ] `create-issue.ts`, `create-pr.ts`, `merge-pr.ts` пробрасывают `context.abort`. - [ ] Env vars `OPENCODE_FORGEJO_TIMEOUT/RETRY/RETRY_INTERVAL` работают с дефолтами 10/3/1. - [ ] Логи ретраев/ошибок в `process.stderr.write` с `[forgejo]` prefix, успешные вызовы не логируются. - [ ] Тесты (`_shared.test.ts`, Bun test): timeout triggers retry, 5xx triggers retry, 4xx no-retry, retries-exhausted readable error, user-abort no-retry, `resolveLabelIds` respects timeout, `resolveForgejoRepo` spawnSync timeout. - [ ] `.env.example` задокументирован. - [ ] Существующие вызовы без `signal` продолжают работать (backward compatible). - [ ] Худший кейс latency ≤ 32с (вместо 5 минут).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slaid098/opencode-config#40
No description provided.