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:

  1. extract_imports(py_file) (588) → _parse_module (532) → read_text()+ast.parse() (497, 502). Parse #1.
  2. extract_function_scoped_imports(py_file) (603) → _parse_module again (558) → same bytes, re-read, re-parsed. Parse #2.
  3. check_di_instantiation (612) → own read+parse (156-157). Parse #3.
  4. check_direct_driver_construction (618) → own read+parse (209-210). Parse #4.
  5. 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

  1. 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_SOCKET seam, 380-388), assert query_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).
  2. Daemon-down fallback test: point AST_DAEMON_SOCKET at a nonexistent path, assert query_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 for architecture, undeclared import for check-deps) — the concrete proof against a false-green when the daemon is down.
  3. Integration proof: scripts/gates.py --all twice back to back — once daemon-warm, once with AST_DAEMON_SOCKET pointed 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).