@yemi33/minions 0.1.2450 → 0.1.2452

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,13 +19,44 @@ function _timeSinceMs(mtimeMs) {
19
19
  return days + 'd ago';
20
20
  }
21
21
 
22
+ // Per-file content cache keyed by name → { mtimeMs, content }. The inbox can
23
+ // hold thousands of notes; without a cache every 4 s refresh re-fetched EVERY
24
+ // file's body. Firing that many fetches at once exhausted the browser socket
25
+ // pool and flooded the console with `net::ERR_INSUFFICIENT_RESOURCES` on every
26
+ // page (the refresh loop runs regardless of the active page). Caching by
27
+ // (name, mtimeMs) means steady-state refreshes re-fetch nothing — only new or
28
+ // edited notes are pulled — and the pool below bounds the initial load so the
29
+ // socket pool is never saturated. (W-mscjlch1005qdc11)
30
+ const _inboxContentCache = new Map();
31
+ // Max concurrent /state/notes/inbox/<file> fetches. Chrome caps same-host HTTP/1
32
+ // connections at 6; 8 keeps the pipe full without tripping ERR_INSUFFICIENT_RESOURCES.
33
+ const INBOX_FETCH_CONCURRENCY = 8;
34
+
35
+ // Run async `worker(item)` over `items` with at most `limit` in flight at once.
36
+ // Preserves input order in the returned results array. Used to bound the inbox
37
+ // per-file content fetches so a large inbox can't exhaust the browser's socket pool.
38
+ async function _mapWithConcurrency(items, limit, worker) {
39
+ const results = new Array(items.length);
40
+ let next = 0;
41
+ async function run() {
42
+ while (next < items.length) {
43
+ const i = next++;
44
+ results[i] = await worker(items[i], i);
45
+ }
46
+ }
47
+ const pool = [];
48
+ for (let i = 0; i < Math.min(limit, items.length); i++) pool.push(run());
49
+ await Promise.all(pool);
50
+ return results;
51
+ }
52
+
22
53
  // Fetch the inbox via the /state/notes/inbox directory listing + per-file
23
54
  // content fetch. The listing call returns {entries:[{name, mtimeMs, size,
