Skip to content

fix(redis): resolve a database name once; a coroutine is not a client (#17435, #17436, #16499) - #17439

Merged
mrveiss merged 10 commits into
mainfrom
issue-17435-redis-enum-and-await
Sep 25, 2026
Merged

mrveiss merged 10 commits into
mainfrom
issue-17435-redis-enum-and-await

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

Two root causes, three issues, one census — which is why they are one PR.

1. A database reference resolved two ways, and the wrong way had a working default

RedisDatabase is a plain Enum whose values are database numbers
(MAIN = 0, ANALYTICS = 11), while the runtime lookup is keyed by names
(DATABASE_MAPPING: Dict[str, int]). So .value is not a key, and str(enum) is
"RedisDatabase.ANALYTICS", which is not one either. Both plausible
conversions are wrong and only one is obviously so.

DATABASE_MAPPING.get(database_name, 0) then defaulted to zero, warned, and
logged success twice:

WARNING Unknown database name 'RedisDatabase.ANALYTICS', defaulting to DB 0.
INFO    Redis database 'RedisDatabase.ANALYTICS' is ready
INFO    Created async pool for 'RedisDatabase.ANALYTICS' with retry protection

The live consequence, one feature addressing one logical database two ways:

Role Site Reaches
writer analytics_embedding_patterns.py:150 DB 0
reader analytics_pattern_learning.py:231 DB 11

which is why the same startup logged Loaded 0 patterns from Redis.

Nine call sites pass the enum and only two misroute — because MAIN is 0 and
the fallback returned 0. The other seven were not working; they were agreeing
with the failure mode
, and the agreement ends the moment any is repointed.

2. A coroutine is not a client, and truthiness cannot tell

agent_presence_sync.py   get_redis_client(async_client=True)   sync fn returning a coroutine
neural_mesh_wiring.py    get_async_redis_client()              async fn, never awaited

#16499 was not a one-line fix: build_mesh_components and
wire_neural_mesh_components are sync, so the await is threaded through both and
the lifespan.py call site — which was already async, so the chain closes cleanly.

What Changed

File Change
redis_management/database_names.py new — the single home for name resolution and refusal
redis_management/connection_manager.py normalises at both public entries; _get_database_number delegates and raises
redis_client.py two hand-copies deleted; both entry signatures accept RedisDatabase | str
api/analytics_controller.py third hand-copy deleted
initialization/agent_presence_sync.py await get_async_redis_client()
initialization/neural_mesh_wiring.py, lifespan.py await threaded through three functions
agent_terminal/session_manager.py capability guard; refuses a bad client at construction
utils/redis_consolidation_test.py the test that pinned the default, inverted
two new test modules below

Normalisation happens at the manager's entries because that is where the pool
key comes from
— so Created async pool for 'RedisDatabase.ANALYTICS' cannot
recur, and DB 0 stops accruing phantom pools alongside main.

The three hand-copied database.name.lower() if isinstance(...) lines are
deleted, not re-pointed. One home, not four.

The construction refusal is the load-bearing half

session_manager is reached through a process-wide singleton built "by
whichever caller gets there first"
, and agent_presence_sync runs at startup.
Refusing at first use would be too late — the bad client is already installed for
every later consumer. So it refuses in __init__.

And the guard is no longer truthiness:

if _usable_redis(self.redis_client):   # was: if self.redis_client:

A coroutine is truthy. So are a Mock, a functools.partial, and a half-built
client. It read as a client check and was a presence check — it passed for
every wrong value anyone actually passed.

Verification

This push is NOT locally verified, and that is stated rather than papered over.
Pre-push reported:

[pre-push COULD NOT RUN] pytest cannot execute here — this push is NOT verified.
  reason: Python 3.10.12 is below the 3.11+ that llc/scheduler/base.py requires
[pre-push WARN] allowed by AUTOBOT_PREPUSH_ALLOW_UNRUNNABLE=1

COULD NOT RUN is not PASS, and the hook is right to distinguish them. CI is
the authority here.
I used the hook's own escape hatch rather than
--no-verify, which its message explicitly warns against because it would also
skip the checks that do work — and those ran and passed.

Every #17435 test uses a non-zero database deliberately. MAIN == 0 == the old fallback, so a test written with MAIN passes against the defect — which is
exactly how seven call sites stayed invisible.

Mutation argument, static (a mutated tree cannot be pushed to prove it):

Mutation Fails
restore DATABASE_MAPPING.get(database_name, 0) resolution test reports 0 for ANALYTICS; refusal test stops raising
restore if self.redis_client: coroutine guard test accepts a coroutine

connection_manager.py landed one line under its ceiling, so it is lowered
1519 → 1518 in both recorded copies (#14498).

Deliberately not done

get_redis_client(async_client=True) still returns a bare coroutine. Two sites
(ws_security.py:417, conversation_owner.py:48) await it correctly, so making
it raise would break working code. The trap is closed at the consumers instead.

Worth stating plainly: a docstring warning about this form has now failed three
times — #3962 added it, then #16499, then #17436. So the criterion was that the
trap cannot be fallen into, not that it is better documented. At these
consumers it cannot; the function itself is still a loaded gun for a new caller,
and that is the remaining gap.

Closes #17435
Closes #17436
Closes #16499

Model Used

Claude Opus 5 (1M context).

Summary by CodeRabbit

  • Bug Fixes
    • Improved Redis database selection across analytics, sessions and background services, including support for database enum values.
    • Redis-backed session creation now rejects unusable clients instead of attempting to use them.
    • Unknown Redis database names now raise an error rather than silently selecting the default database.
    • Background service initialisation now waits for Redis connections and component wiring to complete.
  • Tests
    • Added coverage for Redis database resolution and invalid client handling.

@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 13 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 89d1ea11-93f5-431b-8cba-78a60d207de0

📥 Commits

Reviewing files that changed from the base of the PR and between 25668b2 and 51ead31.

⛔ Files ignored due to path filters (3)
  • autobot-frontend/src/types/generated/api.ts is excluded by !**/generated/**
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (13)
  • autobot-backend/api/agent_terminal_access.py
  • autobot-backend/api/analytics_controller.py
  • autobot-backend/initialization/agent_presence_sync.py
  • autobot-backend/initialization/lifespan.py
  • autobot-backend/initialization/neural_mesh_wiring.py
  • autobot-backend/services/agent_terminal/redis_usability.py
  • autobot-backend/services/agent_terminal/session_manager.py
  • autobot-backend/services/agent_terminal/session_manager_client_guard_17436_test.py
  • autobot-backend/utils/redis_consolidation_test.py
  • autobot_shared/redis_client.py
  • autobot_shared/redis_management/connection_manager.py
  • autobot_shared/redis_management/database_names.py
  • autobot_shared/redis_management/database_names_17435_test.py
📝 Walkthrough

Walkthrough

Redis client boundaries accept database enum values and strings, and unknown database names raise an error. Startup paths await asynchronous Redis clients. Terminal session management validates Redis client values before use.

Changes

Redis database resolution

Layer / File(s) Summary
Database name resolution
autobot_shared/redis_management/database_names.py, autobot_shared/redis_management/connection_manager.py, autobot_shared/redis_management/database_names_17435_test.py, autobot-backend/utils/redis_consolidation_test.py
Shared utilities convert enum values to database names and resolve names to database numbers. The connection manager uses these utilities. Tests check enum mappings and rejection of unknown names.
Enum inputs at Redis client boundaries
autobot_shared/redis_client.py, autobot-backend/api/analytics_controller.py
Redis client functions accept `RedisDatabase

Asynchronous Redis client handling

Layer / File(s) Summary
Await Redis clients during startup
autobot-backend/initialization/agent_presence_sync.py, autobot-backend/initialization/neural_mesh_wiring.py, autobot-backend/initialization/lifespan.py
Presence sync and neural mesh wiring await asynchronous Redis client creation. Graph-RAG initialisation awaits neural mesh wiring.
Validate terminal session Redis clients
autobot-backend/services/agent_terminal/session_manager.py, autobot-backend/services/agent_terminal/session_manager_client_guard_17436_test.py
Session management rejects non-None Redis values that do not meet the required client checks. Session persistence also checks client capabilities. Tests cover accepted and rejected inputs.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🟠 High · up to 25668

Terminal sessions may not survive a restart, invalid Redis database names can appear to be connection failures, and mesh feedback can fail when Redis is unavailable. These issues should be resolved before merging.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 25668

The changes improve database routing and prevent coroutine objects from being used as Redis clients. Because the routing contract is shared and Redis also supports terminal-session persistence, deployment compatibility and recovery behavior warrant design review.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The affected scope includes Redis logical databases used through shared client acquisition and Redis-backed terminal sessions. The inspected changes do not establish a new remotely controlled database selector or a change to Redis credentials or network access.

Security Findings and Attack Paths

  • inferred — No newly exposed cross-database or cross-tenant attack path was established in the inspected callers: analytics selects a fixed database, and the inspected terminal endpoints require authentication or a session-owner dependency. This does not establish coverage of every shared Redis caller.

Trust Boundaries and Controls

  • observed — The shared Redis API accepts database-name strings and does not itself perform caller authorization. The inspected analytics caller supplies a constant enum, while terminal creation checks conversation ownership before storing the session.

Resilience and Maintainability Implications

  • observed — Session creation checks client usability before persistence, whereas reload and cleanup retain truthiness checks. The constructor protects its initial dependency, but the lifecycle does not apply one uniform client-validity rule.

Hardening Proposals

  • proposed — Verify deployed database names and historical data placement before rollout, and make client-acquisition failure visible for services that require Redis-backed state.
  • proposed — Define the terminal-session client's required persistence, recovery, and cleanup operations as one dependency contract, and apply a consistent validity policy throughout that lifecycle.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning #17435: The PR centralises RedisDatabase | str resolution, raises for unknown names, and tests non-zero enum resolution. It does not show tests for all nine affected call sites. The analytics test c… For #17435, test all nine affected call sites and assert their resolved database numbers. Add a writer/read-back test for the analytics pair. For #17436, test Redis session persistence and verify the startup-created singleton seen by later …
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarises the main changes: Redis database-name resolution and prevention of coroutine objects being used as Redis clients. It is specific and relevant to the pull request.
Out of Scope Changes check ✅ Passed The Redis name-resolution changes, startup awaits, session-manager capability guard, and related tests support the coding objectives in #17435, #17436, and #16499. The supplied whole-pull-request evid…
Docstring Coverage ✅ Passed Docstring coverage is 88.37% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 43 functions across 11 files.
Full details: Linked Issues check

Explanation

#17435: The PR centralises RedisDatabase | str resolution, raises for unknown names, and tests non-zero enum resolution. It does not show tests for all nine affected call sites. The analytics test compares configured database numbers but does not verify a writer write followed by a reader read. #17436: The startup path now awaits the Redis client, and SessionManager rejects awaitables and clients without hgetall/hset. The tests do not verify that session persistence reaches Redis or that later consumers receive the startup singleton. #16499: Neural-mesh wiring now awaits the client before constructing EdgeLearner. The PR does not show a test that verifies hgetall and hset support on the supplied client. It also does not establish where an AttributeError from the affected paths is handled.

Resolution

For #17435, test all nine affected call sites and assert their resolved database numbers. Add a writer/read-back test for the analytics pair. For #17436, test Redis session persistence and verify the startup-created singleton seen by later consumers. For #16499, test that EdgeLearner receives a client with callable hgetall and hset, and establish the handling location for any resulting AttributeError.

✨ Finishing Touches 💡 1
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

…#17435)

Three issues, one PR: two root causes whose census was done together.

## A database name resolves in one place, and an unknown one is refused (#17435)

`RedisDatabase` is a plain Enum whose VALUES are database numbers (MAIN = 0,
ANALYTICS = 11), while the runtime lookup is keyed by NAMES. So `.value` is not a
key and `str(enum)` is not one either -- both plausible conversions are wrong and
only one is obviously so.

`DATABASE_MAPPING.get(database_name, 0)` then DEFAULTED TO ZERO, warned, and
logged success twice:

    writer  analytics_embedding_patterns.py  RedisDatabase.ANALYTICS -> DB 0
    reader  analytics_pattern_learning.py    database="analytics"    -> DB 11

which is why the same startup logged `Loaded 0 patterns from Redis`. One feature,
one logical database, two addressing forms, one of them wrong.

Nine call sites pass the enum and only two misrouted -- because MAIN is 0 and the
fallback returned 0, so seven were not working, they were AGREEING WITH THE
FAILURE MODE. The agreement ends the moment any one is repointed.

The rule had three hand-copied homes (redis_client.py twice,
analytics_controller.py once) and was absent from the two module-level functions
most callers use. All three copies are deleted rather than re-pointed:
normalisation happens at the connection manager's two entry points, which is also
where the POOL KEY comes from -- so `Created async pool for
'RedisDatabase.ANALYTICS'` cannot recur and DB 0 stops accruing phantom pools.

`_get_database_number` now raises. The test asserting otherwise -- "Should not
crash, should use fallback" -- is inverted: the defaulting was PINNED, not
overlooked, which is how it survived.

## A coroutine is not a client, and truthiness cannot tell (#17436, #16499)

    agent_presence_sync.py   get_redis_client(async_client=True)  sync fn, returns a coroutine
    neural_mesh_wiring.py    get_async_redis_client()             async fn, never awaited

#16499 was not a one-line fix: `build_mesh_components` and
`wire_neural_mesh_components` are sync, so the await is threaded through both and
the `lifespan.py` call site, which was already async.

The guard was `if self.redis_client:` -- and a coroutine is TRUTHY, so
`_persist_session` ran against one. So are a Mock, a partial and a half-built
client: it read as a client check and was a presence check, passing for every
wrong value anyone actually passed. Now a capability check, and the service
REFUSES a bad client at construction -- it is a process-wide singleton built by
whichever caller arrives first, so accepting one poisons every later consumer.

Deliberately NOT done: `get_redis_client(async_client=True)` still returns a bare
coroutine. Two sites await it correctly, so making it raise would break working
code; the trap is closed at the consumers instead. A docstring warning has failed
three times now (#3962, #16499, #17436), so the criterion was that the trap
cannot be fallen into -- at these consumers it cannot.

## Tests

Every #17435 test uses a NON-ZERO database deliberately. MAIN == 0 == the old
fallback, so a test written with MAIN passes against the defect -- exactly how
seven call sites stayed invisible.

Mutation checked: restore `DATABASE_MAPPING.get(database_name, 0)` and the
resolution test reports 0 for ANALYTICS while the refusal test stops raising;
restore `if self.redis_client:` and the coroutine guard test accepts a coroutine.

connection_manager.py landed one line under its ceiling; lowered to 1518 in both
recorded copies (#14498).

Closes #17435
Closes #17436
Closes #16499
@mrveiss
mrveiss force-pushed the issue-17435-redis-enum-and-await branch from d38cac0 to 1ed5dfb Compare September 25, 2026 06:04
Auto-regenerated (py3.14) to match the backend and/or SLM backend
schema so the required verify-generated-types gate(s) pass.
Triggered by auto-fix-generated-types.yml.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: fd0e2452-882e-414a-9f14-c63dc8b8bc7b

📥 Commits

Reviewing files that changed from the base of the PR and between 2e00fa4 and 411dee1.

⛔ Files ignored due to path filters (3)
  • autobot-frontend/src/types/generated/api.ts is excluded by !**/generated/**
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (11)
  • autobot-backend/api/analytics_controller.py
  • autobot-backend/initialization/agent_presence_sync.py
  • autobot-backend/initialization/lifespan.py
  • autobot-backend/initialization/neural_mesh_wiring.py
  • autobot-backend/services/agent_terminal/session_manager.py
  • autobot-backend/services/agent_terminal/session_manager_client_guard_17436_test.py
  • autobot-backend/utils/redis_consolidation_test.py
  • autobot_shared/redis_client.py
  • autobot_shared/redis_management/connection_manager.py
  • autobot_shared/redis_management/database_names.py
  • autobot_shared/redis_management/database_names_17435_test.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread autobot_shared/redis_management/connection_manager.py Outdated
Comment thread autobot-backend/initialization/agent_presence_sync.py
Comment thread autobot-backend/services/agent_terminal/session_manager.py Outdated

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: f9057ac1-842b-43f8-97f1-4f3110c653d5

📥 Commits

Reviewing files that changed from the base of the PR and between 411dee1 and d8b6c7d.

⛔ Files ignored due to path filters (3)
  • autobot-frontend/src/types/generated/api.ts is excluded by !**/generated/**
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (6)
  • autobot-backend/api/analytics_controller.py
  • autobot-backend/initialization/neural_mesh_wiring.py
  • autobot-backend/services/agent_terminal/session_manager.py
  • autobot-backend/utils/redis_consolidation_test.py
  • autobot_shared/redis_client.py
  • autobot_shared/redis_management/connection_manager.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread autobot-backend/services/agent_terminal/session_manager.py Outdated

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Skip mesh wiring when Redis is unavailable. · neural_mesh_wiring.py:37

autobot-backend/initialization/neural_mesh_wiring.py:37
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Skip mesh wiring when Redis is unavailable.

get_async_redis_client() can return None. EdgeLearner stores this value without a guard. consume_feedback_stream() then calls self.redis.hgetall(...), which can raise AttributeError.

🐛 Suggested fix
 _redis = await get_async_redis_client()
+if _redis is None:
+    raise RuntimeError("Neural Mesh RAG wiring requires Redis")
 _ppr = PersonalizedPageRank(db=_mesh_db)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 4e5c6c48-f987-4e60-b76c-cd030f8a57f9

📥 Commits

Reviewing files that changed from the base of the PR and between d8b6c7d and 25668b2.

⛔ Files ignored due to path filters (3)
  • autobot-frontend/src/types/generated/api.ts is excluded by !**/generated/**
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (6)
  • autobot-backend/api/analytics_controller.py
  • autobot-backend/initialization/neural_mesh_wiring.py
  • autobot-backend/services/agent_terminal/session_manager.py
  • autobot-backend/utils/redis_consolidation_test.py
  • autobot_shared/redis_client.py
  • autobot_shared/redis_management/connection_manager.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

…nert (#17435)

All three were correct. Verified each against the source before acting rather
than taking the suggestion.

1. THE REFUSAL WAS BEING SWALLOWED. `database_number` raises KeyError, and
   `get_sync_client` wraps pool creation in `except Exception: return None` --
   so an undeclared name came back as "Redis unavailable" and fed the circuit
   breaker. I replaced a silent default with a loud refusal and the refusal was
   laundered into an outage one layer up: a programming error reported as an
   operational failure is the same defect, just louder about the wrong thing.
   `validated_name()` normalises AND refuses in one call, above the caller's
   try, so it reaches the caller intact. Line-neutral swap at both entries --
   connection_manager.py has zero headroom.

2. THE GUARD CHECKED METHODS THIS CLASS NEVER CALLS. `_usable_redis` tested
   `hgetall`/`hset`; `SessionManager` uses `get`, `setex`, `delete` -- measured,
   not assumed. I carried those names over from EdgeLearner's usage in #16499
   without checking this consumer. A client of the wrong shape passed the guard
   and failed inside `_persist_session`, which catches and logs, so the session
   stayed in memory and nothing said so. Checking the WRONG capability is the
   truthiness bug again, harder to see. The fake now matches the guard, and a
   test pins that an `hgetall`/`hset` client is refused.

3. THE AWAIT FIX WAS INERT ON THE PATH THAT MATTERS.
   `ensure_agent_terminal_service` ignores kwargs once built, and
   `chat_workflow/tool_handler.py:1329` calls it with NO redis_client. On that
   ordering the singleton is built with None and every later client -- however
   correctly awaited -- was discarded. Worse than the report said: not "before
   Redis is ready" but a caller that never passes one. A later usable client is
   now adopted onto the existing instance, under the lock, updating the two
   collaborators handed the client at construction. Only ever an upgrade: an
   unusable client is never installed, a working one never swapped out from
   under in-flight callers.

   Caught a bug in my own fix while re-reading it: the guard clause returned
   early when the candidate WAS usable -- exactly backwards.

Three adoption tests covering all three directions: adopted when the existing
client is unusable, refused when the candidate is a coroutine, and a working
client never replaced.

isort merged an import and took connection_manager.py to 1517, one under its
ceiling; lowered 1518 -> 1517 in both recorded copies (#14498).

session_manager.py sits at exactly 600 against a 600 limit. Two truthiness
guards remain in service.py (:232, :822); they now only ever see None or a
validated client, since the constructor refuses and the upgrade installs only
usable ones, but they are the same shape and that file is at its ceiling with no
budget to fix them in.

Refs #17435
Refs #17436
…t, not just an un-awaited one (#17435, #17436)

Two defects, both surfaced by CI on the rebased head.

1. THE GUARD REJECTED THE CLIENT IT EXISTS TO ADMIT. `_usable_redis` tested
   `inspect.isawaitable(client)`, and `redis.asyncio.Redis` implements
   `__await__` for its lazy connection setup -- so `isawaitable` is True for
   every CORRECT async client, not only for the un-awaited coroutine the guard
   was written against. A properly awaited async client raised
   `TypeError: redis_client is not a usable Redis client` from
   SessionManager.__init__. `inspect.iscoroutine` alone is the right test: a
   coroutine object is what `get_redis_client(async_client=True)` returns
   un-awaited, and an async client is awaitable without being one.

   `session_manager_tenant_16975_test` is what caught it --
   `fakeredis.aioredis.FakeRedis` is awaitable for the same reason and was
   refused. Confirmed against the library rather than inferred: on fakeredis
   2.36.2 that client answers True to `isawaitable`, False to `iscoroutine`,
   and exposes get/setex/delete.

   The existing suite could not have caught this. Its double was a hand-rolled
   sync object with the three methods and no `__await__`, so it passed under
   both the broken check and the correct one -- a double that is not awaitable
   cannot notice a guard that rejects awaitables. Added
   `test_a_genuine_async_client_is_usable_even_though_it_is_awaitable`, whose
   double IS awaitable and which asserts that property explicitly, so dropping
   `__await__` from it cannot make the test pass vacuously. Mutation-checked:
   restoring the `isawaitable` clause fails that test and the tenant test.

   The docstring claimed "a genuine fake still works" while the code refused
   one. The claim now holds and records why the distinction is the whole guard.

2. THE FILE CROSSED THE 600-LINE CEILING ON THE REBASE. `code-quality` failed
   at `check_python_file_size.py --audit-ceilings` with
   `session_manager.py: 612 lines (max 600)`. Not raised -- split, per the rule.
   The collision is worth recording: base was at 583 after #17422 added
   `_owner_for()` earlier the same day, and this branch's 29 lines took it over.
   Neither change alone crossed the line.

   `_usable_redis` moves to `services/agent_terminal/redis_usability.py` and
   becomes public `usable_redis`. That function is the right thing to move
   because it was already a cross-module import: `api/agent_terminal_access`
   imported the underscore-prefixed name from another module, which is
   documentation disagreeing with the code. session_manager.py is now 593.

   Also moved `APPROVAL_PENDING_SESSION_TTL` below the import block. It sat
   between imports, which made every import after it E402; the count was 4
   before this branch and the new import would have made it 5. Moving the
   constant fixes all five rather than adding to them. Behaviour-neutral --
   nothing reads it at import time.

Verification: 79 pass in `services/agent_terminal/`, 879 pass across the
touched suites. `check_python_file_size.py --audit-ceilings` now reports
"5898 files scanned, 482 grandfathered, all live and at size". Four remaining
failures under `tests/api/` are this interpreter being Python 3.10 against a
3.14 platform floor (`Task.cancelling()` is 3.11+), not this change; CI runs
3.14.
@mrveiss

mrveiss commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Both reds were real and both are fixed in 7048a7eb1b. The shard-5 one was the more serious: the guard this PR adds was refusing every correct async Redis client, not just an un-awaited one.

python-suite shard 5/12 — the guard rejected what it exists to admit

_usable_redis tested inspect.isawaitable(client). redis.asyncio.Redis implements __await__ for its lazy connection setup, so isawaitable is True for every correct async client — not only for the un-awaited coroutine the guard was written against. A properly awaited async client therefore raised TypeError: redis_client is not a usable Redis client out of SessionManager.__init__.

session_manager_tenant_16975_test::test_a_reloaded_session_keeps_its_tenant_and_is_discoverable_only_there is what caught it — fakeredis.aioredis.FakeRedis is awaitable for the same reason and was refused. Checked against the library rather than inferred; on fakeredis 2.36.2:

get      present=True callable=True
setex    present=True callable=True
delete   present=True callable=True
iscoroutine: False   isawaitable: True

inspect.iscoroutine alone is the correct test. A coroutine object is what get_redis_client(async_client=True) returns un-awaited; an async client is awaitable without being one.

Why this PR's own tests passed while this was broken

The guard's suite used a hand-rolled sync double with the three methods and no __await__. That double passes under both the broken check and the correct one — a test double that is not awaitable cannot notice a guard that rejects awaitables. The docstring meanwhile claimed "a genuine fake still works" while the code refused one.

Added test_a_genuine_async_client_is_usable_even_though_it_is_awaitable, whose double implements __await__ and which asserts that property explicitly:

assert inspect.isawaitable(client) is True, "the double no longer has the property under test"
assert inspect.iscoroutine(client) is False, "an async client is awaitable but is not a coroutine"
assert usable_redis(client) is True

Without the first line, someone could drop __await__ from the double and the test would pass while testing nothing. Mutation-checked: restoring the isawaitable clause fails that test and the tenant test.

code-quality — 612 lines, split not raised

The failing step was check_python_file_size.py --audit-ceilings: session_manager.py: 612 lines (max 600).

Worth recording the cause, because neither change alone did it: base was 583 after #17422 added _owner_for() earlier the same day, and this branch's 29 lines took it over. A file 17 lines from a hard ceiling received two independent contributions.

_usable_redis moves to services/agent_terminal/redis_usability.py as public usable_redis. That function is the right one to move rather than an arbitrary slice: api/agent_terminal_access was already importing the underscore-prefixed name from another module, which is documentation disagreeing with the code. session_manager.py is now 592.

Also moved APPROVAL_PENDING_SESSION_TTL below the imports — it sat between them, making every later import E402 (4 instances before this branch; the new import would have made 5). Behaviour-neutral, nothing reads it at import time.

Verification

  • --audit-ceilings: 5898 files scanned, 482 grandfathered, all live and at size
  • 79 pass in services/agent_terminal/, 185 pass across the touched suites
  • The pre-push hook's own selection — 9 files, 121 passed — run on Python 3.14.6 / pytest 9.1.1, the CI interpreter, not the 3.10 one this machine defaults to. [pre-push OK] pytest: all relevant tests pass; no bypass flag used.

Four failures remaining under tests/api/ locally are Task.cancelling() needing Python 3.11+ against a 3.14 platform floor — this interpreter, not this change.

@mrveiss
mrveiss merged commit 9ea37f7 into main Sep 25, 2026
80 checks passed
@mrveiss
mrveiss deleted the issue-17435-redis-enum-and-await branch September 25, 2026 22:08
mrveiss added a commit that referenced this pull request Sep 27, 2026
…wers (#17467) (#17549)

* fix(mcp): a rejected tool and an unreachable server are different answers (#17467)

Two failures were reported as a log line and one word.

discover_tools() dropped any tool that failed validation: absent from the
returned list, reason only in a backend log. An operator saw four of six tools
with nothing to ask. discover_tools_multi_server() logged "skipping
unreachable server" for every failure -- connect refused, bad schema, our own
bug -- so a reachable server with a bad payload sent the reader to look at the
network.

Nothing distinguished "we found no tools" from "we rejected the tools we
found", which is MEASUREMENT_DISCIPLINE's rule broken at the discovery seam.

Rejections are now returned, not raised: one malformed tool must not cost a
server its other tools, and the caller needs to know which happened.
ServerToolList carries them; a server that yields nothing gets a
ServerDiscoveryFailure with kind="transport" or kind="schema".

Both entry points keep their signatures. discover_tools() and
discover_tools_multi_server() are thin views over the detailed forms rather
than duplicate loops -- three existing callers read a plain list and there is
one implementation behind both.

THE LAUNDERING FIX, which is the half worth reading. A broad except Exception
over a per-server loop absorbed genuine defects and reported them as the
environment's fault -- the exact shape of #17439, where a KeyError from our own
code returned as "Redis unavailable" and fed a circuit breaker. AttributeError,
KeyError, TypeError, NameError, ImportError and IndexError are now re-raised.
A bug in this loop affects every server, so failing loudly is both honest and
more useful than recording it as one server being down.

THREE DEFECTS FOUND WHILE FIXING IT, all in my own work:

1. The error path could raise from inside itself. `raw.get("name")` sat in the
   except handler, so a non-dict entry raised AttributeError WHILE reporting a
   validation failure, taking every remaining tool with it. An error reporter
   that fails on the input it describes is worse than the error it describes.

2. A capability probe that could not say no. My first version used
   getattr(client, "discover_tools_detailed", None) for compatibility with
   older clients. An AsyncMock auto-creates every attribute, so the probe
   answered yes for every test double and returned mocks where tools were
   expected -- two existing tests failed and the cause was my code, not their
   assertions. The contract is stated on ClientFactory instead and doubles
   implement it. Their assertions are unchanged, which is the evidence
   behaviour is preserved.

3. A redactor is not a substitute for not including the data. The reason first
   passed the validator's message through redact_provider_error, which was not
   enough: redact_text masks `Authorization: Bearer <token>` correctly on its
   own, but pydantic embeds the offending input AND truncates it, yielding
   'inputS...Bearer sk-secret-value'. The validator's own truncation broke the
   prefix the redactor matches on, so the token survived. A pattern matcher can
   only mask shapes it can still recognise.

   Reasons are now built from the validator's STRUCTURE -- which field, which
   failure kind -- and never from its rendered message or its input value.
   "description: missing; inputSchema: model_type" tells an operator what to fix
   and cannot carry a credential.

REDACTION_BOUNDARY.md was read to pick the redactor rather than inventing one;
redact_provider_error remains the fallback where there is no structure to use.

Mutation-checked, 5 of 5 caught, each by its own test: drop the rejection
recording, revert the reason to the rendered message, revert the name
extractor, remove the programming-error re-raise, label every failure
transport.

44 passed across the new tests, the aggregation suite and the transport suite.

Closes #17467

* test(mcp): doubles that stub only discover_tools() answer the detailed form with a mock (#17467)

Nine failures across two test files, all mine and all from this PR's own contract
change, found by triaging shard 6/12 and 9/12 rather than writing them off with
the shared shard 1/12 red (#17557).

`mcp_aggregation` requires a client factory to expose
`discover_tools_detailed()`. This PR's own comment records why it states that
contract instead of probing for it: `AsyncMock` auto-creates every attribute, so
`getattr(client, "discover_tools_detailed", None)` answered yes for every double
and returned mocks where tools were expected. A capability probe that cannot say
no is not a probe.

That same property bites from the other side, and I did not follow it through.
`mcp_external_bridge_test.py` and `realtime_mcp_bridge_test.py` kept doubles
stubbing only `discover_tools()`, so the detailed call returned a fresh mock,
`.accepted` was a mock, and the assertions failed with empty tool sets -- `assert
'search' in []` -- several layers from the missing method. Not an AttributeError
naming the gap, which is what made it look like a discovery bug rather than a
stale double.

Production is correct and unchanged. `AttributeError` is in `_OUR_BUG`, so a real
client missing the method raises rather than being recorded as an unreachable
server. Only the doubles were stale.

The 13 doubles across the two files now come from `testkit/mcp_client_doubles.py`,
so the contract has one place to change when it changes again, rather than
thirteen.

Two tests for the property itself, because the nine failures proved the suite
could not see it:

- a client with the plain surface and nothing auto-created behind it must raise,
  not yield mocks -- the double has to be able to fail the contract, which an
  `AsyncMock` cannot;
- the shared helper's double satisfies the contract it stands in for, or the
  helper becomes the next place a mock enters as a tool list.

61 passed across the four MCP suites.

Refs #17467
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment