@yemi33/minions 0.1.2178 → 0.1.2180

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.
Files changed (39) hide show
  1. package/README.md +7 -5
  2. package/bin/minions.js +39 -17
  3. package/dashboard/js/command-parser.js +1 -1
  4. package/dashboard/js/memory-panel.js +324 -0
  5. package/dashboard/js/qa.js +2 -2
  6. package/dashboard/js/refresh.js +19 -1
  7. package/dashboard/js/render-other.js +143 -2
  8. package/dashboard/js/render-prs.js +2 -1
  9. package/dashboard/js/render-schedules.js +1 -1
  10. package/dashboard/js/render-watches.js +1 -1
  11. package/dashboard/js/render-work-items.js +18 -1
  12. package/dashboard/js/settings.js +23 -0
  13. package/dashboard/pages/engine-memory-panel.html +56 -0
  14. package/dashboard/pages/engine.html +1 -0
  15. package/dashboard/pages/tools.html +8 -0
  16. package/dashboard/slim/js/link-pr.js +5 -5
  17. package/dashboard/slim/js/modals-tiles.js +44 -3
  18. package/dashboard/slim/js/projects.js +8 -6
  19. package/dashboard/slim/styles.css +20 -0
  20. package/dashboard-build.js +17 -2
  21. package/dashboard.js +693 -19
  22. package/docs/branch-derivation.md +13 -1
  23. package/docs/diagnostics-memory.md +446 -0
  24. package/docs/harness-propagation.md +273 -0
  25. package/docs/human-vs-automated.md +1 -1
  26. package/docs/runtime-adapters.md +5 -0
  27. package/engine/cli.js +24 -5
  28. package/engine/diagnostics-memory.js +190 -0
  29. package/engine/lifecycle.js +111 -1
  30. package/engine/preflight.js +265 -0
  31. package/engine/queries.js +331 -19
  32. package/engine/runtimes/claude.js +36 -0
  33. package/engine/runtimes/codex.js +19 -0
  34. package/engine/runtimes/copilot.js +27 -36
  35. package/engine/shared.js +390 -15
  36. package/engine/spawn-agent.js +178 -12
  37. package/engine/watchdog.js +6 -0
  38. package/engine.js +277 -4
  39. package/package.json +2 -2
