Home / Projects / Orchestrata Ultimate / Decisions / ADR index / ADR-0097

ADR-0097: The resident AST daemon publishes the snapshot file from its warm cache — the post-edit hooks stop re-serializing the tree on every write, and the duplicate home-directory refresh hook is retired

field value
Status Accepted — Accepted (ruling, Mohamed, 2026-10-13, verbatim: "SHOULD WE REPLACE IT NOW TO SAVE HOOK TIME, NOTE I THINK IT WAS DONE IN ORCHERSYTAYA ULTIMATE ARCHITECTURE PROJECT" → recommendation presented → "mint the adr and brief…
Date 2026-10-13
Register factory (docs/adr, 4-digit ids)
Source docs/adr/0097-the-resident-ast-daemon-publishes-the-snapshot-the-post-edit-hooks-stop-re-serializing-and-the-duplicate-hook-is-retired.md — permalink at f5dffa8ab (unchanged on master since that commit)
Wiki register entry ADR-0097 (wiki status: Accepted)

← ADR-0096 · ADR-0098 →

The record

Status: Accepted (ruling, Mohamed, 2026-10-13, verbatim: "SHOULD WE REPLACE IT NOW TO SAVE HOOK TIME, NOTE I THINK IT WAS DONE IN ORCHERSYTAYA ULTIMATE ARCHITECTURE PROJECT" → recommendation presented → "mint the adr and brief the builder") · Date: 2026-10-13 · Extends the AST-daemon work (P1 location-independent, P2 install-tools, both merged) · Bounded hook change; not the codetools loader of codetools ADR-0022, which stays sequenced behind the gate wave

Context

Two refresh paths fire on every Write/Edit in an orchestrata checkout:

  1. The plugin's post-edit hook plugins/orchestrata/project-hooks/ast-incremental-refresh.sh (deployed under ~/.claude/plugins/cache/orchestrata/orchestrata/<ver>/), dispatched by _dispatch.sh, which detaches scripts/serialize_codebase_ast.py serialize … --incremental to rewrite plans/ast-ultimate.json and logs to .cache/ast-refresh.log.
  2. claude/hooks/ast_sync_hook.py (deployed to ~/.claude/hooks/, also shipped as plugins/orchestrata/hooks/ast_sync_hook.py), which detaches the same serialize plus a Prolog KB reindex.

Both are already incremental in the narrow sense (only files whose content hash changed are re-parsed; every other module's dict is reused), yet the refresh hook's own header records a warm re-serialize of 64 s, and on 2026-10-13 at 15:02 one edit in the adr-sync-fix worktree had two serializers plus the module-context hook running concurrently for over two minutes. The hook explicitly detaches so the edit is not blocked, which means the cost is paid in CPU and in snapshot staleness windows rather than in latency, and it is paid twice.

What already exists: scripts/ast_daemon.py, deployed as /opt/orchestrata-tools/bin/ast-daemon and run per checkout by orchestrata-ast-daemon@<path>.service (started by the start-ast-daemon.sh hook; running today for the primary checkout, the boot-errors worktree and, since 15:06, adr-sync-fix). It holds the parsed tree warm, watches the sources with watchfiles (inotify, debounced), invalidates only changed files in its cache, and serves every toolkit query over a Unix socket (query_ast_daemon in serialize_codebase_ast.py, with a local fallback). Its header states that plans/ast-ultimate.json is "a CLI-only snapshot" the daemon does not write. The P1/P2 worktrees that built it are merged (0 commits ahead of master); the architecture branch carries no further AST work. So the warm, incremental engine Mohamed remembers is built and running; the gap is that the on-disk snapshot the freshness pin, code_intel, gates.py and the Prolog KB read is still produced by a separate re-serialize on every edit.

Decision (Mohamed's ruling, restated as the executable rule)

  1. The daemon publishes the snapshot. ast_daemon.py gains a publish command (socket request) that writes the snapshot artifact for its repo (plans/ast-<name>.json, same schema and file_meta block the serializer writes, atomically via temp file + rename(2)) from its warm cache, and a watcher-driven publish: after the debounced invalidation of changed .py files, the daemon republishes the snapshot (debounce configurable, default the existing --watch-debounce-ms). Publishing reuses the serializer's own writer so the artifact is byte-compatible with a CLI serialize of the same tree.
  2. The post-edit hook asks the daemon first. ast-incremental-refresh.sh checks for a live daemon socket for the repo (<repo>/.claude/run/ast-daemon.sock); if present it sends publish (or, when the watcher already republished after this edit, does nothing) and exits; only when no daemon is live does it fall back to today's detached serialize. The freshness pin (ast-freshness-pin.sh) keeps reading the file and keeps its STALE verdict logic unchanged.
  3. The duplicate hook is retired. claude/hooks/ast_sync_hook.py stops serializing; its remaining job (the Prolog KB reindex) moves behind the same daemon-publish event (a reindex runs only when the snapshot content hash changed), or is folded into gates.py's existing ast-sync + reaugment ordering, which stays the one explicit full refresh. One edit triggers at most one snapshot write and at most one reindex.
  4. Measured acceptance. On a worktree with a live daemon, the post-edit wall from Edit return to a fresh snapshot (file_meta hash of the edited file updated) is measured and recorded; target is seconds, against the 64 s warm re-serialize and the >120 s double run observed. The freshness pin reports fresh after the edit. gates.py full refresh still works and still reaugments runtime calls (the bare ast-sync zeroing hazard noted in the pin hook must not regress).
  5. Scope. Hooks and the daemon only; no change to the snapshot schema, to serialize_codebase_ast.py's CLI, to code_intel, or to codetools. Deployed through deploy/install-claude-config.sh and deploy/install-tools.sh like any hook/tool change.

Alternatives rejected

Consequences

Provenance

Mohamed, 2026-10-13, verbatim in Status. Measurements: hook header (ast-incremental-refresh.sh lines 84–87, "a warm re-serialize is 64s"); process list at 15:02 (ast-sync ×2, serialize_codebase_ast.py serialize, module_context.py, 108–118 s elapsed on one edit); daemon source scripts/ast_daemon.py (_watch_loop, _invalidate_file, header note on plans/ast-ultimate.json); unit orchestrata-ast-daemon@.service (ExecStart=/opt/orchestrata-tools/bin/ast-daemon --repo-root /%I --socket /%I/.claude/run/ast-daemon.sock); worktrees wt-AST-DAEMON-P1/P2 at 0 commits ahead of origin/master. Recorded by the coordinating session; the code change is delegated under the brief.

Incident evidence added 2026-10-13 15:20, after acceptance (append-only; decision unchanged, scope of rules 2–3 made explicit): one builder's edits in the adr-sync-fix worktree accumulated 32 concurrent serialize_codebase_ast.py serialize processes, 16 ast-sync and 12 orphaned module_context.py hook processes (reparented to the user systemd manager after their 45 s hook timeout), driving the load average to 45 on 16 threads; the coordinator terminated the hook byproducts and load fell to 29 within a minute. Causes: the plugin refresh hook's single-writer flock is bypassed by ast_sync_hook.py, which spawns its serializer unlocked; and _dispatch.sh does not kill a timed-out hook's process group. Both are inside the hooks this ADR already names, so the builder brief gains: (a) at most one serializer per repo at any time, with the daemon publish single-writer and the fallback taking the same lock, proven by a burst test; (b) every dispatched hook runs in its own process group that is killed on timeout, proven by a slow-dummy-hook test. Mohamed, same time: "I THINK WE FIXED THE ORPHAN PROBLEM EITHER HERE OR USING KIMI IN THE ORCHESTRATA ULTIMATE ARCH PROJECT" — checked: the fixes he recalls are reap-orphan-waiters.sh (agent polling loops, deliberately conservative after it killed a live agent on 2026-10-11) and the architecture branch's "incremental loads no longer leave orphan subtrees" (graph), neither of which covers hook child processes.

Rulings cited

None.

Specs cited

None (or no spec page exists for the ones named).