From 9ba97b6d2be95f3010ae2d8fa28a33341d58bda8 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 07:49:14 +0300 Subject: [PATCH] =?UTF-8?q?docs(ai-chat):=20=D0=B7=D0=B0=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4=D0=B0=D0=B5?= =?UTF-8?q?=D0=BC=D1=83=D1=8E=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20#487=20(F2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG [Unreleased]: серверный supersede + три кода (SUPERSEDE_INVALID / SUPERSEDE_TARGET_MISMATCH / SUPERSEDE_TIMEOUT) в Added; смена поведения (легаси вторая вкладка теперь → 409 A_RUN_ALREADY_ACTIVE вместо второго параллельного стрима; каждый ход — ран в обоих режимах) в Changed. - .env.example: три новых AI_CHAT_* (SUPERSEDE_TIMEOUT_MS / RECONCILE_INTERVAL_MS / INAPP_TOOL_CALL_CAP_MS, дефолты 10000/120000/120000, связь cap↔staleness). - AGENTS.md:458: ран теперь универсален (оба режима), флаг autonomousRuns = только disconnect-семантика; плюс упоминание supersede. - ai-chat.service.ts:1136: устаревший коммент про «legacy → socket signal» → оба режима на run-signal, guard runId защищает лишь no-handle fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 23 +++++++++++++++++++ AGENTS.md | 2 +- CHANGELOG.md | 22 ++++++++++++++++++ .../src/core/ai-chat/ai-chat.service.ts | 15 ++++++------ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index e490f7c9..f3acb7ba 100644 --- a/.env.example +++ b/.env.example @@ -288,6 +288,29 @@ MCP_DOCMOST_PASSWORD= # registry is process-local). # AI_CHAT_RESUMABLE_STREAM=false +# --- Run lifecycle tunables (#487) --- +# These govern the universal run machinery (every turn is now a first-class run, +# both modes) and rarely need changing. +# +# How long a server-side SUPERSEDE ("interrupt and send now") waits for the target +# run to settle after issuing Stop before it degrades to a 409 SUPERSEDE_TIMEOUT +# (nothing sent, the composer keeps the user's text). 10s is generous under a +# healthy DB; do NOT raise it to paper over a slow DB — a SUPERSEDE_TIMEOUT is the +# honest signal. Default 10000 (10s). +# AI_CHAT_SUPERSEDE_TIMEOUT_MS=10000 +# +# How often the periodic bidirectional reconcile job runs (heals runs/messages +# left dangling by a crash or a lost terminal write). Default 120000 (2 min). +# AI_CHAT_RECONCILE_INTERVAL_MS=120000 +# +# Wall-clock cap for a SINGLE in-app tool call (a long paginated read, or a content +# write whose collab commit hangs) — the per-call half of the composite abort +# signal every in-app tool is wrapped with (the other half is the turn's Stop). +# The reconcile staleness floor is derived as max(2 x this cap, 15min), so a very +# high value delays stale-run recovery (the server boot-warns above 30min). Default +# 120000 (2 min). +# AI_CHAT_INAPP_TOOL_CALL_CAP_MS=120000 + # --- Anonymous public-share AI assistant --- # Opt-in per workspace (AI settings -> "public share assistant"; off by default). # When enabled, anonymous visitors of a published share can ask an AI about that diff --git a/AGENTS.md b/AGENTS.md index 08756773..dd548743 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -455,7 +455,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes - `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration). - `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint. - `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic. - - `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart. + - `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **every agent turn is now a first-class server-side RUN** (`#184`, universalized in `#487`): its lifecycle is tracked in `ai_chat_runs` in **both** modes, and the single-active-run-per-chat concurrency gate is enforced universally (a legacy second tab now gets a clean `409 A_RUN_ALREADY_ACTIVE` instead of a second parallel stream that interleaved history). The per-workspace `settings.ai.autonomousRuns` flag (off by default) **no longer gates whether a turn is a run** — it now controls **only the browser-disconnect semantics**: when ON the run is *detached* (a disconnect leaves it executing server-side; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`); when OFF (legacy) a disconnect ends the turn by stopping its run via the run's stop lever. `#487` also adds a server-side **supersede** CAS ("interrupt and send now") to `POST /ai-chat/stream` (`supersede: { runId }`): it atomically stops the chat's currently-active run and waits for it to settle before the new turn claims the slot, returning `SUPERSEDE_INVALID` / `SUPERSEDE_TARGET_MISMATCH` / `SUPERSEDE_TIMEOUT` on the non-proceed branches. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart. ### Client structure Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions: diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4d5a03..30d27413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -202,6 +202,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is not yet reliable); the server warns at startup on a horizontally-scaled deployment. (#184) +- **Server-side "interrupt and send now" (supersede) for AI chat.** `POST + /ai-chat/stream` now accepts a `supersede: { runId }` field: when the user sends + a new message while a run is active, the server atomically stops that run and + waits for it to settle before the new turn claims the chat's single run slot, + instead of the send being rejected as concurrent. The compare-and-set surfaces + three codes on its non-proceed branches — `SUPERSEDE_INVALID` (the targeted run + is malformed / belongs to another chat), `SUPERSEDE_TARGET_MISMATCH` (a + different run is now active; carries the current `activeRunId`), and + `SUPERSEDE_TIMEOUT` (the previous run did not stop within the settle window, so + nothing was sent and the composer keeps the text). Tunable via + `AI_CHAT_SUPERSEDE_TIMEOUT_MS` (default 10s). (#487) - **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A new MCP tool serializes a whole page (its full ProseMirror JSON, with every internal image/file mirrored) into an ephemeral in-RAM blob and returns only @@ -282,6 +293,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Every AI-chat turn is now a first-class server-side run, and one run per chat + is enforced in both modes.** The run machinery from `#184` was universalized: a + turn is tracked in `ai_chat_runs` and gated by the single-active-run-per-chat + index regardless of the `settings.ai.autonomousRuns` flag. **Behavior change:** + a second tab (or a double-submit) that starts a turn while one is already active + on the chat is now rejected up front with `409 A_RUN_ALREADY_ACTIVE` (carrying + the `activeRunId`); previously, on the legacy path, it opened a second parallel + stream on the same chat that interleaved history. The `autonomousRuns` flag no + longer controls whether a turn is a run — it now governs **only** the + browser-disconnect semantics (ON = detached/survives a disconnect; OFF = a + disconnect stops the run). (#487) - **Client markdown paste/copy and AI-chat rendering now go through the canonical converter.** Pasting markdown into the editor, "Copy as markdown", the AI title generator, and the AI-chat markdown renderer all now use diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index 959d2d44..02ecc9d1 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -1131,14 +1131,13 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy { ); } catch (err) { // An explicit Stop reached the RUN's signal DURING setup: re-throw so the - // outer catch finalizes the run as aborted — never swallow a Stop. Gated on - // `runId`: the re-throw exists ONLY to finalize the run, which exists only - // in autonomous mode. On the legacy path (no runId) `effectiveSignal` is the - // SOCKET signal (it aborts on a client disconnect); re-throwing there would - // change prior behavior and make the controller write JSON to an already- - // closed socket (it only attaches res.raw.on('error') in autonomous mode). - // So legacy keeps its prior behavior — warn + proceed, and streamText then - // observes the aborted socket signal. + // outer catch finalizes the run as aborted — never swallow a Stop. #487: the + // turn is ALWAYS run-wrapped now (both modes), so `effectiveSignal` is the + // RUN signal and `runId` is set in BOTH — a Stop (from /ai-chat/stop or a + // legacy disconnect's requestStop) aborts it identically. The `runId` guard + // now only defends the theoretical no-handle fallback (`begin` returned + // nothing, leaving `effectiveSignal` as the bare socket signal): there we + // keep the old warn-and-proceed rather than re-throw. if (runId && effectiveSignal.aborted) { throw err; }