@@ -0,0 +1,273 @@
1
+ # Harness Propagation
2
+
3
+ > Status: contract doc for the **seamless user/repo harness invocation** plan
4
+ > (`plans/seamless-user-repo-harness-invocation.md`, PRD
5
+ > `prd/minions-opg-2026-06-10-4.json`). This is the foundation item — it nails
6
+ > down the *current* propagation surfaces and the worktree footgun so the
7
+ > rest of the plan can extend them without reinventing the contract. Read this
8
+ > before adding a new adapter method, a new asset kind, or a new opt-out flag.
9
+
10
+ ## What is a "harness"
11
+
12
+ In this doc, "harness" means any user-installed or repo-local asset that the
13
+ runtime CLI auto-discovers when it starts:
14
+
15
+ - **Skills** — `SKILL.md` files the CLI loads as reusable workflows.
16
+ - **Slash commands** — `*.md` files the CLI exposes as `/command-name`.
17
+ - **MCP servers** — JSON config entries that point at a stdio/HTTP server.
18
+
19
+ A harness is "propagated" when it reaches a dispatched agent without the user
20
+ having to register it again in Minions. The single-line goal: *if it works
21
+ when you type the same task into `claude` / `copilot` / `codex` in this repo
22
+ on this machine, it works when Minions dispatches the same task here.*
23
+
24
+ ## The four propagation mechanisms
25
+
26
+ Every asset reaches the agent through exactly one of these mechanisms today.
27
+ No engine code branches on `runtime.name` for any of them — the runtime
28
+ adapter answers the contract questions and the engine fans out.
29
+
30
+ ### 1. Runtime native discovery (no engine code involved)
31
+
32
+ The CLI reads its own config and indexes its own asset dirs on every spawn.
33
+ Minions does not copy, symlink, or aggregate user-level assets — it just
34
+ makes sure the relevant dirs are *readable* from the agent's cwd (see
35
+ mechanism #3 below). The actual loading is the CLI's job.
36
+
37
+ | Runtime | User skills | User commands | User MCP servers |
38
+ |---------|-------------|---------------|------------------|
39
+ | Claude (`engine/runtimes/claude.js`) | `~/.claude/skills`, `~/.agents/skills`, plus `~/.claude/plugins/<…>/skills` | `~/.claude/commands`, plugin `commands/` | `~/.claude.json → mcpServers` |
40
+ | Copilot (`engine/runtimes/copilot.js`) | `~/.copilot/skills`, `~/.agents/skills`, `~/.copilot/installed-plugins/<…>/skills` | `~/.copilot/commands` *(probe)* | `~/.copilot/mcp-config.json → mcpServers` |
41
+ | Codex (`engine/runtimes/codex.js`) | `~/.agents/skills`, `/etc/codex/skills` | not yet supported by CLI | not yet a stable contract |
42
+
43
+ The first column is the source of truth for "where does the CLI look on
44
+ this machine?". The dashboard's tooling page mirrors this via
45
+ `engine/queries.js → collectSkillFiles / collectCommandFiles`.
46
+
47
+ ### 2. Cwd routing (`shared.resolveSpawnPaths`)
48
+
49
+ `engine/shared.js → resolveSpawnPaths(project, type, MINIONS_DIR)` decides
50
+ what the agent's `cwd` is:
51
+
52
+ - **Read-only types** (`meeting`, `ask`, `explore`, `plan-to-prd`, `plan`)
53
+ → `cwd = project.localPath` (the operator's main checkout — sees
54
+ uncommitted files).
55
+ - **Code-mutating types** (`implement`, `fix`, `review`, `test`, `verify`,
56
+ `decompose`, `docs`) → `cwd = <worktree>` (a fresh `git worktree add`
57
+ checked out at branch tip — only sees committed files).
58
+
59
+ Cwd routing matters because every CLI's native discovery is **rooted at
60
+ cwd**. Project-scope skills like `<repo>/.claude/skills/foo/SKILL.md` are
61
+ loaded by the CLI only if `<cwd>/.claude/skills/foo/SKILL.md` exists at
62
+ spawn time. See *The worktree-uncommitted footgun* below.
63
+
64
+ Live-checkout mode (`project.worktreeMode: 'live'`) collapses both branches
65
+ to `cwd = project.localPath` for every dispatch type, so the agent sees
66
+ the operator's working tree as-is (including uncommitted assets). The
67
+ tradeoff is single-mutating-dispatch concurrency per project — see
68
+ `docs/live-checkout-mode.md`.
69
+
70
+ ### 3. `--add-dir` (`engine/spawn-agent.js → computeAddDirs`)
71
+
72
+ `computeAddDirs({ runtime, minionsDir, homeDir })` builds the list of
73
+ absolute dirs the CLI is allowed to read **outside** its cwd. The current
74
+ list:
75
+
76
+ 1. `minionsDir` — always first, so playbooks, system prompt, and skill
77
+ index are reachable from any worktree.
78
+ 2. Every existing dir returned by `runtime.getUserAssetDirs({ homeDir })`
79
+ for the resolved runtime — e.g. `~/.claude` + `~/.agents` for Claude,
80
+ `~/.copilot` + `~/.agents` for Copilot, `~/.codex` + `~/.agents` for
81
+ Codex.
82
+
83
+ Non-existent dirs are dropped (Claude CLI rejects unknown `--add-dir`
84
+ entries) and the list is deduped by resolved path.
85
+
86
+ `--add-dir` is the **only** mechanism today that crosses the worktree
87
+ boundary. It does not (yet) cover project-local-but-uncommitted assets —
88
+ that's plan item #5 (`harnessPropagateProjectLocal`).
89
+
90
+ ### 4. MCP suppression flags
91
+
92
+ Two flags strip otherwise-inherited MCP / instruction surfaces; everything
93
+ else flows through implicitly because the CLI reads its native config on
94
+ every spawn:
95
+
96
+ - `engine.copilotDisableBuiltinMcps` (default `true`) → `--disable-builtin-mcps`
97
+ on Copilot. Strips Copilot's built-in `github-mcp-server` so dispatched
98
+ agents do not try to open a parallel PR through the bundled MCP while
99
+ Minions is already managing the PR via the host integration.
100
+ - `engine.copilotSuppressAgentsMd` (default `true`) → `--no-custom-instructions`
101
+ on Copilot. Strips project `AGENTS.md` auto-load so Minions playbook
102
+ prompts are not silently overridden by repo-local instructions.
103
+
104
+ There is no equivalent flag for stripping user-level skills, user-level
105
+ slash commands, or user-level MCPs. The current posture is "always inherit"
106
+ — `engine.hermeticHarness` (P-49e1c8b7, default `false`) is the per-fleet /
107
+ per-agent opt-out. When TRUE:
108
+
109
+ - `--add-dir` collapses to exactly `[minionsDir]` — every dir from
110
+ `runtime.getUserAssetDirs({ homeDir })` is dropped.
111
+ - `harnessPropagateProjectLocal` is skipped — no `--project-harness-dir`
112
+ flags emitted.
113
+ - `claudePreApproveWorkspaceMcps` is skipped — workspace `.mcp.json`
114
+ servers are not pre-approved in `~/.claude.json`.
115
+
116
+ The flag is **independent** of `copilotDisableBuiltinMcps` and
117
+ `copilotSuppressAgentsMd` — those keep their existing semantics so an
118
+ operator can run hermetic Claude *and* keep Copilot's built-in MCPs off
119
+ or AGENTS.md auto-load suppressed without re-thinking each lever. Per-agent
120
+ override at `agent.hermeticHarness` (resolved via
121
+ `shared.resolveAgentHermeticHarness(agent, engine)`, mirrors
122
+ `shared.resolveAgentBareMode`).
123
+
124
+ ## The worktree-uncommitted footgun
125
+
126
+ The single biggest behavioral wrinkle for new contributors:
127
+
128
+ 1. The operator drops an experimental skill at
129
+ `<repo>/.claude/skills/bar/SKILL.md` on their main checkout.
130
+ 2. The skill works fine when invoked by hand:
131
+ `cd <repo>; claude "use the bar skill"` — Claude's auto-discovery
132
+ sees it because cwd is the main checkout.
133
+ 3. The operator dispatches an `implement` work item against the same repo.
134
+ 4. The engine runs `git worktree add <wt> origin/<branch>` (uncommitted
135
+ files do not appear in a fresh worktree), then spawns the agent with
136
+ `cwd = <wt>`.
137
+ 5. Claude's discovery now happens at `<wt>` — `.claude/skills/bar/` is
138
+ absent. The agent silently underperforms with no error message.
139
+
140
+ Read-only dispatch types (`meeting`, `ask`, `explore`, `plan-to-prd`,
141
+ `plan`) do **not** hit this footgun, because their cwd is
142
+ `project.localPath`. Mutating types do.
143
+
144
+ `--add-dir` is not currently extended to cover this case (plan item #5),
145
+ so the only workarounds today are:
146
+
147
+ - **Commit it.** The skill becomes visible to every dispatch and every
148
+ teammate, with the usual reviewability tradeoff.
149
+ - **Move it user-scope.** Drop it under `~/.claude/skills/bar/` instead —
150
+ the CLI's user-skill discovery still works inside a worktree because
151
+ `--add-dir` attaches `~/.claude`.
152
+ - **Flip to live-checkout mode.** Set `project.worktreeMode = 'live'` on
153
+ the project so every dispatch runs in `project.localPath`. Caveats in
154
+ `docs/live-checkout-mode.md`.
155
+
156
+ The dashboard tooling page does not yet surface this gap; that's plan
157
+ item #8 ("Harness diagnostics" panel). Until then, run
158
+ `minions doctor --harness` to get the per-runtime view of which dirs the
159
+ engine actually surfaces, and check that flagged "missing" entries
160
+ actually match the assets you expected to inherit.
161
+
162
+ ## `minions doctor --harness`
163
+
164
+ One-shot CLI diagnostic that prints, per registered runtime, every dir
165
+ or file the engine would surface to a spawned agent under its scope label.
166
+ Implemented in `engine/preflight.js → runHarnessDoctor(minionsHome)` and
167
+ wired into `bin/minions.js` and `engine/cli.js` as the `--harness` mode
168
+ of the existing `minions doctor` command.
169
+
170
+ ### What it prints
171
+
172
+ ```
173
+ Minions Harness Propagation
174
+ Runtime: claude
175
+ User asset dirs (--add-dir to agents):
176
+ ✓ /home/you/.claude [user]
177
+ ✓ /home/you/.agents [user]
178
+ Skill roots (CLI native discovery):
179
+ ✓ /home/you/.claude/skills [user]
180
+ ✓ /home/you/.agents/skills [user]
181
+ ⚠ /repo/.claude/skills [project:myrepo] (missing on disk)
182
+ ✓ /repo/.agents/skills [project:myrepo]
183
+ Skill write targets (auto-extract destinations):
184
+ ✓ /home/you/.claude/skills [personal]
185
+ ✓ /repo/.claude/skills [project:myrepo]
186
+ Runtime: copilot
187
+ User asset dirs (--add-dir to agents):
188
+ ✓ /home/you/.copilot [user]
189
+ ✓ /home/you/.agents [user]
190
+
191
+ Worktree --add-dir snapshot (engine fleet default: copilot):
192
+ ✓ /home/you/.minions [minions]
193
+ ✓ /home/you/.copilot [user]
194
+ ✓ /home/you/.agents [user]
195
+ All harness paths surveyed. Missing on-disk paths are warnings, not failures —
196
+ they're listed so you can decide whether to create the dir, populate it, or
197
+ ignore it for this host.
198
+ ```
199
+
200
+ ### Exit behavior
201
+
202
+ The command exits **0 on a healthy host** even if some scoped paths do
203
+ not exist on disk. The whole point is to surface "what would the engine
204
+ attach today?" — a user that does not run Codex may legitimately have
205
+ no `~/.codex` dir, and that is not a failure. Exits non-zero only on a
206
+ true runtime/config error (e.g. registry refuses to resolve a known
207
+ runtime, or `config.json` is unparseable).
208
+
209
+ ### When to run it
210
+
211
+ - After installing a new skill / command / MCP and "it didn't take" on
212
+ the next dispatched agent — confirm Minions actually surfaces that dir
213
+ for the runtime you're dispatching with.
214
+ - After changing `engine.defaultCli` — different runtimes have different
215
+ asset dirs; the `Worktree --add-dir snapshot` block reflects the fleet
216
+ default.
217
+ - After re-cloning a repo with `<repo>/.claude/skills/` — the diagnostic
218
+ shows whether the project-scope path is on disk.
219
+ - Before reporting "agent ignored my skill" — half the time the answer is
220
+ visible in the diagnostic output.
221
+
222
+ It does **not** check whether the assets *load successfully* inside the
223
+ CLI (no parse of `SKILL.md`, no MCP handshake). For loadability,
224
+ inspect the dashboard tooling page or run an `ask` dispatch and watch
225
+ the live output.
226
+
227
+ ## Adapter contract summary
228
+
229
+ The runtime adapters answer these questions. The engine never special-cases
230
+ a runtime by name — it only calls these methods on the resolved adapter.
231
+
232
+ | Method | Signature | Used by |
233
+ |--------|-----------|---------|
234
+ | `getUserAssetDirs({ homeDir })` | → `string[]` | `engine/spawn-agent.js → computeAddDirs`; `runHarnessDoctor` |
235
+ | `getSkillRoots({ homeDir, project? })` | → `[{ scope, dir, projectName? }]` | `engine/queries.js → collectSkillFiles`; `runHarnessDoctor` |
236
+ | `getSkillWriteTargets({ homeDir, project? })` | → `{ personal, project? }` | `engine/lifecycle.js → extractSkillsFromOutput`; `runHarnessDoctor` |
237
+ | `getCommandRoots({ homeDir, project? })` | → `[{ scope, dir, projectName? }]` | `engine/queries.js → collectCommandFiles` (via `getProjectHarnesses` for project scope); `runHarnessDoctor` |
238
+ | `getMcpConfigPaths({ homeDir, project? })` | → `[{ scope, file, projectName? }]` | `runHarnessDoctor`; planned: `engine/queries.js → getStatusSlowStateMtimePaths` |
239
+
240
+ `runHarnessDoctor` renders all five surfaces as "Slash commands" and "MCP
241
+ config files" sections alongside the existing User asset dirs / Skill
242
+ roots / Skill write targets blocks. Adapters that don't yet have a stable
243
+ CLI contract for a surface (today: Codex for both commands and MCP,
244
+ Copilot for project-scope commands) return `[]` so consumers can iterate
245
+ runtimes generically without per-runtime branching.
246
+
247
+ `engine/queries.js` exposes two shared helpers — `getUserHarnesses(homeDir)`
248
+ and `getProjectHarnesses(project)` — that iterate every registered runtime
249
+ via `engine/runtimes.listRuntimes()` + `resolveRuntime()` and union the
250
+ `getSkillRoots` / `getCommandRoots` / `getMcpConfigPaths` contributions,
251
+ deduped by absolute path. The same physical dir contributed by multiple
252
+ adapters (e.g. `~/.agents/skills` exposed by Claude, Copilot, and Codex)
253
+ surfaces once with a `runtimes: [name, ...]` provenance array. Use them
254
+ from dashboard / diagnostics consumers instead of re-walking adapters by
255
+ hand; `collectSkillFiles` / `collectCommandFiles` already do.
256
+
257
+ > **Follow-up.** `getStatusSlowStateMtimePaths` still hardcodes the
258
+ > per-runtime MCP file paths; refactoring it to consume
259
+ > `getUserHarnesses(...).mcps` + `getProjectHarnesses(...).mcps` is
260
+ > tracked separately so the slow-state mtime tracker automatically picks
261
+ > up new adapters.
262
+
263
+ ## Related docs
264
+
265
+ - `docs/runtime-adapters.md` — full adapter interface table.
266
+ - `docs/live-checkout-mode.md` — `project.worktreeMode: 'live'`
267
+ contract (alternative to the worktree-add-dir story for repos where
268
+ worktrees are unworkable).
269
+ - `docs/skills.md` — skill block format and auto-extraction targets.
270
+ - `plans/seamless-user-repo-harness-invocation.md` — full multi-item plan
271
+ this doc is the foundation of.
272
+ - `CLAUDE.md` → "Agent Spawn" + "Adapter Contract" — load-bearing
273
+ invariants the propagation contract piggy-backs on.
@@ -15,7 +15,7 @@
15
15
  | Metrics | Engine (auto-collect) | Engine | You (view) | — |
16
16
  | Error recovery | Engine (detect) | — | You (retry/delete) | You |
17
17
  | Project linking | You (`minions add/scan`) | — | — | — |
18
- | MCP servers | You (`~/.claude.json`) | Inherited by agents | — | — |
18
+ | MCP servers / Skills / Commands | You (native CLI config) | Inherited by agents | — | — (opt-out via `hermeticHarness`) |
19
19
 
20
20
  ## The Two Human Gates
21
21
 
@@ -174,6 +174,11 @@ user sees outside Minions. The cross-runtime portable location
174
174
  `~/.agents/skills` is included by every adapter that opts into it (Copilot today;
175
175
  add it to new adapters when the runtime can read directly from there).
176
176
 
177
+ For a full per-runtime survey of which dirs the engine actually surfaces today
178
+ — grouped by scope label and with on-disk-missing flags — run
179
+ `minions doctor --harness`; the diagnostic and the propagation contract it
180
+ codifies are documented in [`docs/harness-propagation.md`](./harness-propagation.md).
181
+
177
182
  ## The "No Runtime-Name Branching" Rule
178
183
 
179
184
  The whole point of this layer: **engine code MUST gate behavior on
package/engine/cli.js CHANGED
@@ -247,8 +247,8 @@ const CLI_COMMAND_DOCS = Object.freeze({
247
247
  kill: { args: '', summary: 'Kill all active agents, reset to pending' },
248
248
  complete: { args: '<dispatch-id>', summary: 'Mark a dispatch as done' },
249
249
  cleanup: { args: '', summary: 'Clean temp files, worktrees, zombies' },
250
- 'mcp-sync': { args: '', summary: 'Sync MCP servers from ~/.claude.json' },
251
- doctor: { args: '', summary: 'Check prerequisites and runtime health' },
250
+ 'mcp-sync': { args: '', summary: 'Print harness propagation diagnostic (same source as `minions doctor --harness`; read-only, no writes)' },
251
+ doctor: { args: '[--harness]', summary: 'Check prerequisites and runtime health (--harness: print harness propagation diagnostic)' },
252
252
  config: { args: 'set-cli <R> [--model M]', summary: 'Persist defaultCli/defaultModel without starting' },
253
253
  pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
254
254
  bridge: { args: 'status|health|enable|disable', summary: 'Constellation bridge: toggle and inspect the read-only cross-repo feed' },
@@ -1356,6 +1356,7 @@ const commands = {
1356
1356
  const flagFields = [
1357
1357
  'claudeBareMode', 'claudeFallbackModel',
1358
1358
  'copilotDisableBuiltinMcps', 'copilotSuppressAgentsMd', 'copilotStreamMode', 'copilotReasoningSummaries',
1359
+ 'harnessPropagateProjectLocal', 'claudePreApproveWorkspaceMcps', 'hermeticHarness',
1359
1360
  'maxBudgetUsd', 'disableModelDiscovery',
1360
1361
  'ccUseWorkerPool',
1361
1362
  ];
@@ -1854,11 +1855,29 @@ const commands = {
1854
1855
  },
1855
1856
 
1856
1857
  'mcp-sync'() {
1857
- console.log('MCP servers are read directly from ~/.claude.json no sync needed.');
1858
+ // P-90a37c52 repurposed as a harness diagnostic printer. Same source
1859
+ // as `minions doctor --harness` (engine/preflight.js → runHarnessDoctor)
1860
+ // but stays exit 0 regardless of the diagnostic's hardError signal, so
1861
+ // callers that scripted around the legacy `mcp-sync` (which was a pure
1862
+ // print + exit 0) don't have their automation flipped. For an exit
1863
+ // code that reflects harness health, use `minions doctor --harness`.
1864
+ //
1865
+ // Stays a no-op: no writes to ~/.claude.json, no state mutation. The
1866
+ // legacy "Sync MCP servers from ~/.claude.json — no sync needed."
1867
+ // message is replaced by the structured per-runtime propagation survey.
1868
+ const { runHarnessDoctor } = require('./preflight');
1869
+ runHarnessDoctor(MINIONS_DIR);
1858
1870
  },
1859
1871
 
1860
- doctor() {
1861
- const { doctor } = require('./preflight');
1872
+ doctor(...doctorArgs) {
1873
+ const { doctor, runHarnessDoctor } = require('./preflight');
1874
+ if (doctorArgs.includes('--harness')) {
1875
+ // P-a3f9b2c1 — harness propagation diagnostic. See
1876
+ // docs/harness-propagation.md and the parallel branch in bin/minions.js.
1877
+ const ok = runHarnessDoctor(MINIONS_DIR);
1878
+ if (!ok) process.exit(1);
1879
+ return;
1880
+ }
1862
1881
  return doctor(MINIONS_DIR).then(ok => {
1863
1882
  if (!ok) process.exit(1);
1864
1883
  });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * engine/diagnostics-memory.js — In-process memory + event-loop + GC sampler.
3
+ *
4
+ * P-a1b2c3d4 (memory + perf audit plan). Importable from both engine.js and
5
+ * dashboard.js — each Node process gets its own module-scope state (event-loop
6
+ * histogram, GC counters, ring buffer). No I/O; persistence is the caller's
7
+ * responsibility (see P-b2c3d4e5).
8
+ *
9
+ * Exports:
10
+ * - sampleSelf({ label }) → snapshot object with RSS / heap / event-loop /
11
+ * GC counters / pid / uptime / capturedAt.
12
+ * - getHistory({ limit }) → up-to-`limit` samples from the ring buffer
13
+ * (oldest first), defaults to all 1440.
14
+ * - recordSample(sample) → append to the ring buffer; rotates at the cap.
15
+ * - startPeriodicSampling({ intervalMs, onSample }) → setInterval-driven
16
+ * sampler; returns a stop() function. Dashboard.js uses this on boot;
17
+ * engine.js drives sampling from its own tick instead.
18
+ *
19
+ * Zero deps — all Node built-ins. The event-loop histogram is a
20
+ * `monitorEventLoopDelay({ resolution: 20 })` singleton lazily enabled on the
21
+ * first `sampleSelf()` call. The PerformanceObserver subscribes to
22
+ * `entryTypes: ['gc']` and accumulates pause times into module-scope counters.
23
+ */
24
+
25
+ const v8 = require('v8');
26
+ const { monitorEventLoopDelay, PerformanceObserver, constants } = require('perf_hooks');
27
+
28
+ const RING_BUFFER_CAP = 1440;
29
+ const HISTOGRAM_RESOLUTION_MS = 20;
30
+ const NS_PER_MS = 1e6;
31
+
32
+ const ringBuffer = [];
33
+
34
+ let eventLoopHistogram = null;
35
+ let gcObserver = null;
36
+ let gcPausesTotalMs = 0;
37
+ let gcCount = 0;
38
+ let lastGcPauseMs = 0;
39
+ let lastGcKind = null;
40
+
41
+ const GC_KIND_NAMES = {
42
+ [constants.NODE_PERFORMANCE_GC_MAJOR]: 'major',
43
+ [constants.NODE_PERFORMANCE_GC_MINOR]: 'minor',
44
+ [constants.NODE_PERFORMANCE_GC_INCREMENTAL]: 'incremental',
45
+ [constants.NODE_PERFORMANCE_GC_WEAKCB]: 'weakcb',
46
+ };
47
+
48
+ function _ensureHistogramStarted() {
49
+ if (eventLoopHistogram) return eventLoopHistogram;
50
+ eventLoopHistogram = monitorEventLoopDelay({ resolution: HISTOGRAM_RESOLUTION_MS });
51
+ eventLoopHistogram.enable();
52
+ return eventLoopHistogram;
53
+ }
54
+
55
+ function _ensureGcObserverStarted() {
56
+ if (gcObserver) return gcObserver;
57
+ gcObserver = new PerformanceObserver((list) => {
58
+ for (const entry of list.getEntries()) {
59
+ // entry.duration is in ms (fractional). entry.kind / entry.detail.kind
60
+ // is the numeric NODE_PERFORMANCE_GC_* constant.
61
+ const pauseMs = Number(entry.duration) || 0;
62
+ gcPausesTotalMs += pauseMs;
63
+ gcCount += 1;
64
+ lastGcPauseMs = pauseMs;
65
+ const kindNum = (entry.detail && entry.detail.kind) || entry.kind || null;
66
+ lastGcKind = (kindNum != null && GC_KIND_NAMES[kindNum]) || (kindNum != null ? String(kindNum) : null);
67
+ }
68
+ });
69
+ gcObserver.observe({ entryTypes: ['gc'], buffered: false });
70
+ // Don't keep the event loop alive just to observe GC.
71
+ if (typeof gcObserver.unref === 'function') gcObserver.unref();
72
+ return gcObserver;
73
+ }
74
+
75
+ function _nsToMs(ns) {
76
+ if (!Number.isFinite(ns) || ns <= 0) return 0;
77
+ return ns / NS_PER_MS;
78
+ }
79
+
80
+ function _readEventLoopLag(hist) {
81
+ // hist.percentile(p) throws when the histogram is empty (no recorded
82
+ // samples yet). Guard so the first sampleSelf() call after process start
83
+ // returns zeros instead of throwing.
84
+ let p50 = 0, p99 = 0, max = 0;
85
+ try {
86
+ if (hist && typeof hist.percentile === 'function') {
87
+ const c = typeof hist.count === 'number' ? hist.count : (hist.totalCount || 0);
88
+ if (c > 0) {
89
+ p50 = _nsToMs(hist.percentile(50));
90
+ p99 = _nsToMs(hist.percentile(99));
91
+ max = _nsToMs(hist.max);
92
+ }
93
+ }
94
+ } catch {
95
+ // Empty / not-yet-populated histogram. Leave zeros.
96
+ }
97
+ return { p50, p99, max };
98
+ }
99
+
100
+ function sampleSelf({ label = null } = {}) {
101
+ const hist = _ensureHistogramStarted();
102
+ _ensureGcObserverStarted();
103
+
104
+ const mem = process.memoryUsage();
105
+ const heap = v8.getHeapStatistics();
106
+ const lag = _readEventLoopLag(hist);
107
+
108
+ return {
109
+ rss: mem.rss,
110
+ heapUsed: mem.heapUsed,
111
+ heapTotal: mem.heapTotal,
112
+ external: mem.external,
113
+ arrayBuffers: mem.arrayBuffers || 0,
114
+ heapSizeLimit: heap.heap_size_limit,
115
+ eventLoopLagP50: lag.p50,
116
+ eventLoopLagP99: lag.p99,
117
+ eventLoopLagMax: lag.max,
118
+ lastGcPauseMs,
119
+ lastGcKind,
120
+ gcPausesTotalMs,
121
+ gcCount,
122
+ uptime: process.uptime(),
123
+ pid: process.pid,
124
+ label,
125
+ capturedAt: Date.now(),
126
+ };
127
+ }
128
+
129
+ function recordSample(sample) {
130
+ if (!sample || typeof sample !== 'object') return;
131
+ ringBuffer.push(sample);
132
+ while (ringBuffer.length > RING_BUFFER_CAP) ringBuffer.shift();
133
+ }
134
+
135
+ function getHistory({ limit } = {}) {
136
+ const n = ringBuffer.length;
137
+ if (!Number.isInteger(limit) || limit <= 0 || limit >= n) {
138
+ return ringBuffer.slice();
139
+ }
140
+ return ringBuffer.slice(n - limit);
141
+ }
142
+
143
+ function startPeriodicSampling({ intervalMs = 60000, onSample = null } = {}) {
144
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
145
+ throw new Error('startPeriodicSampling: intervalMs must be a positive number');
146
+ }
147
+ const handle = setInterval(() => {
148
+ let sample;
149
+ try {
150
+ sample = sampleSelf({ label: 'periodic' });
151
+ } catch {
152
+ return;
153
+ }
154
+ recordSample(sample);
155
+ if (typeof onSample === 'function') {
156
+ try { onSample(sample); } catch { /* swallow — sampler must not crash on callback errors */ }
157
+ }
158
+ }, intervalMs);
159
+ if (typeof handle.unref === 'function') handle.unref();
160
+ return function stop() {
161
+ clearInterval(handle);
162
+ };
163
+ }
164
+
165
+ function _resetForTest() {
166
+ ringBuffer.length = 0;
167
+ if (eventLoopHistogram && typeof eventLoopHistogram.disable === 'function') {
168
+ try { eventLoopHistogram.disable(); } catch {}
169
+ }
170
+ eventLoopHistogram = null;
171
+ if (gcObserver && typeof gcObserver.disconnect === 'function') {
172
+ try { gcObserver.disconnect(); } catch {}
173
+ }
174
+ gcObserver = null;
175
+ gcPausesTotalMs = 0;
176
+ gcCount = 0;
177
+ lastGcPauseMs = 0;
178
+ lastGcKind = null;
179
+ }
180
+
181
+ module.exports = {
182
+ sampleSelf,
183
+ getHistory,
184
+ recordSample,
185
+ startPeriodicSampling,
186
+ // Constants for callers that need to know the cap up front.
187
+ RING_BUFFER_CAP,
188
+ // exported for testing
189
+ _resetForTest,
190
+ };
@@ -915,9 +915,30 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
915
915
  (projects.length === 1 ? projects[0] : null);
916
916
  const useCentral = !defaultProject;
917
917
 
918
- // Match each PR to its correct project by finding which repo URL appears near the PR number in output
918
+ // Match each PR to its correct project. W-mqba5ulq000nd255 primary
919
+ // strategy is canonical scope: parse the evidence URL into a host scope
920
+ // (e.g. `github:opg-microsoft/minions`) and find the configured project
921
+ // whose `getProjectPrScope` matches. This routes the record into the
922
+ // correctly-scoped project file even when the dispatching agent ran
923
+ // against a different project (the cross-project PR case that previously
924
+ // produced stale `_invalidProjectScope` stubs in the wrong file). Falls
925
+ // back to the legacy substring match (handles legacy non-canonical
926
+ // configs), then to a repoName-by-_git fallback, then to the dispatching
927
+ // project (which will get the `_invalidProjectScope` stamp via
928
+ // normalizePrRecord — preserving today's tracking behavior for orphan /
929
+ // unknown-owner URLs).
919
930
  function resolveProjectForPr(prId) {
920
931
  const evidenceUrl = prEvidence.get(prId) || '';
932
+ // Scope match wins. Use the most-specific URL available: prefer the
933
+ // direct evidence URL captured alongside the PR id; fall back to the
934
+ // generic stdout scan if that's empty.
935
+ const urlScope = shared.getPrScopeInfo(null, evidenceUrl)?.scope || '';
936
+ if (urlScope) {
937
+ for (const p of projects) {
938
+ const projScope = shared.getProjectPrScope(p);
939
+ if (projScope && projScope === urlScope) return p;
940
+ }
941
+ }
921
942
  const evidenceText = `${outputText}\n${evidenceUrl}`;
922
943
  for (const p of projects) {
923
944
  if (!p.prUrlBase) continue;
@@ -5474,6 +5495,94 @@ function diagnoseEmptyOutput(failureClass, code, elapsedMs) {
5474
5495
  return `[empty-output: process exited in ${elapsedMs}ms \u2014 possible causes: machine sleep, network unavailability, auth failure]`;
5475
5496
  }
5476
5497
 
5498
+ // W-mqba5ulq000nd255 — Reconciliation sweep: delete PR records flagged with
5499
+ // `_invalidProjectScope: { reason: "pr_scope_mismatch" }` IFF a sibling
5500
+ // record for the same canonical pr.id exists in another project whose scope
5501
+ // matches the PR URL (i.e. the correctly-scoped project owns the canonical
5502
+ // record). Sibling-less mismatches are preserved as tracking. Runs once per
5503
+ // tick from engine.js after the ADO/GitHub reconcile polls finish.
5504
+ //
5505
+ // Returns { pruned, scanned } so the engine tick can log a summary line.
5506
+ function pruneScopeMismatchDuplicatePrs(config) {
5507
+ config = config || getConfig();
5508
+ const projects = shared.getProjects(config) || [];
5509
+ if (projects.length === 0) return { pruned: 0, scanned: 0 };
5510
+
5511
+ // Map project name -> canonical scope so we can find which project IS the
5512
+ // correctly-scoped owner for a given mismatch record.
5513
+ const scopeByProject = new Map();
5514
+ const projectByScope = new Map();
5515
+ for (const p of projects) {
5516
+ if (!p || !p.name) continue;
5517
+ const sc = shared.getProjectPrScope(p);
5518
+ if (!sc) continue;
5519
+ scopeByProject.set(p.name, sc);
5520
+ projectByScope.set(sc, p);
5521
+ }
5522
+
5523
+ // Index all PRs by canonical id across all projects (post-_scope decoration).
5524
+ const store = require('./pull-requests-store');
5525
+ const allPrs = store.readAllPullRequests() || [];
5526
+ const byId = new Map();
5527
+ for (const pr of allPrs) {
5528
+ if (!pr || !pr.id) continue;
5529
+ if (!byId.has(pr.id)) byId.set(pr.id, []);
5530
+ byId.get(pr.id).push(pr);
5531
+ }
5532
+
5533
+ // Build per-project delete sets keyed by id, so we batch one mutation per
5534
+ // affected project file.
5535
+ const deletesByProject = new Map(); // projectName -> Set(prId)
5536
+ let scanned = 0;
5537
+ let pruned = 0;
5538
+
5539
+ for (const records of byId.values()) {
5540
+ if (records.length < 2) continue;
5541
+ for (const rec of records) {
5542
+ scanned++;
5543
+ if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
5544
+ const correctScope = rec._invalidProjectScope.prScope;
5545
+ if (!correctScope) continue;
5546
+ const correctProject = projectByScope.get(correctScope);
5547
+ if (!correctProject) continue; // no configured project owns the URL — keep as tracking
5548
+ // Sibling check: is there a record under the correct scope for the same id?
5549
+ const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
5550
+ if (!sibling) continue;
5551
+ // Safe to prune.
5552
+ const owningScope = rec._scope;
5553
+ if (!owningScope || owningScope === 'central') continue;
5554
+ if (!deletesByProject.has(owningScope)) deletesByProject.set(owningScope, new Set());
5555
+ deletesByProject.get(owningScope).add(rec.id);
5556
+ }
5557
+ }
5558
+
5559
+ if (deletesByProject.size === 0) return { pruned: 0, scanned };
5560
+
5561
+ for (const [projectName, idsToDelete] of deletesByProject) {
5562
+ const project = projects.find(p => p.name === projectName);
5563
+ if (!project) continue;
5564
+ const prPath = projectPrPath(project);
5565
+ try {
5566
+ shared.mutatePullRequests(prPath, (prs) => {
5567
+ const before = prs.length;
5568
+ const next = prs.filter(p => !idsToDelete.has(p?.id));
5569
+ const deleted = before - next.length;
5570
+ if (deleted > 0) {
5571
+ pruned += deleted;
5572
+ for (const id of idsToDelete) {
5573
+ log('info', `[pull-requests] pruned scope-mismatch duplicate ${id} from project=${projectName} (sibling exists in correctly-scoped project)`);
5574
+ }
5575
+ }
5576
+ return next;
5577
+ });
5578
+ } catch (err) {
5579
+ log('warn', `pruneScopeMismatchDuplicatePrs: failed to mutate ${projectName}: ${err?.message || err}`);
5580
+ }
5581
+ }
5582
+
5583
+ return { pruned, scanned };
5584
+ }
5585
+
5477
5586
  module.exports = {
5478
5587
  checkPlanCompletion,
5479
5588
  archivePlan,
@@ -5482,6 +5591,7 @@ module.exports = {
5482
5591
  syncPrdItemStatus,
5483
5592
  reconcilePrdStatuses,
5484
5593
  syncPrsFromOutput,
5594
+ pruneScopeMismatchDuplicatePrs,
5485
5595
  updatePrAfterReview,
5486
5596
  updatePrAfterFix,
5487
5597
  updatePrAfterFixError,