Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!--
Path on disk (per AGENTS.md "Filename / folder conventions"):
library/issues/backlog/issue-022-health-starved-by-synchronous-indexing/ird-issue-022-health-starved-by-synchronous-indexing.md
Lifecycle moves:
backlog/ -> in-work/ -> completed/ (entire issue-022-... folder moves)
IRD number = GitHub issue number (#22). Single-scope: this IRD covers only issue #22.
-->

# IRD-022: /health never responds while indexing runs: brood prepare and registration resync execute synchronously on the event loop

> **Status:** Backlog
> **GitHub issue:** [legioncodeinc/nectar#22](https://github.com/legioncodeinc/nectar/issues/22) (OPEN, filed 2026-07-04)
> **Severity:** P1 (liveness contract broken: the daemon is unreachable for minutes while indexing a large repo, so doctor and the Hive dashboard treat it as down)
> **Scope:** Event-loop starvation of `/health` DURING indexing. The post-index CPU busy-loop is a separate failure mode owned by [IRD-023](../issue-023-post-index-cpu-busy-loop/ird-issue-023-post-index-cpu-busy-loop.md); the two share one investigation surface.

---

## Problem

`GET /health` on port 3854 times out indefinitely while a brood or registration cold resync runs on a large repository. The daemon is one Node process; `/health` is served by `node:http` on the same event loop that runs the indexing pipeline, and the pipeline contains multi-minute fully synchronous stretches during which the HTTP handler can never be scheduled. The socket accepts (kernel backlog) but the request is never serviced: exactly the reported Listen-but-timeout behavior. Fleet-status therefore omits nectar and the Hive dashboard stalls on "starting" forever.

## Field report

Environment: nectar 0.1.3 on Windows, honeycomb 0.2.3 fleet, ~85k-file repository.

- Memory ramps to ~868 MB while the tree walk runs, plateaus, then declines to ~670 MB (walk finished).
- Port 3854 shows a clean Listen; earlier `/health` connections went CloseWait and then cleared.
- A fresh `GET /health` still times out indefinitely, even after indexing completes (the post-completion leg is IRD-023).
- Consequence: fleet-status omits nectar; the Hive dashboard never clears "starting".

## Root cause (code-grounded, current main as of 2026-07-04)

`/health` is served by `node:http` on the daemon's single event loop (`src/server.ts:76,108`). Three pipeline stages monopolize that loop:

1. **Discovery blocks.** `spawnGitLsFiles` uses `spawnSync` (`src/brooding/discovery.ts:117-124`), and the walk fallback is a synchronous `readdirSync` generator (`src/registration/disk-fs.ts:100`).
2. **Content prep blocks hardest.** `prepareFiles` is a tight synchronous read+hash loop over every discovered file (`src/brooding/precheck.ts:126-133`); each `readContent` is a bare `readFileSync` (`src/registration/disk-fs.ts:70`) followed by hashing. On ~85k files this is minutes of uninterrupted synchronous work.
3. **The registration cold-catch-up resync is one macrotask.** `runCycle` (`src/registration/service.ts:290-320`) enumerates `fs.listPaths()` (sync walk) and runs `processOne` (statSync + readFileSync + hash + TLSH) for every path inside a while loop with no awaits and no yields. The function is `async` in name only on this path.

While any of these run, no I/O callback (including the HTTP request handler) can fire.

## Shipped mitigation (partial)

PRD-019 dormant-by-default (PR #21) means a fresh boot no longer indexes anything, so `/health` answers immediately at boot and the Hive gate clears. This fixes the boot-time case only: the moment a project is activated and a brood or cold resync starts on a large repo, the starvation recurs.

## Proposed remediation direction (proposed, not decided)

- Move discovery + prepare + resync work off the event loop (worker thread), or make the loops yield (async iteration with per-N-files awaits and async fs), or both.
- Bound the per-macrotask work so no single synchronous stretch exceeds a small budget.
- Add a regression test asserting `/health` answers within a bounded time WHILE a large synthetic brood/resync is in flight.

## Acceptance criteria

| ID | Criterion |
|---|---|
| AC-1 | Given a large synthetic repo (tens of thousands of files) and an in-flight brood, when `GET /health` is issued at any point during discovery, prepare, or description, then it responds within a bounded time (target: under 250 ms) for the entire duration of the run. |
| AC-2 | Given a registration cold-catch-up resync over a large store, when `GET /health` is issued mid-resync, then it responds within the same bound; no single macrotask on the resync path exceeds the per-macrotask work budget. |
| AC-3 | Given the pipeline is restructured (worker thread and/or yielding loops), when the full brood completes, then its output (registered files, hashes, descriptions) is byte-equivalent to the pre-change synchronous pipeline. |
| AC-4 | A regression test exists in the suite that starts a large synthetic brood/resync and asserts `/health` latency stays within the bound while the work is in flight; the test fails against the pre-fix synchronous pipeline. |

## Related

- [IRD-023: post-index CPU busy-loop](../issue-023-post-index-cpu-busy-loop/ird-issue-023-post-index-cpu-busy-loop.md) - the sibling failure mode from the same field report; it starves `/health` AFTER indexing. One investigation surface, two distinct fixes.
- [IRD-024: non-gitignore exclude for committed dirs](../issue-024-non-gitignore-exclude-for-committed-dirs/ird-issue-024-non-gitignore-exclude-for-committed-dirs.md) - independent, but reduces the size of the workload this IRD makes preemptible.
- [`prd-019-project-scoped-brooding-activation`](../../../requirements/backlog/prd-019-project-scoped-brooding-activation/prd-019-project-scoped-brooding-activation-index.md) - the shipped dormant-by-default mitigation covering the boot-time case only.
- `src/server.ts:76,108` - the `/health` handler on the shared event loop.
- `src/brooding/discovery.ts:117-124` - `spawnSync` discovery.
- `src/brooding/precheck.ts:126-133` - the synchronous `prepareFiles` loop.
- `src/registration/disk-fs.ts:70,100` - `readFileSync` content reads and the synchronous walk generator.
- `src/registration/service.ts:290-320` - the single-macrotask `runCycle` resync.
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<!--
Path on disk (per AGENTS.md "Filename / folder conventions"):
library/issues/backlog/issue-023-post-index-cpu-busy-loop/ird-issue-023-post-index-cpu-busy-loop.md
Lifecycle moves:
backlog/ -> in-work/ -> completed/ (entire issue-023-... folder moves)
IRD number = GitHub issue number (#23). Single-scope: this IRD covers only issue #23.
-->

# IRD-023: Post-index CPU busy-loop: per-batch known-path stat sweeps and per-event git check-ignore spawnSync keep the daemon hot after indexing

> **Status:** Backlog
> **GitHub issue:** [legioncodeinc/nectar#23](https://github.com/legioncodeinc/nectar/issues/23) (OPEN, filed 2026-07-04)
> **Severity:** P1 (the daemon pegs a core indefinitely after indexing a large repo and `/health` stays unresponsive, so the process looks hung rather than idle)
> **Scope:** Sustained CPU burn and `/health` starvation AFTER the index walk finishes. Starvation DURING indexing is owned by [IRD-022](../issue-022-health-starved-by-synchronous-indexing/ird-issue-022-health-starved-by-synchronous-indexing.md); the two share one investigation surface.

---

## Problem

After the index walk completes, the daemon does not settle into an idle steady state. CPU keeps climbing while memory stays flat or declines: the signature of a busy-loop, not of indexing. Every trickle of file-system events (editor autosave, build output, a log write) re-triggers work that scales with the size of the whole store rather than with the size of the event, and some of that work spawns a synchronous git process per path. `/health` stays unresponsive throughout.

## Field report

Environment: nectar 0.1.3 on Windows, honeycomb 0.2.3 fleet, ~85k-file repository.

- Memory ramps to ~868 MB during the walk, plateaus, then declines to ~670 MB (walk finished).
- Past the 900-second mark, CPU keeps climbing with flat/declining memory: a busy-loop, not indexing.
- `/health` remains unresponsive the entire time, so fleet-status omits nectar and the Hive dashboard stalls on "starting".

## Root cause (code-grounded, current main as of 2026-07-04): two named suspects

1. **Per-batch known-path sweeps scale with store size, not batch size.** Every registration cycle batch recomputes `knownPaths()` and `missingPaths()` (`src/registration/service.ts:303-312`, per the snapshot comment), and `missingPaths` performs an `existsOnDisk` stat for EVERY known path. With ~85k rows in the store, a single settled watch event (one file save) triggers ~85k synchronous `statSync` calls. Any trickle of events re-runs the sweep, keeping the CPU pegged indefinitely while the event loop starves. This matches the flat-memory, climbing-CPU signature exactly.
2. **Shared-ignore cache misses spawn git synchronously per path.** `createSharedIgnore`'s gitignore leg resolves a snapshot miss via `runGitCheckIgnore` = `spawnSync("git", ["check-ignore", ...])` per path (`src/registration/ignore.ts:187-199`, wired at `src/registration/ignore.ts:282-291`). Any watch-event storm over paths not in the ls-files snapshot (untracked build artifacts, temp files) spawns a synchronous git process per observation; on Windows each spawn is tens to hundreds of milliseconds of blocked loop.

Compounding: the recursive `fs.watch` intake on Windows can deliver high event volumes on large trees. Each settled event feeds suspect 1, and each raw observation can feed suspect 2 before debouncing, because the ignore test runs pre-debounce (`src/registration/fs-watch.ts:165-173`).

## Proposed remediation direction (proposed, not decided)

- Make the known/missing-path reconciliation incremental (indexes/dirty sets) instead of per-batch full sweeps: a single-path settle must be O(1)-ish, never O(store).
- Batch or cache negative check-ignore resolutions (and/or refresh the ls-files snapshot instead of per-path `spawnSync`); never spawn a process per watch event.
- Add a soak test: sustained single-file churn on a large synthetic store must keep CPU bounded and `/health` responsive.

## Acceptance criteria

| ID | Criterion |
|---|---|
| AC-1 | Given a store with tens of thousands of known rows, when a single watch event settles, then the reconciliation work performed is proportional to the event (O(1)-ish), not to the store; no full `knownPaths`/`missingPaths` stat sweep runs per batch. |
| AC-2 | Given a storm of watch events over paths absent from the ls-files snapshot, when the shared ignore predicate resolves them, then no synchronous process is spawned per event: negative resolutions are cached or the snapshot is refreshed in bulk. |
| AC-3 | Given sustained single-file churn (steady autosave-rate writes) on a large synthetic store, when the soak runs for a sustained window, then daemon CPU stays bounded (no monotonic climb) and `GET /health` remains responsive within a fixed latency bound throughout. |
| AC-4 | A soak/regression test encoding AC-3 exists in the suite and fails against the pre-fix per-batch-sweep behavior. |

## Related

- [IRD-022: /health starved during indexing](../issue-022-health-starved-by-synchronous-indexing/ird-issue-022-health-starved-by-synchronous-indexing.md) - the sibling failure mode from the same field report; it starves `/health` DURING indexing. One investigation surface, two distinct fixes.
- [IRD-024: non-gitignore exclude for committed dirs](../issue-024-non-gitignore-exclude-for-committed-dirs/ird-issue-024-non-gitignore-exclude-for-committed-dirs.md) - independent, but shrinking the watched/known set reduces the constant factors here.
- `src/registration/service.ts:303-312` - the per-batch `knownPaths`/`missingPaths` sweep with an `existsOnDisk` stat per known row.
- `src/registration/ignore.ts:187-199,282-291` - `runGitCheckIgnore` via `spawnSync` on snapshot miss and its wiring.
- `src/registration/fs-watch.ts:165-173` - the pre-debounce ignore test that lets raw observations reach the spawn path.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!--
Path on disk (per AGENTS.md "Filename / folder conventions"):
library/issues/backlog/issue-024-non-gitignore-exclude-for-committed-dirs/ird-issue-024-non-gitignore-exclude-for-committed-dirs.md
Lifecycle moves:
backlog/ -> in-work/ -> completed/ (entire issue-024-... folder moves)
IRD number = GitHub issue number (#24). Single-scope: this IRD covers only issue #24.
-->

# IRD-024: Document and surface a non-gitignore exclude for committed dirs (graph-ignore.json today, .nectarignore alias?)

> **Status:** Backlog
> **GitHub issue:** [legioncodeinc/nectar#24](https://github.com/legioncodeinc/nectar/issues/24) (OPEN, filed 2026-07-04)
> **Severity:** P2 (capability exists and works; the gap is documentation and discoverability, but on large repos the cost of not finding it is a full walk over vendored code every brood)
> **Scope:** Documentation, discoverability, and dry-run surfacing of the existing non-gitignore exclude. Independent of the performance investigation in [IRD-022](../issue-022-health-starved-by-synchronous-indexing/ird-issue-022-health-starved-by-synchronous-indexing.md) / [IRD-023](../issue-023-post-index-cpu-busy-loop/ird-issue-023-post-index-cpu-busy-loop.md).

---

## Problem

Operators need to exclude directories from indexing that cannot be gitignored because they are committed (vendored code, checked-in build outputs). The capability already exists (`.honeycomb/graph-ignore.json`), but no user-facing documentation names it, so operators fall back to gitignore surgery that cannot cover committed paths, and there is no way to see which excludes are active before paying for a full walk.

## Field report

Environment: nectar 0.1.3 on Windows, honeycomb 0.2.3 fleet, ~85k-file repository (the same deployment whose memory/CPU timeline is recorded in IRD-022/IRD-023: ~868 MB peak during the walk, ~670 MB post-walk, sustained CPU climb afterwards).

- The operator had to gitignore-exclude `.claude/worktrees` and `node_modules` to tame indexing.
- Committed vendored code (`vendor/`, 9,262 files) cannot be gitignored, so it is walked and described on every brood.
- Ask as filed: provide a non-gitignore exclude (`.nectarignore` or config) for committed dirs.

## What already exists (code-grounded, and not discoverable)

`.honeycomb/graph-ignore.json` at the repo root is exactly this: a per-repo JSON prefix list (`["vendor/"]` or `{ "ignore": ["vendor/"] }`) honored in UNION with gitignore semantics by the shared ignore contract (`src/registration/ignore.ts:43-127`). It is applied on the git discovery path AND the walk AND (since PRD-019d) every CLI discovery path. The reporter's exact case is a one-line file, no gitignore change needed.

## Gaps to close

1. **Documentation.** No user-facing doc names `graph-ignore.json` as the committed-dirs exclude. The README and first-run guidance should, with the `vendor/` example.
2. **Discoverability/naming.** Consider a `.nectarignore` file (gitignore syntax) as a friendlier alias feeding the same shared-ignore predicate, or at minimum surface the ignore sources and their effect in `nectar brood --dry-run` output ("N files excluded by graph-ignore.json").
3. **Pre-brood visibility.** The dry-run report should make it obvious BEFORE a brood which excludes are active and how many files each source excludes, so an operator on a large repo can iterate without paying for a full walk.

## Proposed remediation direction (proposed, not decided)

- Author the user-facing documentation (README plus first-run guidance) naming `.honeycomb/graph-ignore.json` as the committed-dirs exclude, with the `vendor/` one-liner example.
- Decide on and, if accepted, implement the `.nectarignore` gitignore-syntax alias feeding the same predicate (union semantics preserved; no second ignore engine).
- Extend `nectar brood --dry-run` to report active ignore sources and per-source exclusion counts.

## Acceptance criteria

| ID | Criterion |
|---|---|
| AC-1 | Given a fresh reader of the README (or first-run output), when they need to exclude a committed directory, then the documentation names `.honeycomb/graph-ignore.json`, shows the `vendor/` example, and states the union-with-gitignore semantics. |
| AC-2 | Given a repo with an active `graph-ignore.json` (and `.nectarignore` if the alias ships), when `nectar brood --dry-run` runs, then the output lists each active ignore source and the count of files it excludes, without performing a full brood. |
| AC-3 | Given the `.nectarignore` alias is accepted and shipped, when both it and `graph-ignore.json` are present, then both feed the single shared-ignore predicate in union with gitignore semantics, and existing `graph-ignore.json` behavior is unchanged. |
| AC-4 | Given the reporter's scenario (committed `vendor/` tree), when the documented one-line exclude is added and a brood runs, then no file under `vendor/` is walked, prepared, or described, on the git discovery path, the walk fallback, and every CLI discovery site. |

## Related

- [IRD-022: /health starved during indexing](../issue-022-health-starved-by-synchronous-indexing/ird-issue-022-health-starved-by-synchronous-indexing.md) and [IRD-023: post-index CPU busy-loop](../issue-023-post-index-cpu-busy-loop/ird-issue-023-post-index-cpu-busy-loop.md) - the performance investigation from the same field report; this IRD is independent but shrinks the workload those fixes must bound.
- [`prd-019-project-scoped-brooding-activation`](../../../requirements/backlog/prd-019-project-scoped-brooding-activation/prd-019-project-scoped-brooding-activation-index.md) - PRD-019d hardened the ignore contract so `graph-ignore.json` applies on every CLI discovery path.
- `src/registration/ignore.ts:43-127` - the shared ignore contract implementing `graph-ignore.json` with union semantics.
Loading
Loading