Orchestrata Ultimate › Plans › gate-daemon-routing-design
Gate-daemon routing design — cutting architecture (26.1s) and check-deps (14.1s)
Source: plans/gate-daemon-routing-design.md at 8a2cb42ad · last changed 2026-08-07 · first committed 2026-08-07 · 190 lines.
Prose pending.
Plan text
Gate-daemon routing design — cutting architecture (26.1s) and check-deps (14.1s)
Status: design only, no code changed. Baseline: scripts/gates.py measured
2026-08-07, 10 gates parallel, wall = 26.1s (= architecture's own runtime).
(a) Where the time actually goes — read from source, not measured
architecture — 26.1s, scripts/architecture_checker.py
check_project() (architecture_checker.py:572-635) walks
sorted(root.rglob("*.py")) (line 584) — 844 files under
src/orchestrata_ultimate (find ... -name '*.py' | wc -l → 844). For
every file it calls, unconditionally and independently:
extract_imports(py_file)(588) →_parse_module(532) →read_text()+ast.parse()(497, 502). Parse #1.extract_function_scoped_imports(py_file)(603) →_parse_moduleagain (558) → same bytes, re-read, re-parsed. Parse #2.check_di_instantiation(612) → own read+parse (156-157). Parse #3.check_direct_driver_construction(618) → own read+parse (209-210). Parse #4.check_graph_seam(624) → own read+parse (359-360). Parse #5.
5 independent ast.parse() calls per file, zero sharing — 4,220 parses
of 844 files. architecture_checker.py never imports
serialize_codebase_ast and has no cache of its own.
A second, likely larger defect rides on parses #1/#2. extract_imports /
extract_function_scoped_imports iterate every orchestrata_ultimate
import node (_iter_ultimate_imports, 509-521) and, per node, call
_is_inside_type_checking (453-461) and _is_inside_function_or_class/
_is_inside_def (464-484) — each does a full ast.walk(tree), with a
nested ast.walk(subtree) per candidate enclosing scope found. Called
from extract_imports at 538/540 and extract_function_scoped_imports at
564/566. Net cost per file is O(imports_in_file × nodes_in_file), not
O(nodes) — a full-tree rescan per orchestrata_ultimate import statement.
I cannot apportion wall-time between the linear 5x redundancy and this
quadratic scan from reading alone — that needs cProfile/py-spy on one
run, which I did not do (gates are off-limits this session). Indirect
evidence the quadratic term matters more: check-deps scans ~3x more
files (below) with a linear algorithm and still finishes in about half
the time.
check-deps — 14.1s, scripts/serialize_codebase_ast.py check-deps
gates.py runs it with --include-tests (gates.py:61-69): src/ (844) +
tests/ (1,557) ≈ 2,401 files. Handler (serialize_codebase_ast.py:11495-
11501) calls find_undeclared_dependencies (1324) → _collect_imports
(1341, defined 1258-1299).
_collect_imports parses each file once (line 1275, its own private
ast.parse(path.read_text(...))), builds one parents dict via one
ast.walk(tree) (1279-1281), then does an O(1) dict lookup per import node
(1294). Linear, O(nodes) per file — no quadratic blowup, which is why it
beats architecture despite ~3x more files.
The defect: that parse (1275) is raw and uncached — it never calls
_parse_cached (1394-1454), the module's own warm-cache-aware parse
(consulted by _iter_py_files, 1457-1483, which find_undeclared_ dependencies does not use). So check-deps bypasses both the in-process
_PARSE_CACHE and the cross-invocation sha1 sidecar (configure_parse_ cache, 187) even though the same process wires that cache up on every
invocation (11367-11368) — check-deps just never reads it.
_local_module_names (1302-1321) does a third independent rglob, but
only reads path.name/.stem — no parse, cheap.
(b) Daemon operations — existing vs. new
The daemon (scripts/ast_daemon.py) speaks two protocols on
.claude/run/ast-daemon.sock: (1) every plain GET route already defined
in scripts/ast_api.py — _load_ast_api() (71-85) exec_modules that
file, registering its routes on app at import time, and _install_routes
(409) only adds /health+/query to that same object; (2) POST /query
dispatched via _QUERY_DISPATCH (107-125): symbols, refs, source,
who-imports, find-cycles, call-graph, complexity. Only (2) is what
the CLI client query_ast_daemon (serialize_codebase_ast.py:412-434)
speaks — it never hits an arbitrary GET route.
check-deps: 0 new capability, 1 new dispatch line. ast_api.py's
/check-deps route (190-203) already calls _tool.find_undeclared_ dependencies(...), same signature the CLI gate uses. Add to
_QUERY_DISPATCH:
"check-deps": lambda p, api: api.check_deps(
source=_param_source(p),
pyproject=p.get("pyproject", "pyproject.toml"),
include_tests=p.get("include_tests", False),
),
No new route, no new logic — matches the daemon's own stated tenet
(ast_daemon.py:17-26). Caveat: this alone does not fix the dominant
cost from (a) — _collect_imports still raw-parses inside the daemon
process. Real fix: rewrite it to consume _iter_py_files (1457-1483,
already _parse_cached-backed) instead of its private loop — a
scripts/ change, out of this session's write scope, named as required
follow-up.
architecture: genuinely new, 1 route + 1 dispatch entry. ast_api.py
imports only serialize_codebase_ast (line 42) — nothing wraps
architecture_checker.py. Needed: a new GET /architecture route in
ast_api.py (imports architecture_checker, calls check_project(Path(...)),
returns the 4-tuple as JSON), a new _QUERY_DISPATCH["architecture"] entry,
and a query_ast_daemon("architecture", ...) call added to
architecture_checker.py main() (638-689, currently has none) following
the exact symbols/refs pattern (serialize_codebase_ast.py:11530-11541).
None of this yields a speedup by itself — see (c): the daemon process
being warm only saves interpreter/import startup unless check_project
is also rewritten to share one parse per file (via _parse_cached, so it
also gets the cross-call warm cache).
(c) Design
Client shape: reuse query_ast_daemon exactly as the existing seven
subcommands do (e.g. 11530-11541) — call it, and on the _DAEMON_MISS
sentinel (349-357, distinct from a real falsy/empty result) fall through to
the unmodified local function. Not new design, just applying the existing
pattern to two more commands.
Daemon-down correctness: already enforced by that sentinel — the
forbidden shape is rows = daemon_result or local_call() (a real, correct,
empty daemon answer wrongly triggers a redundant recompute — wasteful, not
false-green) or if rows: use it (a real empty answer silently discarded
and misreported as "no violations" — that is the false-green class
this brief forbids). Enforce via review of the two new call sites, not a
new mechanism.
Staleness: solved underneath, provided both routes go through
_parse_cached (1394-1454) as recommended in (b): every access
re-validates (mtime_ns, size) (1409-1417) regardless of the background
watcher, so a stale entry costs an extra re-parse at worst, never a wrong
answer. The watcher (_watch_loop, 210-247) only pre-warms state.sources,
which defaults to src/orchestrata_ultimate alone (_DEFAULT_SOURCE,
line 62) — check-deps --include-tests also touches tests/, uncovered by
default. Correctness holds regardless (stamp check catches it); start the
daemon with --source src/orchestrata_ultimate,tests to get eager pre-warm
there too. gates.py's own fingerprint cache (90-118, 252-258) is a second,
independent staleness guard sitting above all of this — a gate isn't even
invoked when its input roots' git-hash is unchanged.
(d) Expected wall-time — arithmetic, and where it runs out
De-duplicating the 5 parses to 1 removes 4/5 of the linear cost;
removing the O(imports×nodes) rescans (one top-down pass tracking
TYPE_CHECKING/def/class scope while descending, instead of per-import
backward ast.walks) removes the quadratic term. I cannot give a
post-fix number for architecture — the split between the two terms needs
profiling, not reading (named in (a); not done this session).
Bound from an already-measured data point: check-deps's linear,
single-parse algorithm covers ~3x more files (2,401 vs 844) in 14.1s. A
linear, single-parse architecture over only 844 files should land well
under 14.1s — plausibly near validate-ci's 9.5s — but this is a
directional bound, not a prediction. Cannot predict the exact number
without profiling.
Once source content is unchanged across repeat gates.py runs, the daemon
path (500ms-capped /health + one /query round-trip over already-cached
trees) approaches the same near-zero marginal cost the other seven daemon
subcommands get — but gates.py's fingerprint cache already skips
re-running an unchanged gate entirely, so the daemon only helps the first
run after a real source change, not repeat no-op runs (already free).
If both land, the new parallel wall is whatever the next-slowest gate is —
today validate-ci at 9.5s, unless it has the same redundant-parse shape
(unread this session, so unknown). Re-measure after the change; don't
project further than that.
(e) Verification plan
- Golden-equality test per gate, mirroring the existing
test_cli_delegation_uses_daemon_when_socket_present(tests/scripts/test_ast_daemon.py:468): with a live daemon on a throwaway socket (AST_DAEMON_SOCKETseam, 380-388), assertquery_ast_daemon("check-deps"/"architecture", params)deep-equals the plain local call over the same real tree (src/orchestrata_ultimate[+tests/]) — no mocking, no synthetic fixture (a fixture could hide a JSON round-trip edge case the live tree wouldn't). - Daemon-down fallback test: point
AST_DAEMON_SOCKETat a nonexistent path, assertquery_ast_daemon(...) is _DAEMON_MISS, and assert the CLI fallback still produces the correct verdict against a fixture seeded with a KNOWN violation (upward import forarchitecture, undeclared import forcheck-deps) — the concrete proof against a false-green when the daemon is down. - Integration proof:
scripts/gates.py --alltwice back to back — once daemon-warm, once withAST_DAEMON_SOCKETpointed dead — diff violation counts (must be identical) and per-gate wall-times to confirm direction and magnitude. Deferred to implementation time; not run this session (gates.py is off-limits while the suite is live).