24
- // isDir}]} and is cheap (one statSync per entry). The per-file fetches run
25
- // in parallel with their own mtime+size ETag so unchanged files 304 with
26
- // no body. Issue #2949 the staleness fix applies here too: the inbox
27
- // directory mtime advances on every new note, and per-file ETags surface
28
- // content edits within one 4 s poll.
55
+ // isDir}]} and is cheap (one statSync per entry). Per-file bodies are fetched
56
+ // through a bounded-concurrency pool and cached by (name, mtimeMs), so unchanged
57
+ // notes are served from memory and only new/edited notes hit the network. Issue
58
+ // #2949 the staleness fix applies here too: the inbox directory mtime advances
59
+ // on every new note, and the mtime cache key surfaces content edits within one 4 s poll.
29
60
  async function fetchInboxFromDisk() {
30
61
  try {
31
62
  const listResp = await fetch('/state/notes/inbox');
@@ -35,21 +66,27 @@ async function fetchInboxFromDisk() {
35
66
  const mdFiles = entries
36
67
  .filter((e) => e && !e.isDir && typeof e.name === 'string' && e.name.endsWith('.md'))
37
68
  .sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
38
- const items = await Promise.all(mdFiles.map(async (e) => {
69
+ // Drop cache entries for notes that no longer exist so the cache can't grow
70
+ // unbounded across the lifetime of the page.
71
+ const liveNames = new Set(mdFiles.map((e) => e.name));
72
+ for (const key of _inboxContentCache.keys()) {
73
+ if (!liveNames.has(key)) _inboxContentCache.delete(key);
74
+ }
75
+ const items = await _mapWithConcurrency(mdFiles, INBOX_FETCH_CONCURRENCY, async (e) => {
76
+ const cached = _inboxContentCache.get(e.name);
77
+ if (cached && cached.mtimeMs === e.mtimeMs) {
78
+ return { name: e.name, mtime: e.mtimeMs, age: _timeSinceMs(e.mtimeMs), content: cached.content };
79
+ }
39
80
  try {
40
81
  const r = await fetch('/state/notes/inbox/' + encodeURIComponent(e.name));
41
82
  if (!r.ok) return null;
42
83
  const content = await r.text();
43
- return {
44
- name: e.name,
45
- mtime: e.mtimeMs,
46
- age: _timeSinceMs(e.mtimeMs),
47
- content,
48
- };
84
+ _inboxContentCache.set(e.name, { mtimeMs: e.mtimeMs, content });
85
+ return { name: e.name, mtime: e.mtimeMs, age: _timeSinceMs(e.mtimeMs), content };
49
86
  } catch {
50
87
  return null;
51
88
  }
52
- }));
89
+ });
53
90
  return items.filter(Boolean);
54
91
  } catch {
55
92
  return null;
package/dashboard.js CHANGED
@@ -3690,6 +3690,19 @@ function handleStateRead(req, res) {
3690
3690
  let lstat;
3691
3691
  try { lstat = fs.lstatSync(resolved); }
3692
3692
  catch {
3693
+ // A not-yet-created but allowlisted top-level state directory (e.g. the
3694
+ // meetings dir before the first meeting is recorded) is an EMPTY directory,
3695
+ // not a missing resource. Returning 404 here made the dashboard's directory-
3696
+ // listing clients (render-meetings.js) log a console error on every refresh.
3697
+ // List it as empty instead so "no items yet" is a clean 200. Missing FILES
3698
+ // and missing nested paths still 404. (W-mscjlch1005qdc11)
3699
+ if (isSingleSegment && STATE_READ_ALLOWED_DIRS.has(top)) {
3700
+ res.statusCode = 200;
3701
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
3702
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
3703
+ res.end(JSON.stringify({ path: rel.replace(/\\/g, '/'), entries: [] }));
3704
+ return;
3705
+ }
3693
3706
  res.statusCode = 404;
3694
3707
  res.setHeader('Content-Type', 'application/json');
3695
3708
  res.end(JSON.stringify({ error: 'not found' }));
package/docs/README.md CHANGED
@@ -44,6 +44,7 @@ Architecture, design proposals, and lifecycle references for people working on t
44
44
  - [cooldown-merge-semantics.md](cooldown-merge-semantics.md) — Scoping deliverable defining merge semantics for `saveCooldowns` (longer-of TTL merge, key-level upserts, gitignored on-disk format).
45
45
  - [copilot-cli-schema.md](copilot-cli-schema.md) — Behavior and schema reference for the GitHub Copilot CLI adapter (capability flags, stdin vs `-p`, model discovery, effort levels).
46
46
  - [cross-repo-plans.md](cross-repo-plans.md) — Cross-repo plans: a single plan whose work items ship into two or more configured projects — per-item `project` field, per-project work-item fan-out, and one verify work item per touched repo.
47
+ - [dashboard-reliability-and-responsiveness.md](dashboard-reliability-and-responsiveness.md) — Reliability/perf deep dive on what starves the single dashboard event loop: the `GET /api/work-items` full-enrichment stall (measured ~3.3 s p50 at 2,300 items; production 7–17 s), the ETag/`MAX(events.id)` re-run trigger, the health-probe/CC-SSE false-disconnect chain, a threading/process-boundary decision matrix, a P0/P1/P2 roadmap, and explicit worker-thread non-recommendations. Distinguishes merged safeguards from the still-in-flight fix (PR #1198).
47
48
  - [dead-code-audit-retractions.md](dead-code-audit-retractions.md) — Retracted dead-code-audit findings (false positives) that future audits MUST read before re-citing.
48
49
  - [default-branch-ci.md](default-branch-ci.md) — The post-merge default-branch gate: why a `pull_request` gate cannot catch merge-order defects (two green PRs that break only in combination), why the post-merge workflow *calls* the PR gate rather than copying it or inverting the direction (the ruleset requires the `Unit Tests` context by exact string), the cost controls, and the evaluated-but-rejected merge-queue alternative.
49
50
  - [deprecated-process.md](deprecated-process.md) — Schema for `docs/deprecated.json` and the weekly `cleanup-deprecated` audit walk that retires entries past their removal signal.
@@ -0,0 +1,281 @@
1
+ # Dashboard Reliability & Responsiveness
2
+
3
+ Deep dive into what keeps (and what starves) the Minions dashboard/API event loop
4
+ under realistic engine + UI load. Verified against `engine/core/queries.js`,
5
+ `dashboard.js`, and `engine/observability/diagnostics-memory.js` on `work/W-msci5nby0044fd1f`
6
+ (branched from `main`), with reproducible measurements from an isolated benchmark
7
+ (see [Appendix A](#appendix-a--reproduction)).
8
+
9
+ **Audience:** engine/dashboard contributors filing follow-up performance work. This is
10
+ a decision document — each finding names the module boundary that changes, the fix
11
+ class, and an acceptance metric. It is **not** an implementation.
12
+
13
+ ---
14
+
15
+ ## 1. Executive summary
16
+
17
+ The dashboard is a single Node process (`dashboard.js`, default port 7331) sharing one
18
+ event loop across: the 4 s SPA poll (`/api/status` + per-page list endpoints), the
19
+ 250 ms-cadence `/api/health` restart probe, Command Center / doc-chat SSE streams, and
20
+ all mutating API calls. Any synchronous region longer than a few hundred milliseconds on
21
+ that loop degrades *every* concurrent consumer at once.
22
+
23
+ **The dominant offender is `GET /api/work-items`.** Its list builder runs
24
+ `queries.getWorkItems()` with **full enrichment** (`enrich: true`), whose
25
+ `_artifacts`/`_notes` step does per-item bucketed scans over the knowledge-base snapshot,
26
+ the notes archive, per-agent output dirs, and the inbox — `O(items × files)` synchronous
27
+ work. On a history of ~2,300 work items with a comparably sized notes archive, a single
28
+ cold/cache-busted enrichment blocks the event loop for **~3.3 s p50 / ~3.9 s p95**
29
+ (measured; [Appendix A](#appendix-a--reproduction)); production incidents at ~2,281 items
30
+ reported **7–17 s** (larger KB, colder OS file cache, slower disk).
31
+
32
+ **User-visible failure chain:**
33
+
34
+ ```
35
+ engine tick writes state (events.id bumps every tick)
36
+ → /api/work-items ETag busts → list builder runs full enrichment every ~4s poll
37
+ → synchronous O(items×files) scan blocks the single event loop for seconds
38
+ → /api/health 250ms probe misses → supervisor sees a "dead" dashboard
39
+ → CC SSE 15s heartbeat fires late → [cc-stall] logged, browser reconnects
40
+ → browser fetch() times out → "TypeError: Failed to fetch" false-disconnect UI
41
+ ```
42
+
43
+ **Status of the fix.** The root-cause fix — serving the list from the *lean*
44
+ (`enrich: false`) path — is **in flight, not merged**: PR **#1198** ("Make work-items
45
+ list event-loop safe") rewrites the route builder to `getWorkItems(enrich:false)` +
46
+ pagination + a cheap map-based PR link-up, leaving detail-only fields on
47
+ `GET /api/work-items/<id>`. On `main` today the route still calls the enriching
48
+ `getWorkItems().map(slimWorkItemForList)` (`dashboard.js`, `/api/work-items` GET route),
49
+ so the starvation path is **still present until #1198 lands**. An earlier merged fix
50
+ (PR **#456**) reduced the same enrichment's complexity with per-agent bucketing but did
51
+ not take the route off the enrich path. See [§7](#7-already-fixed-vs-still-outstanding).
52
+
53
+ ---
54
+
55
+ ## 2. Current architecture (responsiveness-relevant slice)
56
+
57
+ | Surface | Where | Freshness / isolation today |
58
+ |---|---|---|
59
+ | `/api/status` | `handleStatus` → `refreshStatusAsync` (`dashboard.js`) | Single-flight `_statusRebuildPromise`; ETag on `_statusCacheVersion`; one cooperative `await _yieldEventLoop()` between fast/slow halves; invalidation-race guard. **Well isolated.** |
60
+ | `/api/work-items` (list) | `/api/work-items` GET builder → `queries.getWorkItems()` | `serveFreshJson`, ETag = `tag-variant-mtime-eventVersion`. `eventVersion` = `SELECT MAX(id) FROM events` (`_getCurrentEventVersion`), so on a busy engine the ETag busts **every poll** and the builder re-runs. Builder is **synchronous** with no yield. |
61
+ | `/api/work-items/<id>` (detail) | `handleWorkItemsById` | Full single-record read; backs the detail/edit modal. |
62
+ | `/api/health` | `handleHealth` | Minimal inline agent array from config + dispatch only (two cached reads, no dir enumeration) — deliberately kept off `getAgents()`/`/api/status`. **Well isolated.** |
63
+ | CC / doc-chat SSE | `ccCallStreaming`, doc-chat handlers | 15 s heartbeat (`CC_STREAM_HEARTBEAT_MS`); `[cc-stall]` logged when it fires > 3 s late (`CC_STREAM_STALL_THRESHOLD_MS`); per-tab queue shed at 4 MB / 30 s backpressure (`SSE_MAX_QUEUE_BYTES`, `SSE_STUCK_KILL_MS`). Heartbeat is a timer → **starved by any long sync region**. |
64
+ | Memory / event-loop-lag observability | `engine/observability/diagnostics-memory.js` | Engine samples every `memoryBaselineEveryTicks` (≈60 s). Dashboard samples via `startPeriodicSampling({ intervalMs: 60000 })`. Event-loop-lag histogram exists but is **sampled only once per 60 s** on the dashboard side. |
65
+
66
+ ### The read-side aggregation layer
67
+
68
+ `queries.getWorkItems(config, { enrich })` (`engine/core/queries.js`) is the hinge:
69
+
70
+ - **`enrich: false` (lean):** SQL rows (`work-items-store.readAllWorkItems()`) + dispatch
71
+ cross-reference + pending-gate annotation + the deterministic sort. **No filesystem
72
+ scans.** 1 s TTL cache (`_workItemsLeanCache`).
73
+ - **`enrich: true` (default):** everything above **plus** PR cross-reference and the
74
+ per-item `_artifacts`/`_notes`/`_model` detail-modal fields. This is where the
75
+ `O(items × files)` cost lives. Its own header comment documents the freeze and the
76
+ per-agent bucketing that PR #456 added; a `_buildNotesByWiMap` + mtime-gated archive
77
+ cache (`_buildArchiveNoteRefs`, 5 min TTL) further amortize the archive read — **but any
78
+ archive add/remove busts that cache**, and consolidation moves inbox → archive
79
+ routinely, so the "warm" state is not durable under a live engine.
80
+
81
+ Both share a 1 s TTL so intra-request duplication (`getWorkItems` is called 5–7× per
82
+ `/api/status` rebuild) collapses to one scan; the TTL does not help *across* poll cycles
83
+ once `eventVersion` busts the route ETag.
84
+
85
+ ---
86
+
87
+ ## 3. Measured bottlenecks (evidence table)
88
+
89
+ Confidence: **H** = directly measured or unambiguous in code; **M** = strong code
90
+ evidence + incident correlation; **L** = plausible, needs instrumentation to confirm.
91
+
92
+ | # | Finding | Severity | Conf. | Reproduction | Affected surfaces |
93
+ |---|---|---|---|---|---|
94
+ | E1 | `/api/work-items` list runs full `enrich:true` enrichment; a cold/cache-busted call blocks the loop ~3.3 s p50 / ~3.9 s p95 at 2,300 items (production 7–17 s) | **P0** | H | [Appendix A](#appendix-a--reproduction) | health probe, CC SSE, every concurrent fetch |
95
+ | E2 | Route ETag includes `MAX(events.id)`; a busy engine bumps it every tick, so the enrich builder re-runs on essentially every 4 s poll instead of 304-ing | **P0** | H | `serveFreshJson` ETag = `…-mtime-eventVersion`; `_getCurrentEventVersion` | list latency, loop fairness |
96
+ | E3 | The enrich builder is one uninterrupted synchronous region — no `await _yieldEventLoop()` mid-scan (unlike `/api/status`) | **P0** | H | `getWorkItems` enrich block is straight-line sync | loop fairness |
97
+ | E4 | Archive notes-by-WI cache is busted by any archive add/remove; consolidation writes archive notes under a live engine, so the multi-second cold scan recurs | **P1** | H | Bench with per-iteration archive write: p50 rebuild 3.3 s vs 63 ms when cache holds | list latency spikes |
98
+ | E5 | KB snapshot bucketing scales with KB size (production KB ≈ 4,600 indexed + up to ~10k entries); benchmark understates it (cold snapshot returns `[]`) | **P1** | M | `getKnowledgeBaseEntriesSnapshot()` async warm; `_kbByAgent` bucketing in `getWorkItems` | list latency at scale |
99
+ | E6 | Dashboard event-loop-lag is sampled once per 60 s; a 3–17 s spike is usually invisible to `/api/diagnostics/memory` | **P1** | H | `DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS = 60000` | observability blind spot |
100
+ | E7 | No per-route duration / event-loop-delay instrumentation on the dashboard; `[cc-stall]` is the only starvation signal and it is CC-specific | **P1** | H | grep: no `monitorEventLoopDelay` in `dashboard.js` request path | triage blind spot |
101
+ | E8 | `_getCurrentEventVersion()` runs a synchronous `prepare().get()` against SQLite on every `serveFreshJson` request (cheap, but on the hot loop) | **P2** | M | `_getCurrentEventVersion` | micro-overhead |
102
+ | E9 | Supervisor treats a starved-but-alive dashboard as dead → false restart, which drops in-flight SSE and compounds the perceived outage | **P1** | M | `/api/health` polled every 250 ms for 15 s post-restart (`handleHealth` header) | restart churn |
103
+
104
+ ---
105
+
106
+ ## 4. Threading / process-boundary decision matrix
107
+
108
+ The primary question is not "which threads help?" but "which synchronous work should not
109
+ exist on the hot loop at all?". Prefer, in order: **remove → cache/index → chunk/yield →
110
+ worker thread → child process.**
111
+
112
+ | Work | Keep on loop | Chunk/yield | Cache/index/query | Worker thread | Child process | Chosen | Why |
113
+ |---|:--:|:--:|:--:|:--:|:--:|---|---|
114
+ | Work-items **list** payload | | | ✅ | | | **Serve lean (E1/E2)** | Remove the fs scan from the list path entirely (PR #1198). No concurrency needed — the data is already in SQL. |
115
+ | Per-item `_artifacts`/`_notes` enrichment | | ✅ | ✅ | | | **Detail-only + index** | Only the detail modal needs it; compute per-id on `/<id>`, or precompute a WI→artifacts index in SQL. |
116
+ | `/api/status` rebuild | | ✅ | ✅ | | | **Already done** | Single-flight + mid-rebuild yield already isolate it. |
117
+ | KB / notes text search for linkage | | | ✅ | ⚠️ | | **Index in SQL/FTS5** | FTS5 already backs memory retrieval; reuse it instead of `String.includes` scans. A worker still pays transfer cost for a scan that an index removes. |
118
+ | Health probe | ✅ | | | | | **Already minimal** | Must never touch heavy reads. |
119
+ | CC / doc-chat LLM turns | | | | | ✅ | **Already isolated** | Spawned children; only their SSE writer touches the loop. |
120
+ | Skills / tools / MCP discovery | | | ✅ | | ✅ | **Already child-process** | `/api/tools` discovers in a child; keep it off `/api/status`. |
121
+
122
+ **Guiding rule:** if an index or a cache removes the cost, a worker thread is the *wrong*
123
+ tool — it adds serialization + transfer overhead to hide work that should not run. Worker
124
+ threads earn their keep only for genuinely CPU-bound, non-SQL, non-fs compute that cannot
125
+ be indexed away (none identified today).
126
+
127
+ ---
128
+
129
+ ## 5. Prioritized roadmap
130
+
131
+ ### P0 — take the list off the enrich path (removes the starvation)
132
+
133
+ - **P0-1 — Land PR #1198 (list serves `enrich:false`).**
134
+ - **Modules:** `dashboard.js` `/api/work-items` GET builder; `queries.attachWorkItemPrLinks` (new, `engine/core/queries.js`); `dashboard/js/render-work-items.js` (detail hydration).
135
+ - **Success metric:** with an engine actively writing state, `/api/health` p99 stays < 500 ms and no `[cc-stall]` fires while `/api/work-items` is polled at 4 s over a 2,300-item history; list rebuild p95 < 50 ms (from ~3.9 s).
136
+ - **Rollback:** the change is builder-local and byte-compatible on the no-paging path; revert the single route builder to restore prior behavior.
137
+ - **P0-2 — Regression guard.** Add/keep a source-and-timing test asserting the list route
138
+ never calls `getWorkItems(enrich:true)` and that a synthetic 2k-item rebuild stays under
139
+ a budget (the `test/unit/dashboard-work-items-list-event-loop-safe.test.js` added by
140
+ #1198 is the anchor).
141
+ - **Success metric:** test fails if any future edit reintroduces enrich-on-list.
142
+
143
+ ### P1 — establish performance budgets & observability *before* adding concurrency
144
+
145
+ - **P1-1 — Dashboard request-path event-loop-delay instrumentation.**
146
+ - **Modules:** `dashboard.js` request wrapper; reuse `engine/observability/diagnostics-memory.js` histogram.
147
+ - **Deliverable:** a bounded per-route duration + a continuously-enabled
148
+ `monitorEventLoopDelay` histogram surfaced on `/api/diagnostics/memory` (not just a
149
+ 60 s point sample), plus a `[slow-route]` warn log over a configurable threshold.
150
+ - **Success metric:** a synthetic 1 s block is visible in `/api/diagnostics/memory`
151
+ within one poll interval; today it is usually invisible (E6/E7).
152
+ - **P1-2 — Precompute the WI→artifacts/notes index in SQL.**
153
+ - **Modules:** a numbered migration + store helper; `queries.getWorkItems` enrich block
154
+ reads the index instead of scanning KB snapshot + archive + agent dirs.
155
+ - **Success metric:** detail enrichment for one item is O(1) index lookups, no directory
156
+ walk; the archive-cache-bust cliff (E4) disappears.
157
+ - **P1-3 — Supervisor false-positive hardening.** Confirm the restart probe distinguishes
158
+ "dashboard process alive, status cache slow" (already exposed via
159
+ `handleHealth`'s `dashboardStartedAt`) from "new process". Ensure a single slow
160
+ `/api/status` cannot trip a restart when `/api/health` is answering.
161
+ - **Success metric:** no supervisor restart is triggered while `/api/health` p99
162
+ < 500 ms, even if `/api/status` is momentarily stale.
163
+
164
+ ### P2 — micro-overheads & scale hygiene
165
+
166
+ - **P2-1 — Cache `_getCurrentEventVersion()` for a sub-second TTL** so the per-request
167
+ SQLite `MAX(id)` read (E8) is not repeated across concurrent polls in the same tick.
168
+ - **P2-2 — Pagination default-on for the SPA list poll.** The `_parsePageParams` /
169
+ `_paginateList` foundation and `engine/api-contracts/paging.js` bounds already exist;
170
+ have the classic 4 s loop request a bounded first page instead of the full array.
171
+ - **P2-3 — Database-growth soak.** Extend `test/perf/soak.test.js` with a work-items /
172
+ archive growth axis so latency regressions at 5k–10k items are caught pre-merge.
173
+
174
+ ---
175
+
176
+ ## 6. Proposed follow-up work items (independently shippable)
177
+
178
+ Each is small enough to file directly as a WI. All are documentation-derived; none bundle
179
+ a speculative production refactor.
180
+
181
+ 1. **Merge/rebase PR #1198** and confirm P0-1 acceptance metric on a live-ish dataset
182
+ (regression test + manual `/api/health` latency check). *(depends: nothing)*
183
+ 2. **Dashboard event-loop-delay + per-route timing** (P1-1). Ships a diagnostic endpoint
184
+ surface + `[slow-route]` log; observability-only, no behavior change.
185
+ *(depends: nothing; unblocks measuring everything below)*
186
+ 3. **SQL WI→artifacts/notes index** (P1-2). Migration + store helper + `getWorkItems`
187
+ enrich read-path swap; keep the fs-scan fallback for one release behind the index.
188
+ *(depends: #2 for before/after numbers)*
189
+ 4. **Event-version read cache** (P2-1). One-line-scope TTL around `_getCurrentEventVersion`.
190
+ *(depends: nothing)*
191
+ 5. **SPA list poll → paginated** (P2-2). Frontend + route wiring only.
192
+ *(depends: #1 so the page path is already lean)*
193
+ 6. **Work-items growth soak axis** (P2-3). Test-only.
194
+ *(depends: #2 for the budget threshold)*
195
+
196
+ Every item above ships with (a) a regression test or benchmark, and (b) an observability
197
+ requirement satisfied by #2 (a starvation must be *visible* before and *absent* after).
198
+
199
+ ---
200
+
201
+ ## 7. Already-fixed vs still-outstanding
202
+
203
+ **Merged / present on `main` (do not re-file):**
204
+
205
+ - `/api/status` event-loop isolation: single-flight `_statusRebuildPromise`, ETag on
206
+ `_statusCacheVersion`, mid-rebuild `await _yieldEventLoop()`, invalidation-race guard.
207
+ - `getWorkItems` 1 s TTL caches (enriched + lean, held separately) and per-agent bucketing
208
+ of the KB/archive scans (PR #456) — reduced the constant factor.
209
+ - `slimWorkItemForList`: hard-caps `description` to 2,048 chars and replaces
210
+ `acceptanceCriteria`/`references` arrays with `*Count` integers, so a single oversized
211
+ transcript can no longer balloon the list payload (bounds bytes, not compute).
212
+ - `/api/health` minimal inline agents (no directory enumeration) — keeps the restart probe
213
+ cheap.
214
+ - CC SSE backpressure shedding (`SSE_MAX_QUEUE_BYTES` / `SSE_STUCK_KILL_MS`) and
215
+ `[cc-stall]` / `[cc-sse-backpressure]` logging.
216
+ - List pagination *foundation* (`_parsePageParams` / `_paginateList`, bounds from
217
+ `engine/api-contracts/paging.js`) — present but not yet the default for the SPA poll.
218
+
219
+ **Still outstanding:**
220
+
221
+ - **P0:** the list route still enriches on `main` (fixed only once PR #1198 merges) — E1/E2/E3.
222
+ - **P1:** archive-cache-bust cliff (E4), KB-scale enrichment cost (E5), coarse dashboard
223
+ lag sampling (E6), no per-route timing (E7), supervisor false-positive hardening (E9).
224
+ - **P2:** per-request `MAX(id)` read (E8), pagination-by-default, growth soak.
225
+
226
+ ---
227
+
228
+ ## 8. Non-recommendations & traps
229
+
230
+ - **Do not move `getWorkItems` enrichment onto a worker thread.** The cost is fs + SQL
231
+ scanning, not CPU math. `node:sqlite` statement objects are **not transferable** across
232
+ worker boundaries, and structured-cloning a 2k-item enriched list back to the main
233
+ thread would re-introduce a serialization stall. Remove the scan (index it) instead.
234
+ - **Do not wrap cheap operations in workers.** Worker spawn + message serialization dwarfs
235
+ a sub-millisecond SQL read; the lean list path is already ~11–20 ms.
236
+ - **Do not "fix" starvation by raising health/probe timeouts.** That hides the loop block
237
+ from the supervisor while browsers still see `Failed to fetch`. Extending
238
+ `restartHealthTimeoutMs` treats the symptom; removing the sync region treats the cause.
239
+ - **Do not add a second always-on cache without an invalidation source.** The archive
240
+ cache already shows how a cache that a live engine busts is worse than no cache (a
241
+ surprise multi-second cliff). Prefer an index keyed by the same events the ETag reads.
242
+ - **Do not push `LIMIT/OFFSET` into the per-scope stores for the list** without accounting
243
+ for the post-enrichment cross-scope sort — the current design slices the deterministic
244
+ cached array at the endpoint precisely so `total` matches `/api/status` counts and pages
245
+ never overlap (see the pagination design note in `dashboard.js`).
246
+ - **Do not conflate `/api/status` with the list endpoints.** `/api/status` is already
247
+ isolated; the regression risk is re-coupling a heavy list build back into the status
248
+ rebuild.
249
+
250
+ ---
251
+
252
+ ## Appendix A — Reproduction
253
+
254
+ Method: an isolated `MINIONS_TEST_DIR` (via the test harness `createTestMinionsDir()`)
255
+ seeded with **2,300 work items**, **2,300 archive notes**, and 5 agents × 40 output logs,
256
+ exercising the **real** `engine/core/queries.getWorkItems`. Event-loop blocking equals the
257
+ synchronous wall-time of the call (the builder has no `await`), so a cold call of duration
258
+ _D_ blocks the loop for _D_. The archive notes-by-WI cache was busted each iteration (one
259
+ archive write) to model consolidation writing under a live engine.
260
+
261
+ Machine/runtime: Node v24.14.0, win32, warm SSD. Numbers are order-of-magnitude, not a
262
+ guaranteed SLA — production disks and a populated KB snapshot make the enrich path slower,
263
+ consistent with the 7–17 s incident reports.
264
+
265
+ ```
266
+ dataset: 2300 work items, 2300 archive notes, 5 agents
267
+ enrich:true COLD = ~3.2–3.8 s (2300 items) ← single event-loop block
268
+ enrich:false COLD = ~15–20 ms (2300 items)
269
+ enrich:true rebuild p50=3264ms p95=3942ms (archive cache busted each poll)
270
+ enrich:false rebuild p50=11ms p95=13ms
271
+ ```
272
+
273
+ Ratio: the lean path is **~250–300× cheaper** and, crucially, does **zero** filesystem
274
+ scanning, so it cannot be pushed back onto the multi-second cliff by a KB/archive write.
275
+
276
+ The benchmark script is intentionally **not committed** — it is a throwaway diagnostic. To
277
+ reproduce, adapt the harness in `test/unit/queries-work-items-cache.test.js`
278
+ (`createTestMinionsDir` + `seedWorkItems`), seed a large archive under
279
+ `notes/archive/`, and time `queries.getWorkItems(null, { enrich })` both ways. The
280
+ regression anchor that *is* committed (once PR #1198 lands) is
281
+ `test/unit/dashboard-work-items-list-event-loop-safe.test.js`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2450",
3
+ "version": "0.1.2452",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"