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
pull from: feat/184-autonomous-agent-runs
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:test/244-part-b
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/221-image-captions
vvzvlad:feat/git-sync
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/244-dataloss-bugs
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:develop
vvzvlad:feature/offline-sync
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
bug
documentation
duplicate
enhancement
epic
feature
good first issue
help wanted
idea
invalid
needs-human
question
refactor
review/approved
review/changes-requested
review/needs
security
status/blocked
status/done
status/in-progress
status/ready
test
wontfix
Something isn't working
Improvements or additions to documentation
This issue or pull request already exists
New feature or request
Large multi-phase effort spanning many changes
New functionality request
Good for newcomers
Extra attention is needed
Idea / proposal for discussion
This doesn't seem right
эскалация: нужно решение человека
Further information is requested
Code cleanup / refactoring
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Security / hardening issue
ждёт зависимость blocked_by
закрыто и проверено
в активной работе (мягкая заявка)
специфицировано, не заблокировано, ждёт исполнителя
Test coverage / test infrastructure
This will not be worked on
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#234
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/184-autonomous-agent-runs"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Реализует первую фазу #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— ЕДИНСТВЕННОЕ, что абортит ран; startupsweepRunning— crash-recovery).AiChatService.stream: опциональныеrunHooks(без них — легаси-путь без изменений); сигнал рана какabortSignal;runIdв metadatastart.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
6900c30935to4c0a4eb9ccF1 (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>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>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>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,runIdundefined, стримить как обычно». Эта resilience-ветка не драйвится ни одним тестом (grepstreaming 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реджектит plainnew Error('insert failed'); проверить, что stream() НЕ бросает ConflictException, что streamText вызван с abortSignal === socketSignal (legacy-фолбэк, runId undefined), и что ход всё равно стримится (user message сохранён). Это пиннит swallow-and-continue отдельно от 409-пути.Ревью
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 (кодеру НЕ делать · калибровка):
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.