Skip to content

refactor(chat): split routers/chat.py into thin handlers + bounded services (#1483)#1695

Merged
vybe merged 13 commits into
devfrom
AndriiPasternak31/issue-1483
Jul 19, 2026
Merged

refactor(chat): split routers/chat.py into thin handlers + bounded services (#1483)#1695
vybe merged 13 commits into
devfrom
AndriiPasternak31/issue-1483

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #1483

Summary

Enforces Invariant #1 (Router → Service → DB) for the chat surface by splitting the monolithic routers/chat.py (2,756 → 988 lines) into a thin, HTTP-only router over four new leaf services:

  • chat_execution_service/chat setup + the sync-chat run_chat_turn applier (decomposed per plan §3a), the /task dispatch orchestration (derive/spoof, file upload, row+activity creation, async/sync fork), and terminate_execution. HTTP-free — raises ChatDispatchError, mapped 1:1 at the router.
  • dispatch_admission_service — request admission for /chat and /task: idempotency begin/replay + audit, the /chat pure-state breaker read (feat: per-agent dispatch circuit breaker to prevent backlog poisoning (RELIABILITY-007) #526 F1), and CapacityManager.acquire for /chat.
  • chat_persistence_service — authenticated /task chat-session persistence (bug: async chat with save_to_session completes but never creates a chat session #1444): persist_chat_session (SUCCESS-guard + IDOR owner-check + fail-loud no-user-content ERROR log) + persist_and_broadcast_chat_session.
  • chat_signals — dependency-free leaf holding the chat domain signals (ChatAdmission/ChatExecutionContext, ChatAdmissionReplay, ChatDispatchError).

task_execution_service is byte-untouched — the split delegates /task sync/async to the existing execute_task / apply_result and never introduces a second /task applier, keeping the pull-migration single-applier seams intact. run_chat_turn is the ONE remaining divergent terminal writer (sync-chat does its own chat_sessions persistence + mode="chat" prompt that execute_task doesn't) and is declared transitional — converging it onto execute_task is a tracked follow-up (a genuine behaviour change, out of #1483's scope).

OpenAPI proof (no behaviour change)

The API surface is byte-identical before and after the split, reproduced independently in this stage against origin/dev:

paths operations normalized SHA256
origin/dev (pre-split, chat.py 2,756 ln) 372 477 05f0de8d6a7018a57a6436808322009351205437257212b0b3d33907ccc9f1c5
HEAD (split, chat.py 988 ln + 4 services) 372 477 05f0de8d6a7018a57a6436808322009351205437257212b0b3d33907ccc9f1c5

Same 372 paths / 477 operations, identical SHA256 — first proven at review, independently re-derived here from a fresh origin/dev worktree. (OpenAPI is order-independent, so it is blind to route order — see the dedicated route-order guard below.)

Preserved guards (byte-for-byte)

Verification

  • Char + guard suites: 36/36 (test_1483_execute_parallel_task_characterization, test_1483_run_chat_and_finalize_characterization, test_1483_ws_setters_wired, test_1483_route_order). The route-order gate was hardened at review to survive a flattened route table under the pinned fastapi 0.115.6; re-confirmed here (also green under a newer fastapi).
  • Patch-site batch: 143/143 across the 11 modified pre-existing suites (test_1332, test_1444, test_1457, test_1578, test_1672, test_679, test_946, test_async_task_persistence, test_backlog, test_chat_admission, test_chat_dispatched_marker), 0 failures in-sweep.
  • verify-local --skip-agent: PASS (prior stage — full prod-image build + booted sibling stack + unit/integration suites).
  • Broad sweep: zero new failures vs origin/dev.
  • SHIP-stage fix 1 (standalone-only test): the two bug: async chat with save_to_session completes but never creates a chat session #1444 fail-loud tests were monkeypatching chat_mod.db, but after the split persist_chat_session binds db inside chat_persistence_service; the chat_mod fixture pops database from sys.modules, so chat_mod.db is a different DatabaseManager instance than the one the code calls. The tests passed in-sweep (import order made the two coincide) but failed standalone. Repointed both patches to chat_persistence_service.db (the object the function actually calls) — now green standalone and in-batch, no product-code change.
  • SHIP-stage fix 2 (collection self-guard): test_1483_route_order errored the whole-directory tests/unit collection (ModuleNotFoundError: utils.password_validation) because its module-scope import main hits the tests/utils shadow — a permanently-red [E] in CI's regression-diff, which collects the whole tree. Wrapped that import so a polluted-sweep import failure becomes a loud module-level pytest.skip instead of a collection error. Verified both modes: standalone 4 passed; whole-tree collect-only 4437 collected, exit 0, 0 errors (was exit 1, 1 error). Test-only, no product-code change.

Reviewer note

Run the route-order guard standalone to exercise the real assertions:

pytest tests/unit/test_1483_route_order.py    # 4 passed

It imports the assembled main app at module scope (cross-router order needs the whole app). In the whole-directory tests/unit collection an earlier module binds sys.modules['utils'] to the repo's tests/utils package (pythonpath lists tests before src/backend), which has no password_validation submodule — so import main (via routers/setup.py's from utils.password_validation import ...) would ModuleNotFound and ERROR collection. The module now self-guards: it wraps that import main and, on the polluted-sweep import failure, issues a loud module-level pytest.skip(...) (reason: "requires pristine sys.modules — run standalone"). So in the full collection this file is SKIPPED, not errored (whole-tree collect-only: 4437 collected, exit 0), and standalone it runs 4/4.

Follow-ups (for Andrii to file)

🤖 Generated with Claude Code

AndriiPasternak31 and others added 11 commits July 18, 2026 17:47
Pin the observable behavior of routers/chat.py's two monsters and the
Invariant #4 route-order landmine BEFORE any code moves (issue-mandated
TDD spine), so every extraction step can be held byte-identical.

- test_1483_run_chat_and_finalize_characterization.py: the sync-chat
  execute+finalize path — SUCCESS (UUID-validated claude_session_id,
  activity completion, idempotency snapshot, slot release), the full
  SUB-003 429/auth switch matrix (switch-authoritative / switch-raised /
  switch-real-errored / no-switch — the top-risk branch), budget
  exhausted, and #678 partial-metadata salvage. Anchored on
  sys.modules[fn.__module__] so patch targets follow the move.
- test_1483_execute_parallel_task_characterization.py: the /task monster
  end-to-end — sync immediate/backlog(drain + row-reconstruction), async
  queued-202 (#914 shape) / accepted-202, #1672 resume 400/404,
  SELF-EXEC-001 403 spoof, idempotency replay 409/snapshot, #1444
  chat_persist_failed marker, the upload-502-keeps-idem quirk (RD11), and
  the #1578 reserved-event triggered_by="event" sinks.
- test_1483_route_order.py: proves GET /executions/running resolves to
  chat's get_agent_running_executions (not schedules' get_execution) via
  the app's real match order — OpenAPI is blind to route order (#1483 §5).

35 new tests green against unmodified code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#1483

Move `_persist_chat_session` + `_persist_and_broadcast_chat_session` out of
routers/chat.py into services/chat_persistence_service.py (Invariant #1 — the
router holds no DB/persistence logic). Byte-for-byte preserved: the #1444
SUCCESS-guard, the IDOR owner-check that falls through to the caller's own
session, and the fail-loud non-fatal ERROR log carrying only agent name +
execution_id + exception type (no user content, no re-raise). The
chat_response_ready WebSocket broadcast moves with it and gets its own
set_websocket_manager wired in main.py (per-service setter pattern).

The router imports the service module and delegates at both call sites (the
sync /task branch and the async wrapper). No route/model/status change; OpenAPI
byte-identical.

Tests repointed in-commit (module-identity): test_1444 direct calls + the
fail-loud caplog logger name (now services.chat_persistence_service, RD13);
test_async_task_persistence patches persist_chat_session + mirrors the service
_websocket_manager for the chat_response_ready broadcast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1483

Move the request-admission orchestration out of routers/chat.py (Invariant #1).
New leaf services:

- services/chat_signals.py — dependency-free domain signals the chat services
  return/raise instead of touching FastAPI: ChatAdmission / ChatExecutionContext
  (relocated NamedTuples), ChatAdmissionReplay (idempotent-replay outcome), and
  ChatDispatchError (HTTP-free error carrying the status/detail/headers the
  router maps 1:1).
- services/dispatch_admission_service.py — the /chat admission gate
  (admit_chat_request: idempotency begin/replay + audit, the #526 F1 pure-state
  breaker read, CapacityManager.acquire) and the shared /task idempotency
  begin/replay (begin_task_idempotency + audit_idempotent_replay). HTTP-free: it
  returns ChatAdmission/ChatAdmissionReplay and raises the already-domain
  CircuitOpen/CapacityFull/EphemeralBudgetExhausted; the thin router maps them to
  503/429/410 (the FAILED-row-write + raise stay in the router's _raise_* helpers,
  RD-E12). Named "dispatch" not "chat" because it serves both endpoints (RD2).

Preserved byte-for-byte: idempotency release-vs-keep (upfront deny → fail), the
audit rows, the breaker fast-fail, and every deny status/detail. OpenAPI
byte-identical (372 paths). Route-order guard green.

Tests repointed in-commit (module-identity): admission collaborators now patched
at dispatch_admission_service in test_chat_admission / test_946 / test_1578 / the
#1483 /task characterization suite; the direct admit-helper test imports from the
new service + chat_signals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#1483)

Move the /chat business logic out of routers/chat.py into
services/chat_execution_service.py (Invariant #1), decomposing the CC-57
_run_chat_and_finalize monster (AC #2):

- prepare_chat_execution (was _prepare_chat_execution): exec record + subscription
  + collaboration broadcast/activity + session + chat-start activity + user-msg log.
- broadcast_collaboration_event (moved, own set_websocket_manager wired in main.py).
- run_chat_turn (was _run_chat_and_finalize, declared TRANSITIONAL — RD15) split into
  build_chat_payload / _finalize_chat_success / _parse_agent_http_error /
  _finalize_budget_exhausted / _finalize_http_failure / _apply_sub003_autoswitch.
  Each ≤ CC 20. HTTP-free: failure paths raise ChatDispatchError, mapped 1:1 by the
  thin chat_with_agent handler; the lone fastapi touch is the defensive
  `except HTTPException: raise` preserving SUB-003 propagate-unchanged semantics.

Byte-for-byte preserved: MEM-001 runtime-aware prompt, #686 mark-dispatched-before-POST,
UUID-validated claude_session_id, #1332 read-before-close mirroring, #678 partial-
metadata salvage, the full SUB-003 429/auth switch matrix, and the finally slot+idem
release. routers/chat.py: 2756 → 1889 lines; unused imports trimmed. OpenAPI
byte-identical (372 paths).

Tests repointed in-commit: the run_chat_turn char suite + test_chat_admission's direct
finalize/prepare tests (now raise ChatDispatchError, patch _CE collaborators); the
test_chat_dispatched_marker AST/mirror guards now parse chat_execution_service.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decompose the CC-82 execute_parallel_task monster (AC #2). The router keeps only
the request-boundary guards (container 404/503, #1672 resume IDOR gate, #1068
timeout normalization — the redis-via-router helper stays here per RD10) and a
thin call that maps ChatDispatchError → HTTPException / ChatAdmissionReplay →
409/200. Everything else — derive/spoof (403), idempotency begin/replay, file
upload (502), row+activity creation, and the async/sync fork — moves to
chat_execution_service, each function ≤ CC 20 / ≤ 150 lines:

  dispatch_parallel_task, derive_source_and_trigger, process_task_file_uploads,
  create_task_execution_and_activities, _acquire_task_capacity, _map_task_failure,
  _dispatch_async, _dispatch_sync{,_immediate,_backlog}, run_async_task,
  complete_collaboration_activity, finalize_self_task (kept WHOLE, RD9).

execute_parallel_task: CC 82 → 13; routers/chat.py: 2756 → 1132 lines. The sync
paths still delegate to task_execution_service.execute_task (never a 2nd applier,
RD1). Circuit/ephemeral 503/410 + FAILED-write mirrored in the service (single
place per path — no double-write, RD-E12). #1578 reserved-event tag flows through
every sink; #914 queued-202 shape, #1444 chat_persist_failed, and the upload-502
no-idempotency-fail quirk (RD11) preserved. OpenAPI byte-identical.

- Repoint the production importer backlog_service.py:265 →
  services.chat_execution_service.run_async_task (kept LAZY — breaks a real cycle).
- The chat router no longer holds a WebSocket manager (all broadcasts moved to the
  two services, each with its own setter — §7 minimize-WS-globals); main.py wiring
  updated. Unused router imports trimmed.
- Tests repointed in-commit to the new collaborator modules (module-identity):
  the /task char suite, test_946, test_1578, test_backlog (fake-module + AST guard),
  test_1332, test_1457, test_async_task_persistence, test_1444, test_1672.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#1483

Decompose terminate_agent_execution (CC 22, 177 lines — the last function over
the AC #2 gate) into chat_execution_service, HTTP-free:

  terminate_execution (orchestrator), _cancel_queued_if_queued (BACKLOG-001),
  _proxy_terminate_and_finalize (agent-proxy + force-release + #679 CANCELLED CAS
  + final activity), _close_dispatch_activity_cancelled (#1332).

The router handler is now a thin mapper (ChatDispatchError → HTTPException). The
agent-proxy stays inline rather than reusing
task_execution_service.terminate_execution_on_agent — that helper returns a bool
and swallows connect/timeout, so reusing it would drop the 502/504/404 the router
surfaces (a behavior change). #679 already-finished-vs-terminated and the #1332
CAS-gated dispatch-activity close preserved byte-for-byte.

Result: EVERY function in routers/chat.py + the chat services is now ≤ CC 20 and
≤ 150 lines (AC #2 met). routers/chat.py: 2756 → 988 lines. OpenAPI byte-identical.
Unused router imports trimmed. test_679_terminate / test_1332 terminate patches
repointed to chat_execution_service (httpx.AsyncClient stays a global patch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1483 §7)

Static AST check: any `set_*_ws_manager` alias imported into main.py must also be
called at startup wiring. Mitigates the silent-no-op hazard the split introduced
by fanning the chat broadcasts across chat_execution_service /
chat_persistence_service — a missed setter leaves the module global None and the
broadcast vanishes (invisible to the OpenAPI diff and manager-patching tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- architecture.md: add chat_execution_service / dispatch_admission_service /
  chat_persistence_service / chat_signals to the Backend Services catalog; note
  task_execution_service stays the single terminal applier; repoint the
  chat_sessions/chat_messages persistence citation to chat_persistence_service.
- feature-flows.md: Recent Updates row for #1483.
- authenticated-chat-tab.md: persistence now in chat_persistence_service (logger
  name change); async wrapper now chat_execution_service.run_async_task.
- task-execution-service.md: sync-chat sibling note — run_chat_turn is the
  transitional divergent applier; /task delegates here; single-applier seams
  untouched; convergence is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…services

The async /task wrapper (run_async_task) and #1444 persistence now live in
chat_execution_service / chat_persistence_service; the router keeps only thin
handlers. Historical Recent-Updates rows are left as dated records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tened route table

test_chat_router_precedes_schedules_router_in_include_order failed on the pinned
fastapi==0.115.6 (and 0.124.2): its _include_index helper only handled an
_IncludedRouter-wrapped app.routes, but include_router flattens routes into plain
APIRoute entries in both versions — so the endpoint lookup always fell through to
AssertionError. Reuse the same both-shapes handling already in
_flatten_in_match_order (flattened APIRoute in app.routes, or legacy
_IncludedRouter wrapper). The three load-bearing Match.FULL resolution tests were
unaffected. 4/4 now green on fastapi 0.115.6 and 0.124.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1483)

The two fail-loud persistence tests monkeypatched `chat_mod.db`, but after
the #1483 split `persist_chat_session` lives in `chat_persistence_service`
and binds `db` at its own import. The `chat_mod` fixture pops `database`
from `sys.modules` and reimports `routers.chat`, so `chat_mod.db` is a
different DatabaseManager instance than the one the function calls — the
injected boom only landed when a full-sweep import order made the two
coincide, so the tests passed in-batch but failed standalone.

Repoint both patches to `chat_persistence_service.db` (the instance the
code under test actually calls). No product-code change; the tests now pass
standalone and in-batch, keeping the #1444 fail-loud + PII-safety guard
order-independent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndriiPasternak31 AndriiPasternak31 self-assigned this Jul 18, 2026
…ep (#1483)

The full `tests/unit` collection binds `sys.modules['utils']` to the repo's
`tests/utils` package (pythonpath lists `tests` before `src/backend`), which
has no `password_validation` submodule. This module imports the assembled
`main` app at module scope, and `main` → `routers/setup.py` does
`from utils.password_validation import ...`, so under the whole-directory
collection the import dies with ModuleNotFoundError and the module ERRORS
collection — a permanently-red entry in CI's regression-diff (which runs the
whole matrix). Standalone, `utils` resolves to `src/backend/utils` and the
import is clean.

Wrap the module-scope `import main` so a polluted collection produces a loud
module-level `pytest.skip(...)` (with a run-standalone reason) instead of a
collection error. Keying on the actual `import main` outcome is bulletproof
for the success path: standalone still imports cleanly and runs 4/4; the full
collection goes from "4437 collected, 1 error" (exit 1) to "4437 collected"
(exit 0), with this file skipped in-collection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review July 18, 2026 22:08
@AndriiPasternak31
AndriiPasternak31 requested review from dolho and vybe July 18, 2026 22:08
@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr: OpenAPI byte-identical proof (same 372 paths/477 ops SHA), task_execution_service untouched (single-applier seam preserved), all #1444/#1578/#914/#1457 guards byte-for-byte, route-order guard standalone-green. New modules all under services/ (no Dockerfile COPY gap). Security greps clean. Approving.

@vybe
vybe merged commit a9372ad into dev Jul 19, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants