Repository navigation
fix(redis): resolve a database name once; a coroutine is not a client (#17435, #17436, #16499) - #17439
Conversation
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Warning Review limit reachedNext included review available in 13 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: ⛔ Files ignored due to path filters (3)
📒 Files selected for processing (13)
📝 WalkthroughWalkthroughRedis 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. ChangesRedis database resolution
Asynchronous Redis client handling
Priority: ➖ Normal Severity of issue fixed: Medium Merge Risk: 🟠 High · up to 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 ReviewSecurity architecture risk: 🟡 Moderate · up to 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 Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Linked Issues checkExplanation
Resolution For ✨ Finishing Touches 💡 1📝 Generate docstrings
🧪 Generate unit tests (beta)
🛠️ Fix failing CI checks 💡
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. Comment |
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
…#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
d38cac0 to
1ed5dfb
Compare
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.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (3)
autobot-frontend/src/types/generated/api.tsis excluded by!**/generated/**repo_tests/python_file_size_ratchet_baseline.pyis excluded by!repo_tests/python_file_size_ratchet_baseline.pyscripts/python_file_size_known_large.pyis excluded by!scripts/python_file_size_known_large.py
📒 Files selected for processing (11)
autobot-backend/api/analytics_controller.pyautobot-backend/initialization/agent_presence_sync.pyautobot-backend/initialization/lifespan.pyautobot-backend/initialization/neural_mesh_wiring.pyautobot-backend/services/agent_terminal/session_manager.pyautobot-backend/services/agent_terminal/session_manager_client_guard_17436_test.pyautobot-backend/utils/redis_consolidation_test.pyautobot_shared/redis_client.pyautobot_shared/redis_management/connection_manager.pyautobot_shared/redis_management/database_names.pyautobot_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.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (3)
autobot-frontend/src/types/generated/api.tsis excluded by!**/generated/**repo_tests/python_file_size_ratchet_baseline.pyis excluded by!repo_tests/python_file_size_ratchet_baseline.pyscripts/python_file_size_known_large.pyis excluded by!scripts/python_file_size_known_large.py
📒 Files selected for processing (6)
autobot-backend/api/analytics_controller.pyautobot-backend/initialization/neural_mesh_wiring.pyautobot-backend/services/agent_terminal/session_manager.pyautobot-backend/utils/redis_consolidation_test.pyautobot_shared/redis_client.pyautobot_shared/redis_management/connection_manager.py
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.
🟡 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 winSkip mesh wiring when Redis is unavailable.
get_async_redis_client()can returnNone.EdgeLearnerstores this value without a guard.consume_feedback_stream()then callsself.redis.hgetall(...), which can raiseAttributeError.🐛 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
⛔ Files ignored due to path filters (3)
autobot-frontend/src/types/generated/api.tsis excluded by!**/generated/**repo_tests/python_file_size_ratchet_baseline.pyis excluded by!repo_tests/python_file_size_ratchet_baseline.pyscripts/python_file_size_known_large.pyis excluded by!scripts/python_file_size_known_large.py
📒 Files selected for processing (6)
autobot-backend/api/analytics_controller.pyautobot-backend/initialization/neural_mesh_wiring.pyautobot-backend/services/agent_terminal/session_manager.pyautobot-backend/utils/redis_consolidation_test.pyautobot_shared/redis_client.pyautobot_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.
|
Both reds were real and both are fixed in
|
…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
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
RedisDatabaseis a plainEnumwhose values are database numbers(
MAIN = 0,ANALYTICS = 11), while the runtime lookup is keyed by names(
DATABASE_MAPPING: Dict[str, int]). So.valueis not a key, andstr(enum)is"RedisDatabase.ANALYTICS", which is not one either. Both plausibleconversions are wrong and only one is obviously so.
DATABASE_MAPPING.get(database_name, 0)then defaulted to zero, warned, andlogged success twice:
The live consequence, one feature addressing one logical database two ways:
analytics_embedding_patterns.py:150analytics_pattern_learning.py:231which is why the same startup logged
Loaded 0 patterns from Redis.Nine call sites pass the enum and only two misroute — because
MAINis 0 andthe 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
#16499was not a one-line fix:build_mesh_componentsandwire_neural_mesh_componentsare sync, so the await is threaded through both andthe
lifespan.pycall site — which was already async, so the chain closes cleanly.What Changed
redis_management/database_names.pyredis_management/connection_manager.py_get_database_numberdelegates and raisesredis_client.pyRedisDatabase | strapi/analytics_controller.pyinitialization/agent_presence_sync.pyawait get_async_redis_client()initialization/neural_mesh_wiring.py,lifespan.pyagent_terminal/session_manager.pyutils/redis_consolidation_test.pyNormalisation happens at the manager's entries because that is where the pool
key comes from — so
Created async pool for 'RedisDatabase.ANALYTICS'cannotrecur, and DB 0 stops accruing phantom pools alongside
main.The three hand-copied
database.name.lower() if isinstance(...)lines aredeleted, not re-pointed. One home, not four.
The construction refusal is the load-bearing half
session_manageris reached through a process-wide singleton built "bywhichever caller gets there first", and
agent_presence_syncruns 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:
A coroutine is truthy. So are a
Mock, afunctools.partial, and a half-builtclient. 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:
COULD NOT RUNis notPASS, and the hook is right to distinguish them. CI isthe authority here. I used the hook's own escape hatch rather than
--no-verify, which its message explicitly warns against because it would alsoskip the checks that do work — and those ran and passed.
Every
#17435test uses a non-zero database deliberately.MAIN == 0 == the old fallback, so a test written withMAINpasses against the defect — which isexactly how seven call sites stayed invisible.
Mutation argument, static (a mutated tree cannot be pushed to prove it):
DATABASE_MAPPING.get(database_name, 0)ANALYTICS; refusal test stops raisingif self.redis_client:connection_manager.pylanded one line under its ceiling, so it is lowered1519 → 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 makingit 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