fix(dashboard): read the agent tool policy off the event loop - #4946
Conversation
api_session_tool_policy resolved the calling session's agent config
inline: a stat, a read and a JSON parse on the single event loop every
other gateway request shares. This is not a startup path -- managed MCP
servers call it to filter their tool lists per agent, so it runs on
ordinary request traffic, and the agents dir is a real user directory
whose reads can be slow on a network home or while an indexer holds the
file.
Move the whole transaction to a worker through asyncio.to_thread rather
than only the read: offloading read_text alone would leave the existence
probe and the parse on the loop, which is the same defect in a smaller
form.
Observable behaviour is unchanged. The helper answers None for the cases
that already returned an empty policy without an audit record -- missing
file, OSError, malformed JSON, a non-dict policy -- and a dict for a
config that was read and understood. That split matters: a policy of {}
because the key is absent is an agent whose config WAS parsed, which is
what the SEL ok record attests, while an unreadable file is not. Folding
them together would start logging success for files that were never read.
The path-traversal guard still runs before any filesystem work, and
kiro_agents_dir() stays on the loop because it is pure path arithmetic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix matching the repo's established [DESIGN-REVIEWED] 5202903 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe candidate file's discovery pass found no candidates. I've verified the diff independently: this is a behavior-preserving refactor that moves the No findings. [OPUS-REVIEWED] 5202903 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of Reading complete — I reviewed the patch, the intent file, and the base tree (the handler at First-Principles-Verdict: CONCERNS The fix is real and derived, but its new bare reader is a fourth spelling of a job What this change shipsIntent: stop
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 5202903 |
bolichen97
left a comment
There was a problem hiding this comment.
Approved after a description-vs-diff consistency review: every claim in the PR description is backed by the diff, and the diff carries no material change the description leaves unmentioned.
…tdev#4946) api_session_tool_policy resolved the calling session's agent config inline: a stat, a read and a JSON parse on the single event loop every other gateway request shares. This is not a startup path -- managed MCP servers call it to filter their tool lists per agent, so it runs on ordinary request traffic, and the agents dir is a real user directory whose reads can be slow on a network home or while an indexer holds the file. Move the whole transaction to a worker through asyncio.to_thread rather than only the read: offloading read_text alone would leave the existence probe and the parse on the loop, which is the same defect in a smaller form. Observable behaviour is unchanged. The helper answers None for the cases that already returned an empty policy without an audit record -- missing file, OSError, malformed JSON, a non-dict policy -- and a dict for a config that was read and understood. That split matters: a policy of {} because the key is absent is an agent whose config WAS parsed, which is what the SEL ok record attests, while an unreadable file is not. Folding them together would start logging success for files that were never read. The path-traversal guard still runs before any filesystem work, and kiro_agents_dir() stays on the loop because it is pure path arithmetic. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Every config.json update in dashboard/handlers/memory.py reads the file, changes one key and writes it all back on the event loop. The read, the JSON parse, and write_config_atomically -- a tmp-file write plus a rename, which can fsync -- all block, stalling every other session while they run. Three sites do it: api_memory_settings, _write_embed_model_config, and _set_migrated, which runs on EVERY boot while migrated is false and so lands the stall exactly when the gateway is bringing sessions up. This is the class the repo has been closing site by site (kirodotdev#4118, kirodotdev#3803, kirodotdev#4550), and kirodotdev#4946's review named this module. The whole transaction crosses over, never just the read. Offloading the read alone would leave the write on the loop and insert a suspension point between the read and the write-back while the file is unguarded on disk: an external editor, a CLI command or another process landing in that gap would be silently overwritten by a write derived from state nobody re-checked. That gap is zero today because the sequence is synchronous, and it stays zero because the worker performs the whole thing without yielding. The existing per-config lock is held across the hop, so two coroutines still cannot interleave. Two of the three sites also hand-rolled a reader this module already imports. read_config_for_update is the documented companion to write_config_atomically with 27 call sites, and api_memory_settings uses it 200 lines above; _set_migrated and _write_embed_model_config instead caught Exception around json.loads. The helper additionally refuses a non-object top level, where the hand-rolled version accepted a list and then raised AttributeError from setdefault -- a crash where a fail-closed refusal was intended. ConfigReadError is not swallowed by the helper: what to tell the user differs per site, and each keeps exactly the behaviour it had -- skip and retry next boot, raise ValueError, or answer 500 config_unreadable. api_memory_settings now validates its body before the transaction. None of that reads the config, and a 400 previously took the lock and abandoned it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_write_env_updates stats and reads the whole .env, re-parses it line by line, then creates a 0600 temp file, chmods it, writes and renames. All synchronous file I/O, and six async channel config-save handlers call it. Three already reached it through asyncio.to_thread -- telegram, teams, wecom -- and three called it inline on the gateway loop: slack, discord and webex, stalling every other session for the duration of a token save. So this is not a missing convention but an existing one applied to half the call sites. messaging.py already uses asyncio.to_thread 29 times, and with three siblings doing it correctly nothing in the file said which half was right, or stopped the next channel from copying the wrong one. It is the class the repo has been closing site by site (kirodotdev#4118, kirodotdev#3803, kirodotdev#4550), and kirodotdev#4946's review named this module. The WHOLE call is offloaded, never a part of it: the read-modify-write is one transaction, and a suspension point between the read and the rename would let a concurrent writer's keys be dropped by a write derived from lines nobody re-read. Keeping _write_env_updates one synchronous function on one worker preserves that without depending on the caller, which is now said on the function itself so the next channel inherits the reason and not just the shape. The regression pins all six channels rather than the three that moved, since the defect was the split and not any one site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem / Motivation
GET /api/session-tool-policyresolves the calling session's agent config on the gateway event loop.api_session_tool_policydoes the whole filesystem transaction inline:A stat, a read and a JSON parse, none of them offloaded, on the single loop every other gateway request shares.
Why it matters
This is not a startup path. Managed MCP servers (kirocrew-core, kirocrew-cron) call this endpoint to filter their tool lists per agent —
docs/architecture/mcp.mdlists it as a gateway round-trip on the MCP path — so it runs on ordinary request traffic.The directory it reads is a real user directory (
<kiro home>/agents), not a cache, so its reads are exactly as slow as that filesystem is: a network home, a cold page cache, or a Windows indexer or AV holding the file each turn a "cheap" read into loop time that every other request waits behind. Nothing about the call site bounds that cost.The repo already treats this as a defect class of its own (
no-blocking-call-on-event-loop), with the same correction landed at other call sites — #4118, #3803, #4550.What changed (motivation → approach → change)
Symptom — an MCP tool-list filter request performs blocking filesystem work on the gateway loop.
Root cause — the existence probe, the read and the parse are written inline in an
async def, so they execute on whatever thread the coroutine is running on, which is the loop's.Change — extract the three into one sync helper and invoke it through
asyncio.to_thread:The whole transaction moves, not just the read. Offloading
read_textalone would leave thestatand thejson.loadson the loop — the same defect in a smaller form, and a thread-identity test would then prove the fix incomplete rather than prove it correct.Noneis deliberately distinct from{}. The helper answersNonefor every case that already returned an empty policy without an audit record — missing file,OSError, malformed JSON, non-dict policy — and a dict for a config that was read and understood. That split is load-bearing: a policy of{}because the key is absent is an agent whose config was parsed, which is what the SELokrecord attests, while an unreadable file is not. Folding the two together would start logging success for files that were never read. Two tests pin both halves.Not moved:
kiro_agents_dir()stays on the loop — it iskiro_home() / "agents", pure path arithmetic with no I/O, and moving it would be style rather than a fix. The path-traversal guard still runs before any filesystem work, exactly as before.One production file; no route, contract, or response-shape change.
Tests
test_the_agent_config_read_runs_off_the_event_looptest_a_missing_agent_config_is_an_empty_policy{}test_an_unparseable_agent_config_is_an_empty_policy{}, not a raisetest_a_non_dict_policy_is_an_empty_policy{}test_an_agent_without_a_policy_key_is_reported_as_readoktest_an_unread_config_is_not_logged_as_oktest_a_traversing_agent_name_is_still_refusedtest_a_missing_session_key_is_refusedThe proof is thread identity at the real filesystem seam —
Path.read_textitself — not an assertion thatasyncio.to_threadwas called. A spy on the offload would keep passing if the call were later moved back inline behind another wrapper; the thread the read actually runs on cannot be faked. Same shape as the accepted proof in #4118.Fail-before / pass-after on
c940485dc, with onlysessions.pyreverted:The seven behavioural tests pass on both sides, which is the point: they demonstrate that observable behaviour is preserved rather than merely re-stated.
test_session_tool_policy_off_loop.py+test_mcp_shared_cache.py+test_mcp_call_site_auth_coverage.py: 36 passed.Gates:
flake8·isort·scripts/check_black_formatting.py·scripts/check_brand_name.py.Manual verification
N/A — unit coverage sufficient: the change is entirely about which thread performs the filesystem work, and the test measures that directly on the production handler with only the seam instrumented. Observing it in a running gateway would mean timing loop stalls under a slow filesystem, which is precisely the non-deterministic evidence the thread-identity assertion replaces.
Related Issues
Fixes #4945.
Same defect class as #4118, #3803 and #4550.
Checklist
docs/architecture/mcp.mddocuments this endpoint's contract (it returns the session'smanagedToolPolicy.exclude), which is unchanged; only the thread the read runs on differsContribution License Agreement
🤖 Generated with Claude Code