feat(ai-chat): autonomous agent runs — phase 1: durable detached runs (#184) #234

Open
Ghost wants to merge 11 commits from feat/184-autonomous-agent-runs into develop

Реализует первую фазу #184 (большой proposal — полный объём на недели; здесь честный фундамент за флагом, дефолт off).

Фаза: «Durable detached agent runs»

Раньше ран агента жил ровно пока открыт HTTP-запрос (controller.abort() на 'close'). #183 (влит) уже персистит ассистент-строку по шагам и драйнит стрим через consumeStream независимо от сокета — так что единственное, что привязывало ход к браузеру, это abort-on-disconnect. Эта фаза вводит ран как объект первого класса и разрывает связку.

  • ai_chat_runs (миграция 20260627T130000): lifecycle-строка (pending→running→succeeded|failed|aborted, trigger, createdBy, ссылка на ассистент-сообщение #183, error, step_count, stop_requested_at). Частичный уникальный индекс WHERE status IN ('pending','running') = один активный ран на чат.
  • AiChatRunRepo + AiChatRunService (реестр runId→AbortController; requestStop — ЕДИНСТВЕННОЕ, что абортит ран; startup sweepRunning — crash-recovery).
  • AiChatService.stream: опциональные runHooks (без них — легаси-путь без изменений); сигнал рана как abortSignal; runId в metadata start.
  • Контроллер за флагом settings.ai.autonomousRuns: дисконнект логируется, а не абортит; 409 на конкурентный ран; POST /ai-chat/run (reconnect → последний персистнутый ран + сообщение); POST /ai-chat/stop (явный стоп).

Работает end-to-end (флаг on)

Старт хода → ран running → шаги персистятся → закрыл браузер → ход доезжает и персистится, ран succeeded/ai-chat/run отдаёт результат. Явный стоп → onAbort персистит частичное → ран aborted (stop_requested_at отличает от дисконнекта). Рестарт сервера → висячий ран → sweep → aborted.

Тесты: unit (ai-chat-run.service.spec) + integration (real Postgres, ai-chat-run.int-spec, 6) — disconnect≠stop, detached-персист-без-подписчика, уникальность активного рана, sweep. tsc + build + 460 unit зелёные.

Deferred (явно, честно)

Cross-process BullMQ-воркер + resumable live-stream по runId (нужен resumable-stream транспорт — открытое решение в самом issue); autonomy-триггеры (schedule/webhook/continue — колонка готова); бюджеты/kill-switch сверх ручного стопа; tool-идемпотентность на ретрае; экспорт ранов; клиентский UI (runId уже в metadata, /stop принимает chatId). Архитектура шейпнута так, чтобы воркер встал позже без смены схемы.

Closes #184 (phase 1).

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

🤖 Generated with Claude Code

Реализует **первую фазу** #184 (большой proposal — полный объём на недели; здесь честный фундамент за флагом, дефолт off). ## Фаза: «Durable detached agent runs» Раньше ран агента жил ровно пока открыт HTTP-запрос (`controller.abort()` на `'close'`). #183 (влит) уже персистит ассистент-строку по шагам и драйнит стрим через `consumeStream` независимо от сокета — так что единственное, что привязывало ход к браузеру, это abort-on-disconnect. Эта фаза вводит ран как объект первого класса и разрывает связку. - **`ai_chat_runs`** (миграция `20260627T130000`): lifecycle-строка (pending→running→succeeded|failed|aborted, trigger, createdBy, ссылка на ассистент-сообщение #183, error, step_count, stop_requested_at). **Частичный уникальный индекс** `WHERE status IN ('pending','running')` = один активный ран на чат. - **`AiChatRunRepo`** + **`AiChatRunService`** (реестр `runId→AbortController`; `requestStop` — ЕДИНСТВЕННОЕ, что абортит ран; startup `sweepRunning` — crash-recovery). - **`AiChatService.stream`**: опциональные `runHooks` (без них — легаси-путь без изменений); сигнал рана как `abortSignal`; `runId` в metadata `start`. - **Контроллер** за флагом `settings.ai.autonomousRuns`: дисконнект **логируется, а не абортит**; 409 на конкурентный ран; **`POST /ai-chat/run`** (reconnect → последний персистнутый ран + сообщение); **`POST /ai-chat/stop`** (явный стоп). ## Работает end-to-end (флаг on) Старт хода → ран running → шаги персистятся → **закрыл браузер → ход доезжает и персистится, ран succeeded** → `/ai-chat/run` отдаёт результат. Явный стоп → onAbort персистит частичное → ран `aborted` (`stop_requested_at` отличает от дисконнекта). Рестарт сервера → висячий ран → sweep → `aborted`. Тесты: unit (`ai-chat-run.service.spec`) + integration (real Postgres, `ai-chat-run.int-spec`, 6) — disconnect≠stop, detached-персист-без-подписчика, уникальность активного рана, sweep. tsc + build + 460 unit зелёные. ## Deferred (явно, честно) Cross-process BullMQ-воркер + resumable live-stream по runId (нужен resumable-stream транспорт — открытое решение в самом issue); autonomy-триггеры (schedule/webhook/continue — колонка готова); бюджеты/kill-switch сверх ручного стопа; tool-идемпотентность на ретрае; экспорт ранов; клиентский UI (runId уже в metadata, `/stop` принимает chatId). Архитектура шейпнута так, чтобы воркер встал позже без смены схемы. Closes #184 (phase 1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- state:review reviewed_head: 0ecddce7480e1266e2c802bdd68cf949f087b7e3 baseline_head: 0ecddce7480e1266e2c802bdd68cf949f087b7e3 verdict: changes-requested round: 6 max_rounds: 6 open_findings: [F14] reopened: {} -->
Ghost added 1 commit 2026-06-27 07:02:51 +03:00
Make an agent turn a first-class, server-side RUN that keeps executing and
persisting its steps after the browser window closes, and that a later client
can reconnect to — the core invariant of #184. Phase 1 only; the full proposal
(cross-process BullMQ runner, resumable live-tail transport, autonomy triggers,
budgets, history compaction) is explicitly deferred.

What lands:
- `ai_chat_runs` lifecycle table + repo: the run as a persistent object
  (status pending->running->succeeded|failed|aborted, trigger, createdBy,
  assistantMessageId projection link, error, step_count, timings). A partial
  unique index enforces ONE ACTIVE run per chat; a startup sweep recovers
  dangling runs (mirrors #183's sweepStreaming).
- AiChatRunService: owns the run lifecycle + an in-memory abort registry. The
  abort is governed by the RUN (an explicit user stop), NOT the HTTP socket —
  so a browser disconnect no longer ends the turn. Reuses #183's socket-
  independent durable write path (consumeStream + flushAssistant) unchanged.
- Controller, behind `settings.ai.autonomousRuns`: /stream wraps the turn in a
  run and does NOT abort on disconnect (logs only); a clean 409 rejects a
  concurrent run on the same chat; new POST /ai-chat/stop (explicit stop) and
  POST /ai-chat/run (reconnect -> latest persisted run + its projection). The
  runId is surfaced on the streamed start metadata. Flag OFF = byte-for-byte
  legacy behavior.

Tests: AiChatRunService unit spec (lifecycle, disconnect != stop, explicit
stop aborts the signal, best-effort sweeps); ai_chat_runs integration spec
(one-active-run index, detached persist+reconnect with no subscriber, explicit
stop, stale-run sweep). Server tsc + build clean; touched jest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-27 19:19:47 +03:00
The "one active run per chat" guard was bypassable under a race. Two
simultaneous POST /ai-chat/stream on the same chat both passed the
controller's pre-hijack 409 check (a check-then-act TOCTOU), then the
loser's INSERT into ai_chat_runs hit the partial unique index
(ai_chat_runs_one_active_per_chat, 23505). That error was SWALLOWED, so
the second turn streamed UNTRACKED: no runId, not targetable by /stop,
and (autonomousRuns on) onClose won't abort it -> an orphan unstoppable
run that also spends provider tokens.

Make the unique-index INSERT the authoritative gate:

- AiChatRunService.beginRun: when the run-row INSERT fails with a 23505 on
  ONE_ACTIVE_RUN_PER_CHAT_INDEX (via isUniqueViolation/violatedConstraint),
  no longer swallow it -> throw a distinct RunAlreadyActiveError. Any other
  error (incl. a 23505 on a different constraint) propagates unchanged.
- AiChatService.stream: when begin throws RunAlreadyActiveError, reject the
  turn with a 409 ConflictException (code A_RUN_ALREADY_ACTIVE) BEFORE any
  AI/provider call -> no tokens spent, no untracked turn. Other begin
  failures keep the legacy best-effort fallback (stream socket-bound).
- ai-chat.controller: post-hijack catch honors an HttpException's real
  status/body (clean 409) instead of a blanket 500, since the race 409 is
  raised before a byte is written. Pre-check 409 now carries the same code.

The controller's cheap pre-check stays as a fast-path for the common
sequential double-submit; the INSERT violation is the race-safe backstop.

Tests: ai-chat-run.service.spec proves beginRun throws RunAlreadyActiveError
on the active-index 23505 (and only that constraint), leaks no controller,
and an integration-style two-concurrent-begins test where exactly one wins;
new ai-chat.service.run-race.spec proves stream rejects with a 409
ConflictException BEFORE any streamText/generateText and never persists an
untracked turn. The latter fails without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-28 04:10:43 +03:00
Reopening a chat whose agent run is still going showed a frozen snapshot
from the moment it was opened. Add a passive-observer reconnect-poll path:
when this tab did NOT start the run locally, poll POST /ai-chat/run every
2s while the run is pending/running and merge its incrementally-persisted
assistant message into the thread, so new steps/tool-calls and the growing
text appear live. Polling stops on terminal status (refetchInterval keyed
on run.status, mirroring the reindex polling); a final messages invalidate
shows the persisted end state.

Observer-vs-streamer detection: ChatThread reports its local useChat
streaming status up; the window only polls/merges while NOT locally
streaming (the streamer's SSE owns the view — no double-render). Gated by
settings.ai.autonomousRuns; the query is disabled when the feature is off
so the flag-gated endpoint is never hit, and a failed fetch can't loop
(retry:false -> refetchInterval(undefined)=false).

Pure decisions (poll interval, observe gate, message merge) extracted to
run-polling.ts and unit-tested; added query enable-gating and ChatThread
observer-merge tests. Client-only change — the reconnect endpoint already
returns the run plus the assistant message with its metadata.parts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost force-pushed feat/184-autonomous-agent-runs from 6900c30935 to 4c0a4eb9cc 2026-06-28 14:54:49 +03:00 Compare
Ghost added the needs-human label 2026-06-28 22:18:12 +03:00
Ghost added review/changes-requested and removed needs-human labels 2026-06-28 22:24:49 +03:00
Ghost added 4 commits 2026-06-28 23:53:14 +03:00
F1 (DECISION C): make the crash-recovery boot sweep UNCONDITIONAL. A fast
restart (deploy/OOM within the old 10-min window of the last step) left a run
stuck `running` forever, and the one-active-run gate then 409'd every future
turn in that chat. On a fresh single-process boot any pending|running run is
definitionally hung, so onModuleInit now settles ALL of them to `aborted` with
no staleness window. AiChatRunRepo.sweepRunning takes an optional { staleMs }
window, kept ONLY for the future phase-2 multi-instance timer sweep (the boot
path passes no window). Repo + service tests assert a fresh `running` run
(updatedAt = now) is settled, not skipped.

F2 (DECISION A): treat phase-1 autonomousRuns as SINGLE-INSTANCE-ONLY. Stop and
its AbortController are process-local, so cross-instance Stop is unreliable
(phase 2). AiChatRunService now logs a startup WARNING when a horizontally-scaled
deployment is detected — via EnvironmentService.isCloud() (CLOUD=true), the only
horizontal-scaling signal this codebase has (the socket.io Redis adapter is
always wired since REDIS_URL is mandatory, so it is not a discriminator). The
constraint is documented in AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F3: the load-bearing `effectiveSignal = handle.signal` -> streamText
`abortSignal` had no test; a regression to the socket-bound signal would pass
green and silently break Stop + durability. Add a happy-path test (runHooks.begin
returns the run signal -> streamText is driven with abortSignal === handle.signal,
NOT the socket) and a legacy-path test (no runHooks -> the socket signal is used).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F4: getRun was typed Promise<{ run: unknown; message: unknown }> while its
siblings are concrete. Import AiChatRun + AiChatMessage and return
Promise<{ run: AiChatRun | null; message: AiChatMessage | null }>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F5: document the #184 feature under [Unreleased] -> Added — runs survive a
browser disconnect, reconnect-and-live-follow, POST /ai-chat/run + /ai-chat/stop,
the settings.ai.autonomousRuns flag, the ai_chat_runs table, and the phase-1
single-instance constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added status/in-progressreview/needs and removed review/changes-requested labels 2026-06-29 00:17:45 +03:00
Ghost added review/approved and removed review/needs labels 2026-06-29 00:31:41 +03:00
Ghost added review/changes-requested and removed review/approved labels 2026-06-29 01:06:50 +03:00
Ghost added 1 commit 2026-06-29 01:24:15 +03:00
Round-2 review fixes for PR #234 (#184 autonomous agent runs).

F6 (stability): finalizeRun no longer drops the in-memory entry before the
terminal write. It now UPDATEs first with a bounded retry; only on success does
it arm the idempotency once-gate (a new `settled` set keyed on "row already
terminal", not "entry deleted") and free the chat's active slot. If every
attempt fails the entry is RETAINED and the run left unsettled so a later
finalize / requestStop->onAbort / sweep can retry — a transient blip can no
longer strand a run 'running' and 409 every future turn in the chat. Idempotency
preserved (double-settle still collapses to a single write).

F7 (regression from F2): int-spec constructs AiChatRunService with the 2nd
EnvironmentService arg ({ isCloud: () => false }) so the file type-checks and all
integration tests compile+run again.

F8 (regression from F1): the windowed "stale but not fresh" case now calls
sweepRunning({ staleMs: SWEEP_RUN_STALE_MS }); added an int-level variant-C case
proving the no-arg boot sweep aborts even a FRESH running run.

F9 (coverage): run-race spec now captures streamText's options and invokes
onStepFinish/onFinish/onAbort/onError, asserting the #184 run hooks
(onStep / onSettled completed|aborted|error) fire with the right args.

F10 (docs): added an autonomousRuns single-instance-only note to .env.example so
the warnIfMultiInstance JSDoc reference is accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-29 01:35:08 +03:00
The F6 once-gate was non-atomic: `settled.has` was read BEFORE the awaited
terminal UPDATE and `settled.add` only after, so two concurrent finalizeRun
calls for the same run (the documented safety-net catch vs a streamText
terminal callback) both passed the check and both wrote the terminal row —
double-write + last-write-wins status clobber, a window the bounded retry only
widened.

Restore a SYNCHRONOUS atomic claim before any await: capture the entry, then
`active.delete` as a check-and-clear in one tick. The first caller claims and
proceeds; a concurrent second caller finds the entry gone and returns at the
claim, before any UPDATE. On a successful write we arm `settled` (post-write
idempotency gate) and do not restore; on total bounded-retry failure we restore
the claimed entry so a retrier can complete it — never both write and restore.

Also fix the F6(b) JSDoc/comment to not overclaim an in-process retrier on the
no-streamText path: there the only settler is the safety-net, so recovery on
total UPDATE failure is the unconditional boot sweep on the next restart.

Adds a concurrency test firing two simultaneous finalizeRun on one run (update
held on a pending promise) asserting update is called EXACTLY ONCE; existing F6
retry-rides-transient + retain-on-total-failure tests stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/needs and removed review/changes-requested labels 2026-06-29 01:39:30 +03:00
Ghost added review/changes-requested and removed review/needs labels 2026-06-29 01:48:01 +03:00
Ghost added 1 commit 2026-06-29 02:13:52 +03:00
F12 [suggestion]: finalizeRun's "all retries exhausted" path only logged
per-attempt warns ("attempt 3/3") then silently restored the in-memory
entry, giving no clear signal that the run row was left non-terminal
('running') pending recovery. Emit ONE greppable ERROR with context
(runId, chatId, final error) on give-up, matching the import-attachment
retry-loop pattern, so an operator can tell a survived blip from a give-up.

F13 [suggestion]: the "ORDER MATTERS (F6)" doc overclaimed that a later
settle "can retry" the terminal write as an in-process retrier. Correct it:
in-process retry is only POSSIBLE (not guaranteed) and only once the entry
is restored AND a fresh settler arrives afterwards; a concurrent settler in
the retry window is consumed at the synchronous active.delete claim, and the
no-streamText path has no second settler at all. The UNCONDITIONAL backstop
in every case is the boot sweep on the next restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/needs and removed review/changes-requested labels 2026-06-29 02:16:32 +03:00
Ghost added review/approved and removed review/needs labels 2026-06-29 02:27:23 +03:00
Collaborator

F14 [warning] apps/server/src/core/ai-chat/ai-chat.service.ts:397-416 — else-ветка catch у runHooks.begin не покрыта тестом. У begin-catch две ветки: RunAlreadyActiveError → 409 (404-408, залочена run-race.spec) и else (410-415) — «любой ДРУГОЙ сбой старта run не должен ломать ход: залогировать Failed to begin agent run … streaming without run tracking и продолжить с effectiveSignal = signal, runId undefined, стримить как обычно». Эта resilience-ветка не драйвится ни одним тестом (grep streaming without run tracking по спекам — пусто). Контракт намеренный: транзиентный сбой beginRun (напр. не-unique DB-ошибка на insert run-row) обязан всё равно обслужить ход пользователя по сокет-сигналу, без трекинга. Регрессия, которая ре-throw'ит вместо swallow или классифицирует ошибку как 409, ломала бы КАЖДЫЙ ход при сбое старта run — и ничто бы не поймало (все тесты дают begin либо чистый handle, либо RunAlreadyActiveError).

Fix: добавить в ai-chat.service.run-race.spec.ts (или lifecycle) тест: runHooks.begin реджектит plain new Error('insert failed'); проверить, что stream() НЕ бросает ConflictException, что streamText вызван с abortSignal === socketSignal (legacy-фолбэк, runId undefined), и что ход всё равно стримится (user message сохранён). Это пиннит swallow-and-continue отдельно от 409-пути.

F14 [warning] `apps/server/src/core/ai-chat/ai-chat.service.ts:397-416` — else-ветка catch у `runHooks.begin` не покрыта тестом. У begin-catch две ветки: RunAlreadyActiveError → 409 (404-408, залочена run-race.spec) и else (410-415) — «любой ДРУГОЙ сбой старта run не должен ломать ход: залогировать `Failed to begin agent run … streaming without run tracking` и продолжить с `effectiveSignal = signal`, `runId` undefined, стримить как обычно». Эта resilience-ветка не драйвится ни одним тестом (grep `streaming without run tracking` по спекам — пусто). Контракт намеренный: транзиентный сбой beginRun (напр. не-unique DB-ошибка на insert run-row) обязан всё равно обслужить ход пользователя по сокет-сигналу, без трекинга. Регрессия, которая ре-throw'ит вместо swallow или классифицирует ошибку как 409, ломала бы КАЖДЫЙ ход при сбое старта run — и ничто бы не поймало (все тесты дают begin либо чистый handle, либо RunAlreadyActiveError). Fix: добавить в ai-chat.service.run-race.spec.ts (или lifecycle) тест: `runHooks.begin` реджектит plain `new Error('insert failed')`; проверить, что stream() НЕ бросает ConflictException, что streamText вызван с abortSignal === socketSignal (legacy-фолбэк, runId undefined), и что ход всё равно стримится (user message сохранён). Это пиннит swallow-and-continue отдельно от 409-пути.
Collaborator

Ревью 0ecddce74 — переревью ПОЛНЫМИ 8 аспектами (отдельный субагент на каждый). Вердикт: CHANGES.
Раскладка: security / stability / regressions / conventions / documentation / architecture — LGTM. test-coverage — новая F14. simplification — кандидат дропнут (ниже).
Подтверждено: owner-gate и workspace-scoping detached-run'ов; finalizeRun атомарный once-claim + bounded retry + give-up ERROR; boot-sweep вариант C; single-instance вариант A (warn+доки); legacy-стриминг при off не затронут; миграция обратима; покрытие lifecycle/boot-sweep/abortSignal/terminal-колбэков плотное.
Открыто: F14 (warning, test-coverage — else-ветка begin() fallback «стримить без трекинга» без теста). Прошлые F1–F13 закрыты.

DROP (кодеру НЕ делать · калибровка):

  • [below-threshold] suggestion/medium [simplification] удалить staleMs-scaffolding (SWEEP_RUN_STALE_MS + ветка + тест) как неиспользуемое в phase 1 — ai-chat-run.repo.ts:24,188,205-207. Дроп: architecture-аспект подтвердил, что это осознанный, явно помеченный phase-2 seam (multi-instance timer sweep); команда ведёт staged-эпики, разумный автор оставит.
Ревью 0ecddce74 — переревью ПОЛНЫМИ 8 аспектами (отдельный субагент на каждый). Вердикт: CHANGES. Раскладка: security / stability / regressions / conventions / documentation / architecture — LGTM. test-coverage — новая F14. simplification — кандидат дропнут (ниже). Подтверждено: owner-gate и workspace-scoping detached-run'ов; finalizeRun атомарный once-claim + bounded retry + give-up ERROR; boot-sweep вариант C; single-instance вариант A (warn+доки); legacy-стриминг при off не затронут; миграция обратима; покрытие lifecycle/boot-sweep/abortSignal/terminal-колбэков плотное. Открыто: F14 (warning, test-coverage — else-ветка begin() fallback «стримить без трекинга» без теста). Прошлые F1–F13 закрыты. ⛔ DROP (кодеру НЕ делать · калибровка): - [below-threshold] suggestion/medium [simplification] удалить staleMs-scaffolding (SWEEP_RUN_STALE_MS + ветка + тест) как неиспользуемое в phase 1 — ai-chat-run.repo.ts:24,188,205-207. Дроп: architecture-аспект подтвердил, что это осознанный, явно помеченный phase-2 seam (multi-instance timer sweep); команда ведёт staged-эпики, разумный автор оставит.
agent_reviewer added review/changes-requested and removed review/approved labels 2026-06-29 04:21:04 +03:00
This pull request has changes conflicting with the target branch.
  • CHANGELOG.md
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/184-autonomous-agent-runs:feat/184-autonomous-agent-runs
git checkout feat/184-autonomous-agent-runs
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#234