fix(ap): bound interactive AP read calls so a wedged pipeline degrades instead of hanging - #449
Conversation
The AP MCP client runs with callTimeoutMs=0 — deliberately unbounded, because a fresh index_codebase of a large tree legitimately exceeds any fixed wall clock. The only backstop is the 600s wedge-silence window. That is correct for ingestion but wrong for the interactive read path: search_codebase (behind unified_search) and the get_symbol / get_context / get_impact / get_processes / health_check lookups inherited the same 600s, so an AP that connects but then wedges made unified_search / get_causal_chain hang for up to 10 minutes instead of degrading to Cortex-only results — the "unreachable => status=partial" promise in unified_search's docstring was not actually kept. Fix: APBridge.call gains an optional timeout_s that wraps the client call in asyncio.wait_for and degrades a timeout to None (recorded reason + stderr note), exactly like every other AP failure. The interactive read wrappers pass interactive_call_timeout_s() (30s default, env-overridable via CORTEX_AP_INTERACTIVE_TIMEOUT_S, 20x below the 600s indexing window). The indexing/write wrappers (index_codebase, analyze_codebase, resolve_graph, cluster_graph, detect_changes) and the build-loop query_graph stay unbounded. A None from a timed-out search_codebase flows through as_list(None) -> [] -> unified_search returns status=partial with Cortex-only hits. Tests: a hanging fake client returns None within a 50ms ceiling; the interactive wrapper forwards a positive timeout while index_codebase forwards none; the default ceiling is positive and strictly below the 600s wedge window and honors/validates the env override. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017fnomaXS71hgxMw2zr7HGR
|
ZETETIC-REVIEW: REQUEST_CHANGES Independent review (fresh-context reviewer, read-only via gh, head 3debfda). Three blocking findings:
Verified sound (kept): the 30s default is properly sourced (docs/mcp-tools.md <200ms interactive target, Non-blocking: ap_bridge.py is pre-existing over the file cap (509 lines) and this PR grows it (+50) — follow-up split of the timeout/degrade concern into its own module. |
APBridge.call spanned 42 lines (repo cap: 40, scripts/craftsmanship_rules.py METHOD_LINE_LIMIT). Both except-branches duplicated the same "record _unavailable_reason + stderr note + return None" degrade logic; pulled into one _degrade(reason, note) helper, called from both branches. Fixes the CI craftsmanship-gate FAILURE on 3debfda (PR #449 review finding 1). Co-Authored-By: Claude <noreply@anthropic.com>
…he config flag
unified_search.handler derived status solely from the static is_enabled()
config check. An AP that is enabled but times out or errors on the actual
search_codebase call returned [] indistinguishably from AP genuinely
finding nothing: status="ok", sources=["cortex","ap"], counts.ap=0 either
way. This silently hid the exact failure mode #3debfda's timeout fix was
meant to expose.
WorkflowGraphASTSource gains last_search_degraded_reason, reading the
outcome APBridge._degrade already records on its own call path (no
widened return type, no second round-trip) — None when the call
succeeded or was never attempted (AP disabled / no graph configured),
set when it genuinely failed. unified_search now sets status="partial"
and a degraded: {source, reason} field whenever AP was attempted but
failed, keeping status="ok", degraded=null for a genuine empty AP result.
PR #449 review finding 2.
Co-Authored-By: Claude <noreply@anthropic.com>
…ments get_causal_chain (mcp_server/handlers/get_causal_chain.py) imports only MemoryStore and core.knowledge_graph — no AP dependency, so it cannot hang on a wedged AP call. mcp_call_timeout.py's interactive-ceiling comments and the interactive-timeout test's module docstring both claimed otherwise; corrected to name unified_search only, with an explicit note that get_causal_chain is unaffected. PR #449 review finding 3 (PR body corrected separately via gh pr edit). Co-Authored-By: Claude <noreply@anthropic.com>
…ine/40-line gates The degraded-tracking addition (bd74351) pushed workflow_graph_source_ast.py to 316 lines (cap 300) and search_codebase's body to 45 lines (cap 40) — a NEW craftsmanship violation not in the base-ref baseline, caught by CI on 30b2d20. Local `python3 scripts/check_craftsmanship.py` (no args) at the time compared against a stale local origin/main; re-running after `git fetch origin main` reproduces CI's FAILURE exactly. Root fix: search_codebase's row-normalization loop (id/qualified_name/ file_path/score/snippet construction from the raw AP response) is lifted into workflow_graph_ast_response.normalize_search_hits — the same module that already owns "normalize an AP response shape" for the symbol- and edge-loading concerns (issue #275's split), so this is the same seam, not a new one. search_codebase now just resolves the graph path, calls the bridge, and delegates. Docstrings on search_codebase/last_search_degraded_reason trimmed to essentials. workflow_graph_source_ast.py: 316 -> 288 lines. search_codebase: 45 -> delegates (no longer a > 40-line method). Behavior-preserving; targeted tests (ap_bridge/mcp_client/workflow_graph + the two new degraded-status test files + response_budget_wiring) all still green. Co-Authored-By: Claude <noreply@anthropic.com>
_unavailable_reason was only cleared in connect()'s slow (reconnect)
path. connect()'s fast path (client already connected) skipped the
clear, and call()'s success branch never reset it either. On a reused
instance — the codebase already does this, wiki_verify.py reuses one
WorkflowGraphASTSource across its candidate loop — a stale reason from
an earlier failed call leaked into a later successful one, so
last_search_degraded_reason could report a call as failed when it had
actually just succeeded. Contradicted its own docstring ("None if it
succeeded").
Fix: call() resets self._unavailable_reason = None right after the
tool-allowlist check, before touching connect()/the RPC — each call's
own outcome is now authoritative regardless of prior calls on the same
bridge instance. call() stays at 39 lines (cap 40).
New regression test: two calls on one APBridge instance, first via a
hanging client (fails), second via a client that succeeds, asserting
unavailable_reason is set after the first and None after the second.
Fails on pre-fix code (verified via git stash), passes after.
PR #449 review round 3 (Medium finding, house "no deferred coverage on
new code" rule).
Co-Authored-By: Claude <noreply@anthropic.com>
… drift) origin/main advanced past this branch's fork point (#447 FTS5 fix, #448 error-classification fix) and #448 fixed mcp_server/tool_error_handler.py _classify_error's method-size violation (was in the branch-point baseline; now 21 lines on main, verified by AST), pruning it from main's .craftsmanship-baseline.json. This branch's copy of the file still carried the stale entry, which the gate's ratchet rule flags as "added without a base-ref match" once compared against a freshly fetched origin/main (the ratchet may only shrink within a PR). Not something this PR's own changes caused — reproduced by stashing every change in this branch back to dddc001 and re-running the gate against fresh origin/main; the same failure appears. Synced this branch's baseline to match origin/main's exactly for this one entry (diffed byte-for-byte confirmed no other divergence in either direction) rather than grandfathering it or regenerating blind. Co-Authored-By: Claude <noreply@anthropic.com>
|
ZETETIC-REVIEW: APPROVE Final delta re-review (same fresh-context reviewer across three rounds, head 4e036b7). All blocking findings independently re-verified against the code, not the claims:
CI: all checks green on 4e036b7. Green AND verified — mergeable. |
What
The AP MCP client runs with
callTimeoutMs=0— deliberately unbounded, because a freshindex_codebaseof a large tree legitimately exceeds any fixed wall clock (seemcp_call_timeout.py). The only backstop is the 600s wedge-silence window.That's correct for ingestion but wrong for the interactive read path.
search_codebase(behindunified_search) and theget_symbol/get_context/get_impact/get_processes/health_checklookups all inherited the same 600s. So an AP that connects but then wedges madeunified_searchhang for up to 10 minutes before degrading — the "unreachable ⇒status=partial" promise inunified_search's own docstring was not actually kept. (get_causal_chainhas no AP dependency — it is pure knowledge-graph BFS overMemoryStore— and was never affected; an earlier revision of this PR incorrectly claimed otherwise.)This is the
unified_searchhang the harness-comparison benchmark hit: a stalled probe, not a graceful Cortex-only fallback.Fix
APBridge.callgains an optionaltimeout_sthat wraps the client call inasyncio.wait_forand degrades a timeout toNone(recorded reason + stderr note, via a shared_degradehelper) — exactly like every other AP failure path. ANonefrom a timed-outsearch_codebaseflows throughas_list(None)→[]→unified_searchreturnsstatus=partialwith Cortex-only hits.search_codebase,get_symbol,get_context,get_impact,get_processes,health_check) passinteractive_call_timeout_s()— 30s default, env-overridable viaCORTEX_AP_INTERACTIVE_TIMEOUT_S, 20× below the 600s indexing window.index_codebase,analyze_codebase,resolve_graph,cluster_graph,detect_changes) and the build-loopquery_graphstay unbounded — a single build query over a large graph may legitimately run long, and killing a live ingest is the exact regressioncallTimeoutMs=0was set to avoid.unified_search's response now reflects the per-call outcome, not just the staticis_enabled()flag.WorkflowGraphASTSource.search_codebaseexposeslast_search_degraded_reason(reads the sameAPBridge._unavailable_reasonthe timeout/exception path already records — no widened return type, no second round-trip). When AP was attempted but the call failed (timeout, transport error, not installed), the handler setsstatus="partial"and adegraded: {source: "ap", reason: ...}field, distinct from AP genuinely returning zero hits (status="ok",degraded: null). Previously a per-call timeout and a genuine empty AP result were byte-for-byte indistinguishable in the response.Source
The 30s ceiling is documented in
mcp_call_timeout.py: AP read tools are interactive (unified_searchtarget <200ms perdocs/mcp-tools.md); 30s is a wide margin over that yet 20× below the 600s wedge window reserved for indexing.Tests
tests_py/infrastructure/test_ap_bridge_interactive_timeout.py(fake clients, no subprocess):Nonewithin a 50ms ceiling; the unavailable-reason records the timeout and the tool name.timeout_sis unbounded (indexing path).search_codebasewrapper forwards a positiveinteractive_call_timeout_s();index_codebaseforwardsNone.default_call_timeout_s()(600s), and honors/validates the env override (malformed/non-positive → default, never unbounded).tests_py/infrastructure/test_workflow_graph_ast_search_degraded.py(new): a wedged bridge leavessearch_codebasereturning[]withlast_search_degraded_reasonset; a disabled AP leaves the reasonNone(never attempted, not a failure).tests_py/handlers/test_unified_search_degraded_status.py(new): the handler surfacesstatus="partial"+degraded={"source":"ap",...}on a wedged AP call, staysstatus="ok", degraded=Nonewhen AP genuinely finds nothing, and staysstatus="partial", degraded=Nonewhen AP is disabled outright.Gates:
ruff check✓ ·ruff format --check✓ · craftsmanship gate ✓ (fixed aAPBridge.call42-line method-size violation via an extracted_degradehelper, which also removed the duplicated except-branch logic) ·pytest tests_py/infrastructure/test_ap_bridge_interactive_timeout.py tests_py/infrastructure/test_workflow_graph_ast_search_degraded.py tests_py/handlers/test_unified_search_degraded_status.py tests_py/infrastructure/test_workflow_graph_source_ast.py tests_py/handlers/test_response_budget_wiring.py→ 42 passed.Completes the trio of bugs the real benchmark surfaced, alongside #447 (FTS5 crash) and #448 (error misclassification).
🤖 Generated with Claude Code
Generated by Claude Code