From 0aac3b89925b8f674d6576af763bca6c4fedb0a0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 11 Aug 2026 12:37:40 +0200 Subject: [PATCH] docs: D3 framing revision 2 --- round 1 removed the sleep, the guess, and the skip Review round 1 (five findings, four P1) and revision 1 did not survive it. The findings are recorded in the document; three are cases where the framing reasoned from the wrong mechanism. The promised idle state was impossible: pmacs.workers.sleep is a RUNNING job for its whole duration --- dispatched onto the worker pool, sleeping in 1ms slices on one of available_parallelism - 1 threads --- and activity_summary counts every running job. A 4s backoff sleep renders as a constant "sleep 4000ms", and filtering it would touch the instrument. Revision 2 removes the sleep from the design: one process.after-tick subscription owns every group's schedule via monotonic_ms --- autosave's Q#AS2 idiom, whose own comment names the pool-thread hazard. Waiting now allocates no job and no thread; the indicator is absent at idle by its None-at-zero contract, which also becomes the strongest witness in the plan. The scan root is the server's, not a guess: root_uri then cwd then the attached-file fallback. project.detect was wrong by the tree's own testimony --- server rooting honors configured strings and resolvers first, and texlab's Q#LX2 documents a root that detect can never produce. Coalescing gained delivery semantics: shared snapshots, per-watcher baselines (the first snapshot completed after join), membership captured at scan start, cancellation rechecked per watcher at emit (#234's P2 rule, per member). Two epoch witnesses join the plan; the six existing tests do not cover this and were never claimed to. Exclusions default to NONE. Any unconditional skip deviates from the registered glob contract (**/-leading globs can match under .git/, and a server may register .git/HEAD outright), and walk_tree removes the job-count economics that made skipping look necessary: 80% of jobs becomes readdir syscalls inside one job. Arithmetic corrected to this checkout: D = 220, so 221 jobs per watcher per tick and 1,326 for rust-analyzer's six --- six of them pool-thread-holding sleeps. Revised steady state: zero jobs at idle, one walk_tree job while a scan runs. Q#D3-1..4 rewritten accordingly; all four still block implementation. Co-Authored-By: Claude Fable 5 --- docs/active-work.md | 18 +- docs/lsp-file-watch-d3-framing.md | 385 ++++++++++++++++++++---------- 2 files changed, 278 insertions(+), 125 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 79d2bf8..5aac92a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -250,10 +250,22 @@ the same day (`b867f64`), refreshed and re-gated on the merged base. **D3 — the polling cost — is the remainder, and the user has ruled it is next (2026-08-11).** **Branch `lsp-file-watch-d3`** (base `githubsucks/main` @ `add0ba1`; the remote ref is authoritative), with -**framing `docs/lsp-file-watch-d3-framing.md`, revision 1, DRAFT — +**framing `docs/lsp-file-watch-d3-framing.md`, revision 2, DRAFT — awaiting review**, committed at the branch's first commit so it is -portable during review. Four open rulings (Q#D3-1..4: the acceptance -bar, the skip-list default, the string-form base, knobs vs constants) +portable during review. **Review round 1 (2026-08-11) found five +findings and revision 1 did not survive it** — the promised idle +state was impossible (`workers.sleep` is a pool-thread-holding +running job the indicator counts; revision 2 replaces the sleep loop +with autosave's Q#AS2 after-tick cadence), the scan root must be the +server's own `root_uri`/`cwd` (not `pmacs.project.detect`, which +texlab's Q#LX2 proves wrong), coalescing gained registration-epoch +delivery semantics with two new witnesses, the exclusion default +became **none** (any unconditional skip deviates from the registered +glob contract, and `walk_tree` removes the job-count economics that +motivated it), and the cost arithmetic was corrected to this +checkout (1,326 jobs/tick for rust-analyzer's six watchers; revised +steady state is zero jobs at idle). Four open rulings (Q#D3-1..4: +the acceptance bar, exclusions, the scan root, knobs vs constants) block implementation. What was known before framing, verified while framing D1/D2: diff --git a/docs/lsp-file-watch-d3-framing.md b/docs/lsp-file-watch-d3-framing.md index e849fe7..2e0f3ce 100644 --- a/docs/lsp-file-watch-d3-framing.md +++ b/docs/lsp-file-watch-d3-framing.md @@ -1,6 +1,6 @@ # LSP file watcher D3 — the polling cost — framing -**Status: revision 1 — DRAFT, awaiting review. No implementation may +**Status: revision 2 — DRAFT, awaiting review. No implementation may begin from this document.** Continues issue #233, which stays open until this lane closes it. D1 @@ -9,149 +9,289 @@ and D2 — matching correctness and the re-registration leak — merged as 2026-08-11: the watcher is now *correct* and still walks everything, every tick, forever. +## Review round 1 — five findings, and what each changed + +Revision 1 was reviewed 2026-08-11 and did not survive it. Recorded +here because three of the five are cases where the framing reasoned +from the wrong mechanism, which is exactly what a framing review +exists to catch before code does. + +- **P1 — the promised idle state was impossible.** Revision 1 + promised "one brief `walk_tree` blip every four seconds"; but + `pmacs.workers.sleep` allocates a **running job for its whole + duration** (`dispatch_sleep`, `src/async_runtime.rs:1022-1037` — + the job sleeps in 1 ms slices on a **pool thread**), and + `activity_summary` counts every `Running` job + (`src/async_runtime.rs:1570`). A 4 s backoff sleep would render as a + *constant* `⋯1 sleep 4000ms`, and filtering sleeps from the + indicator would touch the instrument this lane declares out of + scope. **Revision 2 removes the sleep from the design entirely** + (see "The cadence" below) — the fix is the codebase's own idiom, + not a new mechanism. +- **P1 — the scan root must be the server's, not a freshly detected + one.** Revision 1 proposed `pmacs.project.detect` for the + string-form base. Server rooting honours **configured strings and + custom resolvers first** (`project_root_for`, + `builtin/runtime/lsp.lua:783`), and the bundled texlab entry + documents why its root *cannot* be `project.detect` (Q#LX2, + `lsp.lua:284` — texlab wants the document root, not the repository + root). The server's own `root_uri` and `cwd` are already exposed + (`pmacs.lsp.list`, `src/lua_bindings/mod.rs:10985-11002`; + `root_uri` is the spec field verbatim, nil when the server never + asked for a root). **Revision 2 roots the scan at what the + registering server actually serves**: `root_uri` → `cwd` → + attached-file directory, in that order. +- **P1 — coalescing needs registration-epoch semantics.** Revision 1 + said "route the shared diff through every watcher" without defining + which watcher set owns a diff. A watcher registered between two + snapshots would receive a false CREATED for a file that predates + its registration; membership changing during a walk recreates + either #234's stale-watcher batch or the same pre-registration + event. **Revision 2 defines shared snapshots with per-watcher + baselines** (below), plus two witnesses the existing six tests do + not cover. +- **P1 — "VCS-only exclusion is safest" was wrong.** A hard skip + silently ignores a server that legitimately registers `.git/HEAD` + or `**/.git/**`, and glob semantics mean even `**/*.rs` *can* match + under `.git/` — so any unconditional exclusion is a deviation from + the registered contract, not a safe default. Revision 1 also + overweighted the win: exclusion was a **job-count** lever when every + directory was a separate job, and the walk primitive removes that + economics. **Revision 2 defaults to no unconditional exclusion** + and reframes Q#D3-2 around the full option set. +- **P2 — the arithmetic used the issue's machine, not this one.** + With D = 220 on this checkout it is 220 `read_dir` jobs **plus one + sleep** per watcher per tick — 221, or **1,326 across + rust-analyzer's six watchers** — and revision 1's proposed steady + state was itself two jobs (sleep + walk), not one. Corrected + throughout; the revised design's steady state is **zero jobs at + idle** and one `walk_tree` job while a scan runs. + ## Verified against the tree at `add0ba1` -Every claim below was read or measured this session. +Every claim below was read or measured this session (revision 2 +re-verified the round-1 corrections against the code). - Each registered watcher is its own coroutine looping `sleep(FILE_WATCH_INTERVAL_MS)` → `scan_tree` (`lsp.lua:1924`, `:2074-2083`); the interval is 250 ms. - `scan_tree` awaits `pmacs.fs.read_dir` once **per directory** - (`:2038-2041`), one async job each (`async_runtime.rs`'s `read_dir - ` purpose). `walk` recurses unconditionally; `matches` gates - only whether an entry is *recorded*. -- **Jobs per tick per watcher = 1 sleep + D read_dirs**, D the - directory count under the base. rust-analyzer registers six watchers - (post-D2; twelve before), so 6·(1+D) jobs per tick against one - server. -- **250 ms is a floor, not a period.** The awaits are sequential, so a - tree whose walk takes longer than the interval makes the effective - period scan-bound — the loop never idles. The issue measured ~270 ms - on a two-directory tree (250 ms plus one async round trip); D round - trips dominate on real trees. -- Measured on this checkout: **220 directories, of which `.git` is 177 - — over 80 % of the walk**. (The issue's machine carried an in-tree - `target/`: 187 directories, 46 outside `.git`+`target`. This machine - exports an external `CARGO_TARGET_DIR` and still pays `.git`.) + (`:2038-2041`), one async job each. `walk` recurses + unconditionally; `matches` gates only whether an entry is + *recorded*. +- **A sleep is a pool-occupying running job.** `dispatch_sleep` + dispatches `run_sleep` onto the worker pool + (`async_runtime.rs:1022-1037`), so every sleeping watcher holds one + of the pool's `available_parallelism - 1` threads for the full + interval — the hazard `autosave.lua:133-139` documents in so many + words. Twelve pre-#234 rust-analyzer watchers were twelve + mostly-sleeping pool threads. +- **Jobs per tick per watcher = 1 sleep + D read_dirs.** On this + checkout D = 220, so 221 per watcher and **1,326 per tick for + rust-analyzer's six watchers**. +- **250 ms is a floor, not a period.** The awaits are sequential, so + a tree whose walk takes longer than the interval makes the + effective period scan-bound — the loop never idles. The issue + measured ~270 ms on a two-directory tree; D round trips dominate on + real trees. +- Measured on this checkout: **220 directories, of which `.git` is + 177 — over 80 % of the walk**. (The issue's machine carried an + in-tree `target/`: 187 directories, 46 outside `.git`+`target`. + This machine exports an external `CARGO_TARGET_DIR` and still pays + `.git`.) - **The string-form base is nondeterministic, found while framing:** `resolve_watcher`'s string arm takes the directory of the FIRST - attachment `pairs()` happens to yield (`:2150-2173`) — table order, - not a chosen root. With one server attached to files in two - directories, which tree a bare-string watcher can ever see depends - on hash order. #234 made matching correct *per base*; **which** base - is still accidental. -- **No `notify`/inotify dependency in the tree** and **no ignore-list - infrastructure to reuse** (`src/project.rs` knows `.git` as a - *marker*, not as something to skip) — both re-verified, both carried - from the D1/D2 framing. + attachment `pairs()` happens to yield (`:2150-2173`) — table + order, not a chosen root. #234 made matching correct *per base*; + **which** base is still accidental. +- **The codebase already has a no-job cadence idiom with five + adopters.** `process.after-tick` fires every frame, including idle + frames (the run loops tick on a frame *timeout*), and + `pmacs.editor.monotonic_ms` is the clock built for exactly such + loops (`lua_bindings/mod.rs:13833`). `autosave.lua`'s Q#AS2 sweep + is the model: one clock read and one compare per frame, no job, no + pool thread. +- **No `notify`/inotify dependency in the tree** and **no + ignore-list infrastructure to reuse** — both re-verified, both + carried from the D1/D2 framing. ## What §9 asks of this lane Not "quiet the modeline." The indicator is the instrument that found -this, and the churn it shows is real; quieting it is explicitly out of -bounds. The lane's job is to make the background work **small, -attributable, and honest**: fewer jobs doing the same watching, each -with a purpose naming its root. +this, and the churn it shows is real; quieting it is explicitly out +of bounds. The lane's job is to make the background work **small, +attributable, and honest**: at idle there should *be* no running +background work to report, and while a scan runs it should be one job +named for its root. -## Design space +## The cadence — after-tick deadlines, not sleeps (review P1) -**A — Coalesce per (server, base).** One scan per tick serves every -watcher sharing a base: the scan records **all** files (today it -records only matches, so a shared scan moves the matcher from scan -time to diff time), the diff runs once, and each change is routed -through every watcher's matcher and kind mask, deduped by -`(uri, type)` into the server's single -`workspace/didChangeWatchedFiles` notification. For rust-analyzer this -is 6× → 1×. Pure Lua. +The per-watcher sleep loop is replaced by the Q#AS2 idiom: one +`process.after-tick` subscription owns every scan group's schedule. +Per frame it reads `pmacs.editor.monotonic_ms` once and compares each +group's `next_scan_at`; a due group gets its scan started (a +coroutine that runs the walk and diff). Waiting therefore allocates +**no job and no pool thread** and renders **no indicator segment** — +`activity_summary` returns `None` at zero by contract. While a scan +runs, the indicator honestly shows its one job. -**B — A Rust walk primitive.** `pmacs.fs.walk_tree(base, opts)` — -the whole recursive walk as **one job** instead of one per directory: -188 jobs per tick per watcher on this repo becomes 1. The indicator -then shows one purpose (`walk_tree `) instead of a stream of -`read_dir` lines. An additive fs binding plus its async-runtime job; -**no wire change** (fs bindings are not the frontend protocol) and no -new crate. Symlink non-traversal (`scan_tree`'s loop-safety) moves -into the primitive's contract. +This also retires a defect revision 1 did not name: today's sleeps +hold pool threads, so N watchers subtract N threads from a pool of +`available_parallelism - 1`. The after-tick cadence gives them all +back. -**C — An ignore list.** Skip named directories at walk time. On this -checkout `.git` alone is >80 % of the walk. **Stated hazard:** -excluding `target/` can suppress legitimate events — rust-analyzer's -`**/*.rs` glob covers build-script `OUT_DIR` outputs under `target/`, -so an aggressive default trades churn for staleness in exactly the -server this issue is about. The default must be conservative -(Q#D3-2). +## The scan root (review P1) -**D — Idle backoff.** The interval doubles while consecutive scans -observe no change, capped; any change batch resets it to 250 ms. -Worst-case latency for an *external* change at idle equals the cap. -LSP imposes no latency bound, and edits made through pmacs itself -never depended on the watcher (the server sees `didChange`); the -watcher exists for git checkouts, generated files, and other editors. +For a string-form (bare `*.txt` / absolute) registration the base +becomes, in order: the server's **`root_uri`** (spec verbatim — nil +when the server never asked for a root), the server's **`cwd`**, and +only then the attached-file directory. These describe the workspace +the registering server actually serves — including configured roots +and custom resolvers like texlab's, which `pmacs.project.detect` can +never reproduce (Q#LX2). This replaces the `pairs()`-order accident +with a deterministic, server-owned answer. `RelativePattern`s keep +their own `baseUri`, unchanged. -**E — Kernel notification** (`notify` crate: inotify / FSEvents / -kqueue). Eliminates polling. Also: a new dependency, a new Rust -subsystem, a Lua binding, a platform matrix, and an interaction with -§9's ownership model. **Deliberately staged separately** — not because -it is wrong but because A–D are pure wins E does not obsolete (a -kernel watcher still needs the initial scan, and a poll remains the -fallback path), and a new-crate decision deserves its own framing. +## Coalescing, with registration epochs (review P1) -## Proposed shape — Stage 1 is B + A + D, with C at a conservative default +One scan group per (server, base). The group's scanner records +**all** files (the matcher moves from scan time to diff time); each +completed scan increments the group's **snapshot epoch**. -- One `walk_tree` job per (server, base) per tick; Lua keeps the - retained map, diffs, and routes per watcher (A + B). -- Backoff 250 ms ×2 per quiet scan → 4 s cap, reset on any change (D). -- Skip-list default: **VCS metadata only** (`.git`, `.hg`, `.svn`) — - `target/` and `node_modules` stay walked unless Q#D3-2 rules - otherwise, because correctness beats quiet. -- The string-form base prefers the **project root** - (`src/project.rs::detect_project` — already Lua-reachable via - `pmacs.project`) and falls back to the attached file's directory, - which fixes the `pairs()`-order nondeterminism (Q#D3-3). +Delivery semantics, stated precisely because revision 1 did not: -At rest on this repo with rust-analyzer attached: from ~1,100 jobs per -scan-bound tick to **one job every four seconds**, named for its root. +- Each watcher records the epoch current when it **joined** the + group. Its **baseline is the first snapshot completed after it + joined**; it receives diffs only between snapshots it has a + baseline for. A file created after the group's previous snapshot + but before a new watcher registered therefore produces **no event + for that watcher** — it is folded into the watcher's baseline, + exactly as the initial scan folds pre-existing files today. +- **Membership is captured at scan start**; a watcher joining + mid-walk waits for the next snapshot. +- **Cancellation is rechecked per watcher at emit time** — #234's P2 + rule, now applied per member: a watcher superseded or unregistered + during the walk emits nothing, and its replacement (a fresh join) + has no baseline yet, so it emits nothing either. Both halves of the + round-1 hazard close on the same two rules. +- Changes passing a watcher's matcher and kind mask are deduped by + `(uri, type)` into the server's single + `workspace/didChangeWatchedFiles` notification, as today. + +## The walk primitive + +`pmacs.fs.walk_tree(base)` — the whole recursive walk as **one job** +instead of one per directory: 220 `read_dir` jobs per scan on this +repo become 1. The indicator shows one purpose (`walk_tree `). +An additive fs binding plus its async-runtime job; **no wire change** +(fs bindings are not the frontend protocol) and no new crate. Symlink +non-traversal (`scan_tree`'s loop-safety) moves into the primitive's +contract, witnessed by its own Rust tests. + +## Exclusions (review P1) — none by default + +Glob semantics make any unconditional skip a contract deviation: +`**/*.rs` compiles with a separator-spanning prefix, so it *can* +match under `.git/`, and a server may register `.git/HEAD` outright +(branch-watching tools do). The only semantics-preserving default is +**no unconditional exclusion**, and with the walk primitive the +economics support it: exclusion was worth 80 % of the *job count* +when every directory was a job; inside one `walk_tree` job it is only +readdir syscalls, and the whole 220-directory walk is a few +milliseconds of one pool thread every backoff interval. + +The option space, for Q#D3-2: (a) no unconditional exclusion — the +proposed default; (b) **opt-in** exclusion through configuration, for +users with pathological trees, framed explicitly as a watcher-contract +trade; (c) matcher-aware pruning — skip a subtree only when *no* +active watcher's pattern can match under it — which is sound but +almost never fires against real registrations, because +`**/`-leading globs can match anywhere; (d) a hard built-in VCS skip, +which revision 1 called "safest" and is not: it is (b) without the +opt-in. + +## Idle backoff + +The interval doubles while consecutive scans observe no change, +capped at 4 s; any change batch resets it to 250 ms. Under the +after-tick cadence a longer interval costs *nothing* while waiting — +backoff now bounds **scan frequency**, not sleep-job length. +Worst-case latency for an external change at idle equals the cap; +LSP imposes no latency bound, and edits made through pmacs never +depended on the watcher (the server sees `didChange`). The watcher +exists for git checkouts, generated files, and other editors. + +## Deliberately staged separately — kernel notification + +`notify` (inotify / FSEvents / kqueue) eliminates polling. Also: a +new dependency, a new Rust subsystem, a Lua binding, a platform +matrix, and an interaction with §9's ownership model. Staged as its +own framing — not because it is wrong but because everything above is +a pure win it does not obsolete (a kernel watcher still needs the +initial scan and a polling fallback), and a new-crate decision +deserves its own review. + +## Proposed shape — Stage 1 + +After-tick cadence + walk primitive + coalescing-with-epochs + +backoff; no exclusions by default; server-owned scan root. + +At rest on this repo with rust-analyzer attached: **from 1,326 jobs +per scan-bound tick (six of them pool-thread-holding sleeps) to zero +jobs at idle**, with one `walk_tree` job for the few milliseconds a +scan actually runs, at most every 250 ms under activity and every 4 s +at rest. ## Open rulings — each blocks implementation -- **Q#D3-1 — the acceptance bar.** With Stage 1 the modeline shows one - brief `walk_tree` blip per backoff interval at idle (up to every - 4 s), **not silence**. Silence requires Stage 2 (E) or touching the - indicator, which is out of bounds. Is "one attributable blip at - idle, correct events, bounded latency" the bar this lane must meet? -- **Q#D3-2 — the skip-list default and its surface.** VCS-only - (safest), or the broader VS Code-style exclude set - (`node_modules` etc.), with the `target/` staleness hazard above? - And where it lives: a module constant, or a config-registry key — - noting `ConfigValue` is four scalars, so a **list-valued setting is - not expressible today**; a key now means a delimited string, or the - skip list waits for table-valued settings (the §6 prerequisite). -- **Q#D3-3 — the string-form base.** Project-root preference (fixes - the nondeterminism; widens the watched tree when attachments span - directories) or attached-file directory (narrower, hash-order - dependent)? The proposed shape says project root; it is still a - behavioural change to a path real servers exercise. -- **Q#D3-4 — interval and cap: constants or config keys.** The D1/D2 - framing refused a knob for a defect. With D3 the poll becomes a - designed mechanism, so keys are defensible — but two more registry - keys is coherence surface. Proposed: constants until someone asks. +- **Q#D3-1 — the acceptance bar.** With Stage 1 the indicator is + **absent at idle** (no running job exists — `activity_summary`'s + `None`-at-zero contract) and shows `⋯1 walk_tree ` for the + duration of each scan. Is that the bar — an honest blip per scan, + absence otherwise — with true event-driven silence deferred to the + kernel-notification framing? +- **Q#D3-2 — exclusions.** Proposed: none by default, with opt-in + exclusion as a documented contract trade (option b) if a user asks. + Confirm, or rule for one of (b)/(c)/(d) above. +- **Q#D3-3 — the scan root.** Proposed: server `root_uri` → server + `cwd` → attached-file directory. This widens the watched tree for + servers with a real root (today it is one attached file's + directory, chosen by hash order) — a behavioural change to a path + real servers exercise. Confirm the order, or rule otherwise. +- **Q#D3-4 — interval, cap, and backoff curve: constants or config + keys.** The D1/D2 framing refused a knob for a defect; with D3 the + cadence becomes a designed mechanism, so keys are defensible — but + more registry surface is coherence cost. Proposed: constants until + someone asks. ## Verification sketch -- **Job-count witness:** a coalesced server's per-tick allocation is - O(1), not O(directories) — observed through the activity summary or - a counter seam, asserted on a tree with enough directories to - discriminate. -- **Backoff witness:** quiet scans lengthen the interval and one - change resets it — observable through the sleep purpose or a seam. -- **Skip witness:** a change under `.git/` never emits; the same - change outside it still does. +- **Idle witness:** with a server attached, watchers registered, and + no file activity, `activity_summary` settles to `None` (the absent + segment) between scans — the strongest form of the job-count claim, + and unwritable under the sleep design. +- **Scan-cost witness:** one scan allocates O(1) jobs, not + O(directories), on a tree with enough directories to discriminate. +- **Epoch witness (registration between snapshots):** create a file + after the group's snapshot, then register a second watcher, then + let a scan complete — the old watcher receives CREATED, the new one + receives **nothing** for that file, and does receive events for + files created after its baseline. +- **Epoch witness (replacement during a scan):** through the group's + scan seam (the `_after_scan_for_tests` device, lifted to the + group), re-register mid-scan — the superseded watcher emits + nothing (P2's rule, per member) and the replacement emits nothing + until its own baseline exists. +- **Backoff witness:** quiet scans lengthen the gap between scans + and one change resets it — observed through scan timestamps at the + seam, not through sleep purposes (there are none). +- **Root witness:** a server with a configured root watches that + root, not the attached file's directory; texlab's resolver shape is + the fixture model. - **Contract preservation:** all six existing `m4_24` watcher tests - stay **byte-unchanged** and green — they are D1/D2's contract, and - this lane must not weaken it. -- `walk_tree` gets Rust unit tests of its own: skip list honored, - symlinks recorded-not-traversed, signature parity with the Lua walk - it replaces. + stay **byte-unchanged** and green. +- `walk_tree` Rust unit tests: symlinks recorded-not-traversed, + signature parity with the Lua walk it replaces. - Each new behaviour is mutation-tested against the defect it guards. ## Coherence impact (§20) @@ -160,13 +300,14 @@ scan-bound tick to **one job every four seconds**, named for its root. - **Interaction islands:** none. - **Config registry:** none by default; Q#D3-2/Q#D3-4 could add keys and are flagged as such. -- **Background-work attribution (§9):** unattributed churn drops from - O(directories × watchers) jobs per tick to one attributable job per - server at rest. The ownership *model* remains §9 Stage 2's work, - not this lane's. +- **Background-work attribution (§9):** at idle there is genuinely no + running background work, and the indicator's absence is then a true + statement rather than a filtered one; each scan is one job named + for its root. The ownership *model* remains §9 Stage 2's work. As a + side effect the watcher stops holding pool threads while waiting. ## Gates -`./scripts/gate --acceptance m4_acceptance`. No `--protocol` — no wire -change, no `PROTOCOL_VERSION` bump; `walk_tree` is an fs binding, not -a protocol message. +`./scripts/gate --acceptance m4_acceptance`. No `--protocol` — no +wire change, no `PROTOCOL_VERSION` bump; `walk_tree` is an fs +binding, not a protocol message.