@yemi33/minions 0.1.2343 → 0.1.2344

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.
@@ -421,6 +421,13 @@ function _refreshSidebarCounterSummaries() {
421
421
  .then(function (r) { return r.ok ? r.json() : null; })
422
422
  .then(function (s) { if (s && typeof s === 'object') window._lastQaRunsSummary = s; })
423
423
  .catch(function () { /* sidebar counter degrades to /api/status fallback */ });
424
+ // QA nav badge count (issue #717) — non-terminal session count, distinct
425
+ // from the qa-runs-summary activity dot above (that one tracks qa RUNS,
426
+ // this tracks in-flight qa SESSIONS).
427
+ fetch('/api/qa-sessions-summary')
428
+ .then(function (r) { return r.ok ? r.json() : null; })
429
+ .then(function (s) { if (s && typeof s === 'object') window._lastQaSessionsSummary = s; })
430
+ .catch(function () { /* sidebar badge degrades to empty */ });
424
431
  fetch('/api/meetings-total')
425
432
  .then(function (r) { return r.ok ? r.json() : null; })
426
433
  .then(function (s) { if (s && typeof s.total === 'number') window._lastMeetingsTotal = s.total; })
@@ -1012,6 +1019,10 @@ function _processStatusUpdate(data, opts) {
1012
1019
  if (swi) swi.textContent = (window._lastWorkItems || []).length || '';
1013
1020
  const spr = document.getElementById('sidebar-pr');
1014
1021
  if (spr) spr.textContent = (window._lastPullRequests || []).length || '';
1022
+ // QA nav badge — count of non-terminal sessions (issue #717). Populated
1023
+ // from the /api/qa-sessions-summary fetch in _refreshSidebarCounterSummaries.
1024
+ const sqa = document.getElementById('sidebar-qa');
1025
+ if (sqa) sqa.textContent = (window._lastQaSessionsSummary && window._lastQaSessionsSummary.active) || '';
1015
1026
  // Refresh KB and plans every status cycle (~4s) so plan status flips
1016
1027
  // (approve/archive/complete) and KB additions surface within the SPA's
1017
1028
  // 4s poll target. Server-side caches keep this cheap:
@@ -104,7 +104,7 @@
104
104
  <a class="sidebar-link" data-page="work" href="/work">Work Items <span class="sidebar-count" id="sidebar-wi"></span></a>
105
105
  <a class="sidebar-link" data-page="plans" href="/plans">Plans & PRD</a>
106
106
  <a class="sidebar-link" data-page="prs" href="/prs">Pull Requests <span class="sidebar-count" id="sidebar-pr"></span></a>
107
- <a class="sidebar-link" data-page="qa" href="/qa" title="QA — live processes + validation runbooks (managed-spawn + keep-processes)">QA</a>
107
+ <a class="sidebar-link" data-page="qa" href="/qa" title="QA — live processes + validation runbooks (managed-spawn + keep-processes)">QA <span class="sidebar-count" id="sidebar-qa"></span></a>
108
108
  <a class="sidebar-link" data-page="inbox" href="/inbox">Notes & KB</a>
109
109
  <a class="sidebar-link" data-page="tools" href="/tools">Skills & MCP</a>
110
110
  <a class="sidebar-link" data-page="schedule" href="/schedule" title="Cron-based recurring tasks (single task per schedule)">Schedules</a>
package/dashboard.js CHANGED
@@ -48,6 +48,7 @@ const issues = require('./engine/issues');
48
48
  const watchesMod = require('./engine/watches');
49
49
  const meetingMod = require('./engine/meeting');
50
50
  const qaRunsMod = require('./engine/qa-runs');
51
+ const qaSessionsMod = require('./engine/qa-sessions');
51
52
  const routing = require('./engine/routing');
52
53
  const playbook = require('./engine/playbook');
53
54
  const dispatchMod = require('./engine/dispatch');
@@ -13399,6 +13400,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13399
13400
  builder: () => qaRunsMod.summarizeRunsForStatus(),
13400
13401
  });
13401
13402
  }},
13403
+ { method: 'GET', path: '/api/qa-sessions-summary', desc: 'Sidebar-counter summary {active, sig} for non-terminal QA sessions (drives the QA nav badge — issue #717)', handler: (req, res) => {
13404
+ return serveFreshJson(req, res, {
13405
+ tag: 'qa-sessions-summary',
13406
+ inputs: [qaSessionsMod.qaSessionsPath()],
13407
+ builder: () => qaSessionsMod.summarizeActiveSessionsForStatus(),
13408
+ });
13409
+ }},
13402
13410
  { method: 'GET', path: '/api/meetings-total', desc: 'Total meeting count (sidebar activity-dot signal — cheaper than fetching /api/meetings)', handler: (req, res) => {
13403
13411
  return serveFreshJson(req, res, {
13404
13412
  tag: 'meetings-total',
@@ -104,6 +104,25 @@ Runs the command with `child_process.exec` / `spawn`, asserts both exit code 0 A
104
104
 
105
105
  `tcp` and `log-match` healthcheck types are intentionally not implemented — see the plan's Rejected items for the reasoning.
106
106
 
107
+ ### Non-PATH native launcher binaries (e.g. Android SDK's `emulator.exe` / `adb.exe`)
108
+
109
+ `adb` and `emulator` are already on `executableAllowlist` (basename matching strips `.exe`/`.cmd`/`.bat`/`.ps1`/`.sh`, so `spec.cmd` can be a bare name resolved via PATH *or* a full absolute path — `path.basename()` matching doesn't care how deep the path is). This covers the common case directly; you do **not** need a `powershell -Command` wrapper just to launch a native SDK tool (issue #711).
110
+
111
+ The one case that needs quoting is a `command`-type healthcheck, because its `cmd` is a single shell-style string combining the executable and its args (unlike `spec.cmd`, which keeps `args` separate). If the binary isn't on PATH and lives under a space-containing install dir (`C:\Program Files (x86)\Android\Sdk\emulator\emulator.exe`, `C:\Users\<user with spaces>\AppData\Local\Android\Sdk\platform-tools\adb.exe`), quote the whole path — the validator extracts the full quoted token (not just up to the first internal space) before checking it against the allowlist:
112
+
113
+ ```jsonc
114
+ "healthcheck": {
115
+ "type": "command",
116
+ "cmd": "\"C:/Program Files (x86)/Android/Sdk/platform-tools/adb.exe\" shell getprop sys.boot_completed",
117
+ "shell": true,
118
+ "expect_regex": "^1$",
119
+ "interval_s": 2,
120
+ "timeout_s": 120
121
+ }
122
+ ```
123
+
124
+ Single quotes (`'…'`) work the same way. No `powershell -Command "& '<path>' <args>"` wrapping is needed or sanctioned — wrap only if you genuinely need PowerShell-specific behavior (e.g. the call operator for a script, not just a native `.exe`).
125
+
107
126
  ## Lifecycle
108
127
 
109
128
  ```
@@ -219,7 +238,7 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js` (`ENGINE_DEFAUL
219
238
  | `promptContextMaxBytes` | `2048` | Auto-injected `## Live managed processes` block cap. |
220
239
  | `requireGitWorkdir` | `true` | Reject specs whose `cwd` isn't inside a git worktree (root or any ancestor up to `gitWorktreeMaxParentDepth`). |
221
240
  | `gitWorktreeMaxParentDepth` | `6` | How many parent directories `shared.isValidGitWorktree` walks when probing for `.git`. Lets monorepo specs pin a per-package `cwd` (`<root>/packages/<pkg>/...`); set to `0` to disable the walk. |
222
- | `executableAllowlist` | `[node, bun, npm, …]` | Single global. Applies to `spec.cmd` AND `command` healthcheck `cmd`. |
241
+ | `executableAllowlist` | `[node, bun, npm, python, docker, adb, emulator, gradle, mvn, pwsh, powershell, bash, sh, curl, wget, git, …]` | Single global. Applies to `spec.cmd` AND `command` healthcheck `cmd`, matched against `path.basename()` with `.exe`/`.cmd`/`.bat`/`.ps1`/`.sh` stripped — bare PATH names and full absolute paths both work. See [Non-PATH native launcher binaries](#non-path-native-launcher-binaries-eg-android-sdks-emulatorexe--adbexe) for quoting a `command` healthcheck's combined executable+args string. |
223
242
  | `envKeyDenyPatterns` | `[^AWS_, ^AZURE_, _SECRET, _TOKEN, _API_KEY, …]` | Regex source strings, matched case-insensitively. Keys matching ANY pattern are rejected unless exact-listed in `envKeyDenyOverrides`. Threat model: credential leakage, not env-key enumeration — plain project vars (`CONSTELLATION_SERVER`, `DATABASE_URL`, …) pass with no config. |
224
243
  | `envKeyDenyOverrides` | `[AWS_REGION, AWS_DEFAULT_REGION, AZURE_REGION, GCP_REGION, AWS_PROFILE]` | Exact-match exemptions for known-safe keys that would otherwise be caught by a broad prefix pattern. Case-sensitive. |
225
244
 
@@ -6747,17 +6747,28 @@ function collapseAllDuplicatePrRecords(config) {
6747
6747
  // a coding WI (not one of the validation types itself). Skips if the coding WI
6748
6748
  // has no PR.
6749
6749
  //
6750
+ // Files exactly ONE validation WI per coding-WI completion (W-mrhybval0001).
6751
+ // `liveValidation.type` still governs CHECKOUT ROUTING — the set of work-item
6752
+ // types that run in-place on the live checkout (see resolveCheckoutMode's
6753
+ // membership check) — but auto-validation no longer fans that array out into
6754
+ // one validation WI per type. That earlier behavior (W-mr2m1ute000a9c01)
6755
+ // conflated "which types run live" with "how many validation tasks to file";
6756
+ // a multi-select routing config would silently spawn N validation WIs after
6757
+ // every coding completion. Validation is a single downstream step, so we
6758
+ // collapse to one WI.
6759
+ //
6750
6760
  // liveValidation.type may be a single string (legacy / back-compat) or an
6751
- // array of strings (W-mr2m1ute000a9c01 — multi-select hybrid types). When an
6752
- // array, ONE validation WI is dispatched per configured type (each validation
6753
- // WI still carries a single canonical `type` field so downstream routing
6754
- // playbooks, agent resolution, resolveCheckoutMode's membership check keeps
6755
- // working per-type).
6761
+ // array of strings (multi-select hybrid types). We choose ONE validation type
6762
+ // for the single WI. It MUST be a member of lvTypes, otherwise resolveCheckoutMode
6763
+ // would route the validation WI to a worktree instead of the live checkout
6764
+ // (defeating hybrid validation). Preference order: the canonical `test` type
6765
+ // when configured, else the first configured live type. The completing item's
6766
+ // own type is excluded so a validation-type WI can't recursively validate
6767
+ // itself (anti-loop).
6756
6768
  //
6757
- // Deduplicates per (codingWiId, validationType) pair: a non-terminal WI with
6758
- // meta.liveValidationFor === codingWiId AND meta.liveValidationType === type
6759
- // blocks a second dispatch of that specific type, so multiple configured
6760
- // types don't collide or skip each other.
6769
+ // Deduplicates per codingWiId: any non-terminal WI with
6770
+ // meta.liveValidationFor === codingWiId blocks a second dispatch, so only one
6771
+ // validation task is ever in flight per coding completion.
6761
6772
  //
6762
6773
  // W-mr9u39az000db5c2 — guard against a self-referential recursive cascade.
6763
6774
  // When liveValidation.type overlaps with a coding WI's own type set (e.g.
@@ -6791,10 +6802,18 @@ function autoDispatchLiveValidationWi(meta, config) {
6791
6802
 
6792
6803
  const lvTypes = Array.isArray(lv.type) ? lv.type : [lv.type];
6793
6804
 
6794
- // Only auto-dispatch for coding WIs skip types that ARE one of the
6795
- // configured validation types (avoid an infinite validate-the-validation loop).
6796
- const typesToDispatch = lvTypes.filter(t => t !== item.type);
6797
- if (typesToDispatch.length === 0) return;
6805
+ // Candidate validation types = configured live types minus the completing
6806
+ // item's own type (avoid an infinite validate-the-validation loop).
6807
+ const candidates = lvTypes.filter(t => t && t !== item.type);
6808
+ if (candidates.length === 0) return;
6809
+
6810
+ // Collapse to a SINGLE validation type: prefer the canonical `test` type when
6811
+ // it is a configured live type, else the first configured live type. Either
6812
+ // way the chosen type is a member of lvTypes, so the validation WI routes to
6813
+ // the live checkout (not a worktree).
6814
+ const validationType = candidates.includes(WORK_TYPE.TEST)
6815
+ ? WORK_TYPE.TEST
6816
+ : candidates[0];
6798
6817
 
6799
6818
  // Resolve PR reference: prefer the canonical stamped _pr field (set by
6800
6819
  // stampWiPrRef which runs earlier in runPostCompletionHooks), then fall
@@ -6817,51 +6836,48 @@ function autoDispatchLiveValidationWi(meta, config) {
6817
6836
  mutateWorkItems(wiPath, items => {
6818
6837
  if (!Array.isArray(items)) return items;
6819
6838
 
6820
- for (const validationType of typesToDispatch) {
6821
- // Dedup: skip if a non-terminal WI already tracks this (coding WI, type)
6822
- // pair. Falls back to the WI's own `type` field when meta.liveValidationType
6823
- // is absent (back-compat with validation WIs created before this field
6824
- // existed, e.g. single-type configs from before W-mr2m1ute000a9c01).
6825
- const existing = items.find(i =>
6826
- i &&
6827
- i.meta &&
6828
- i.meta.liveValidationFor === codingWiId &&
6829
- (i.meta.liveValidationType === validationType ||
6830
- (!i.meta.liveValidationType && i.type === validationType)) &&
6831
- !PLAN_TERMINAL_STATUSES.has(i.status)
6832
- );
6833
- if (existing) {
6834
- log('info', `liveValidation: dedup — non-terminal validation WI ${existing.id} (type=${validationType}) already exists for ${codingWiId}`);
6835
- continue;
6836
- }
6839
+ // Dedup: only one validation WI in flight per coding WI. Any non-terminal
6840
+ // WI already tracking this coding WI (meta.liveValidationFor) blocks a
6841
+ // second dispatch, regardless of its validation type. (Back-compat: WIs
6842
+ // created before meta.liveValidationType existed are matched too, since
6843
+ // we key solely on liveValidationFor.)
6844
+ const existing = items.find(i =>
6845
+ i &&
6846
+ i.meta &&
6847
+ i.meta.liveValidationFor === codingWiId &&
6848
+ !PLAN_TERMINAL_STATUSES.has(i.status)
6849
+ );
6850
+ if (existing) {
6851
+ log('info', `liveValidation: dedup — non-terminal validation WI ${existing.id} already exists for ${codingWiId}`);
6852
+ return items;
6853
+ }
6837
6854
 
6838
- const proposedTitle = 'Validate: ' + (item.title || codingWiId);
6839
- // Defense-in-depth (belt-and-suspenders alongside the meta.liveValidationFor
6840
- // guard above): never create a doubly-nested "Validate: Validate: ..."
6841
- // title even if some future caller manages to invoke this function
6842
- // directly on an already-validation-tagged item.
6843
- if (/^Validate:\s*Validate:/.test(proposedTitle)) {
6844
- log('warn', `liveValidation: refusing to create nested "Validate: Validate:" WI for ${codingWiId} (type=${validationType})`);
6845
- continue;
6846
- }
6855
+ const proposedTitle = 'Validate: ' + (item.title || codingWiId);
6856
+ // Defense-in-depth (belt-and-suspenders alongside the meta.liveValidationFor
6857
+ // guard above): never create a doubly-nested "Validate: Validate: ..."
6858
+ // title even if some future caller manages to invoke this function
6859
+ // directly on an already-validation-tagged item.
6860
+ if (/^Validate:\s*Validate:/.test(proposedTitle)) {
6861
+ log('warn', `liveValidation: refusing to create nested "Validate: Validate:" WI for ${codingWiId} (type=${validationType})`);
6862
+ return items;
6863
+ }
6847
6864
 
6848
- const validationWi = {
6849
- id: 'W-' + shared.uid(),
6850
- title: proposedTitle,
6851
- type: validationType,
6852
- status: WI_STATUS.PENDING,
6853
- depends_on: [codingWiId],
6854
- references: [prRef],
6855
- meta: { liveValidationFor: codingWiId, liveValidationType: validationType },
6856
- project: projectName,
6857
- priority: item.priority || 'medium',
6858
- created: ts(),
6859
- createdBy: 'lifecycle:live-validation-auto-dispatch',
6860
- };
6865
+ const validationWi = {
6866
+ id: 'W-' + shared.uid(),
6867
+ title: proposedTitle,
6868
+ type: validationType,
6869
+ status: WI_STATUS.PENDING,
6870
+ depends_on: [codingWiId],
6871
+ references: [prRef],
6872
+ meta: { liveValidationFor: codingWiId, liveValidationType: validationType },
6873
+ project: projectName,
6874
+ priority: item.priority || 'medium',
6875
+ created: ts(),
6876
+ createdBy: 'lifecycle:live-validation-auto-dispatch',
6877
+ };
6861
6878
 
6862
- items.push(validationWi);
6863
- log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${validationType}) for coding WI ${codingWiId}`);
6864
- }
6879
+ items.push(validationWi);
6880
+ log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${validationType}) for coding WI ${codingWiId}`);
6865
6881
  return items;
6866
6882
  });
6867
6883
  } catch (err) {
@@ -142,11 +142,29 @@ function _resolveProjectForCwd(cwd, projects) {
142
142
  // string so it can be checked against the executable allowlist. Strips
143
143
  // surrounding quotes and any leading path. Returns '' when no candidate
144
144
  // found.
145
+ //
146
+ // W-mr9v7h2u0009ca67 (issue #711): native launcher binaries that don't sit
147
+ // on PATH (e.g. the Android SDK's `emulator.exe` / `adb.exe`) are commonly
148
+ // installed under a space-containing directory (`C:\Program Files
149
+ // (x86)\Android\Sdk\...`, `C:\Users\<user with spaces>\AppData\Local\...`).
150
+ // A `command`-type healthcheck naturally quotes such a path — but a naive
151
+ // "split at first whitespace" tokenizer truncates the quoted path at its
152
+ // first internal space, so the extracted token never matches the allowlist
153
+ // and agents were forced into an undocumented `powershell -Command "&
154
+ // '<path>' <args>"` wrapper just to pass validation. When `cmd` starts with
155
+ // a quote character, walk to the matching closing quote instead of the
156
+ // first whitespace so the full quoted path is treated as one token.
145
157
  function _firstExecutable(cmd) {
146
158
  if (typeof cmd !== 'string') return '';
147
159
  const trimmed = cmd.trim();
148
160
  if (trimmed.length === 0) return '';
149
- const m = trimmed.match(/^["']?([^\s"']+)["']?/);
161
+ const quote = trimmed[0];
162
+ if (quote === '"' || quote === '\'') {
163
+ const closeIdx = trimmed.indexOf(quote, 1);
164
+ if (closeIdx > 0) return trimmed.slice(1, closeIdx);
165
+ // Unterminated quote — fall through to the unquoted split below.
166
+ }
167
+ const m = trimmed.match(/^([^\s]+)/);
150
168
  if (!m) return '';
151
169
  return m[1];
152
170
  }
@@ -602,7 +620,7 @@ function buildManagedSpawnHint(opts) {
602
620
  '',
603
621
  '- Specs per file: ≤ ' + maxSpecs,
604
622
  '- Name: kebab-case, ≤ 64 chars, unique within file',
605
- '- Executable (`cmd` and any `command` healthcheck cmd): on the engine\'s allowlist (node, bun, npm, npx, python, docker, adb, gradle, mvn, pwsh, …)',
623
+ '- Executable (`cmd` and any `command` healthcheck cmd): on the engine\'s allowlist (node, bun, npm, npx, python, docker, adb, emulator, gradle, mvn, pwsh, …). Matching strips the `.exe`/`.cmd`/`.bat`/`.ps1`/`.sh` extension and ignores any leading directory, so a full absolute path to an allowlisted binary works — you do NOT need a `powershell -Command "& \'<path>\' <args>"` wrapper. For a `command`-type healthcheck, if the binary isn\'t on PATH and its path contains spaces, just quote the whole path (`"C:/Program Files (x86)/Android/Sdk/emulator/emulator.exe" -version`) — the validator extracts the full quoted token, not just up to the first space.',
606
624
  '- Env keys: any well-formed POSIX identifier (`/^[A-Za-z_][A-Za-z0-9_]*$/`) is accepted EXCEPT keys matching credential-shaped deny patterns (`*_SECRET`, `*_TOKEN`, `*_API_KEY`, `*_PASSWORD`, `*_PRIVATE_KEY`, `*_CREDENTIALS`, `*_AUTH`, `*_PAT`, `AWS_*`, `AZURE_*`, `GCP_*`, `GH_TOKEN`, `GITHUB_TOKEN`, `OPENAI_*`, `ANTHROPIC_*`, `COPILOT_*`, `DOCKER_AUTH*`, `NPM_TOKEN`). Region/profile names (`AWS_REGION`, `AWS_PROFILE`, …) are exempt. If your service genuinely needs a credential-looking var, rename it (e.g. `DATABASE_URL` not `DB_API_KEY`) or stash the value in `attrs` and have the child read it from there. **The engine forwards env values verbatim — the deny shape is a tripwire, not a scrubber. Do not put real credentials in env even if the key passes.** Projects may tighten by adding patterns to `config.projects[N].managedSpawnExtraDenyPatterns`; projects cannot loosen.',
607
625
  '- Ports: 1024–65535, ≤ 20 per spec',
608
626
  '- TTL: ≤ ' + maxTtl + ' minutes (hard cap), defaults to ' + defaultTtl + ' if omitted',
@@ -711,9 +729,9 @@ function buildManagedSpawnHint(opts) {
711
729
  // ctx) close-handler call site spawns each
712
730
  // then persists them together).
713
731
  // 5. removeManagedSpec(name) → locked unlink of the entry; best-effort
714
- // process.kill of the recorded PID
715
- // OUTSIDE the lock callback. No-op when
716
- // the entry is missing.
732
+ // full-process-tree kill (shared.killImmediate)
733
+ // of the recorded PID OUTSIDE the lock
734
+ // callback. No-op when the entry is missing.
717
735
  // 6. listManagedSpecs({project}) → reads the state file, optionally
718
736
  // filters by owner_project. Used later
719
737
  // by item 4 (dashboard) + item 6
@@ -897,8 +915,14 @@ function removeManagedSpec(name) {
897
915
  }, { defaultValue: _initialStateShape() });
898
916
  // Kill OUTSIDE the lock — never run process ops inside a lock callback
899
917
  // (copilot-instructions.md "Keep lock callbacks synchronous and fast").
918
+ // Use killImmediate (full process-tree kill: `taskkill /PID <pid> /F /T` on
919
+ // Windows, recursive pgrep-based descendant walk on Unix) rather than
920
+ // killByPidImmediate (single-PID only). Managed specs are frequently
921
+ // launcher-style processes (e.g. Android emulator.exe spawning a detached
922
+ // qemu-system-x86_64 child) whose descendants survive a single-PID kill and
923
+ // leak indefinitely (opg-microsoft/minions#710).
900
924
  if (killPid != null) {
901
- try { shared.killByPidImmediate(killPid); }
925
+ try { shared.killImmediate({ pid: killPid }); }
902
926
  catch (e) { log('warn', 'managed-spawn removeManagedSpec: kill ' + killPid + ' failed: ' + e.message); }
903
927
  }
904
928
  }
@@ -1246,8 +1270,11 @@ function restartManagedSpec(name) {
1246
1270
  healthcheck: existing.healthcheck || null,
1247
1271
  };
1248
1272
  // Kill the old PID before respawn (best-effort; outside of any lock).
1273
+ // Full process-tree kill (see removeManagedSpec above) — a single-PID kill
1274
+ // here would leave launcher descendants (e.g. qemu-system-x86_64 under an
1275
+ // Android emulator.exe) running after "restart" respawns a fresh launcher.
1249
1276
  if (Number.isInteger(existing.pid) && existing.pid > 0) {
1250
- try { shared.killByPidImmediate(existing.pid); }
1277
+ try { shared.killImmediate({ pid: existing.pid }); }
1251
1278
  catch (e) { log('warn', 'restartManagedSpec: kill of old PID ' + existing.pid + ' failed: ' + e.message); }
1252
1279
  }
1253
1280
  const ctx = {
@@ -1305,6 +1305,29 @@ function summarizeSessionsForStatus() {
1305
1305
  return { total: sessions.length, sig };
1306
1306
  }
1307
1307
 
1308
+ /**
1309
+ * Cheap summary of non-terminal ("active") sessions for the QA nav sidebar
1310
+ * badge (issue opg-microsoft/minions#717 — QA nav has no visibility signal).
1311
+ * Mirrors summarizeSessionsForStatus but excludes TERMINAL_STATES so the
1312
+ * count matches what a user cares about: sessions still in flight
1313
+ * (pending/spawning/drafting/awaiting-approval/executing), not the full
1314
+ * history of done/failed/killed records.
1315
+ *
1316
+ * @returns {{ active: number, sig: string }}
1317
+ */
1318
+ function summarizeActiveSessionsForStatus() {
1319
+ const sessions = _readSessions();
1320
+ if (!Array.isArray(sessions) || sessions.length === 0) return { active: 0, sig: '' };
1321
+ let active = 0;
1322
+ let sig = '';
1323
+ for (const s of sessions) {
1324
+ if (!s || TERMINAL_STATES.has(s.state)) continue;
1325
+ active++;
1326
+ sig += (s.id || '') + ':' + (s.state || '') + ',';
1327
+ }
1328
+ return { active, sig };
1329
+ }
1330
+
1308
1331
  module.exports = {
1309
1332
  // Constants
1310
1333
  QA_SESSION_STATE,
@@ -1356,6 +1379,7 @@ module.exports = {
1356
1379
  dismissSession,
1357
1380
  // Status
1358
1381
  summarizeSessionsForStatus,
1382
+ summarizeActiveSessionsForStatus,
1359
1383
  // Internals (exposed for tests)
1360
1384
  _isSafeSessionId,
1361
1385
  };
package/engine/shared.js CHANGED
@@ -3058,9 +3058,12 @@ function validateLiveValidation(value, opts) {
3058
3058
  } else {
3059
3059
  throw _httpError(400, typeErrorMsg);
3060
3060
  }
3061
- // autoDispatch (optional): when true, lifecycle auto-creates a validation WI
3062
- // per configured `type` after each coding WI completes with a PR
3063
- // (engine/lifecycle.js autoDispatchLiveValidationWi). Coerce to a strict
3061
+ // autoDispatch (optional): when true, lifecycle auto-creates a SINGLE
3062
+ // validation WI after each coding WI completes with a PR
3063
+ // (engine/lifecycle.js autoDispatchLiveValidationWi). Even when `type` is a
3064
+ // multi-entry array (which drives checkout ROUTING — the types that run live),
3065
+ // exactly one validation WI is filed, of the canonical `test` type when
3066
+ // configured else the first configured live type. Coerce to a strict
3064
3067
  // boolean; absent leaves it off so the operator opts in explicitly.
3065
3068
  if (Object.prototype.hasOwnProperty.call(value, 'autoDispatch')) {
3066
3069
  out.autoDispatch = !!value.autoDispatch;
@@ -8538,12 +8541,20 @@ function mutateWorkItems(filePath, mutator) {
8538
8541
  if (insideMinionsDir) {
8539
8542
  const store = require('./work-items-store');
8540
8543
  const scope = store.scopeForFilePath(filePath);
8544
+ // W-mr9vcxvy000cdb4e — the JSON mirror is now written INSIDE
8545
+ // applyWorkItemsMutation's SQL transaction (before commit), not here
8546
+ // after the fact. Mirroring post-commit left a cross-process window
8547
+ // where a sibling process (dashboard vs. engine — separate Node
8548
+ // processes, each with its own in-memory mirror-hash cache) could
8549
+ // observe the new SQL row before the JSON file caught up and
8550
+ // destructively rehydrate the scope from that stale file, dropping
8551
+ // the just-created row (the "phantom work item" dispatch race). See
8552
+ // work-items-store.js#applyWorkItemsMutation for the full rationale.
8541
8553
  const { wrote, result } = store.applyWorkItemsMutation(scope, (items) => {
8542
8554
  if (!Array.isArray(items)) items = [];
8543
8555
  return mutator(items) || items;
8544
- });
8556
+ }, { mirrorPath: filePath });
8545
8557
  if (wrote) {
8546
- try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
8547
8558
  try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
8548
8559
  try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
8549
8560
  }
@@ -8810,6 +8821,14 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
8810
8821
  if (cause === PR_FIX_CAUSE.HUMAN_FEEDBACK) {
8811
8822
  evidence.lastProcessedCommentDate = feedback.lastProcessedCommentDate || '';
8812
8823
  evidence.feedbackContent = feedback.feedbackContent || '';
8824
+ // #714 — same rationale as BUILD_FAILURE / MERGE_CONFLICT / REVIEW_FEEDBACK:
8825
+ // without a head-SHA salt, a genuine new push that doesn't otherwise change
8826
+ // lastProcessedCommentDate/feedbackContent (e.g. comment-thread bookkeeping
8827
+ // races/dedup) can leave the human-feedback no-op pause stuck even though
8828
+ // real new work landed. Adding head SHA + lastPushedAt gives HUMAN_FEEDBACK
8829
+ // the same natural-unsticking property the other three causes already have.
8830
+ evidence.headRefOid = _prHeadSha(pr);
8831
+ evidence.lastPushedAt = pr?.lastPushedAt || '';
8813
8832
  } else if (cause === PR_FIX_CAUSE.BUILD_FAILURE) {
8814
8833
  evidence.buildStatus = pr?.buildStatus || '';
8815
8834
  evidence.buildFailReason = pr?.buildFailReason || '';
@@ -320,7 +320,7 @@ function _hydrateScopeFromJson(db, scope) {
320
320
  }
321
321
  }
322
322
 
323
- function applyWorkItemsMutation(scope, mutator) {
323
+ function applyWorkItemsMutation(scope, mutator, options = {}) {
324
324
  const { getDb, withTransaction } = require('./db');
325
325
  const db = getDb();
326
326
 
@@ -344,6 +344,27 @@ function applyWorkItemsMutation(scope, mutator) {
344
344
  : (Array.isArray(next) ? next : before);
345
345
  const diff = _computeWorkItemsDiff(scope, beforeSnapshot, after);
346
346
  const wrote = _applyWorkItemsDiff(db, diff);
347
+ // W-mr9vcxvy000cdb4e — phantom-WI race fix: mirror the JSON sidecar
348
+ // INSIDE this same SQL write transaction, BEFORE it commits, instead
349
+ // of after (as the caller used to do post-return). SQLite only allows
350
+ // one writer transaction at a time (BEGIN IMMEDIATE holds an
351
+ // exclusive writer lock), so mirroring here guarantees the on-disk
352
+ // JSON file is fully up to date by the time this commit becomes
353
+ // visible to any other reader/writer — dashboard and engine are
354
+ // SEPARATE Node processes, each with its own in-memory
355
+ // `_lastMirrorHashByScope` cache, so without this ordering a sibling
356
+ // process could observe a freshly committed SQL row before the mirror
357
+ // file caught up, decide (via its own stale hash) that the JSON file
358
+ // was "externally edited", and call `_hydrateScopeFromJson` — which
359
+ // DELETEs + reinserts the whole scope from that stale file, silently
360
+ // dropping the row a POST /api/work-items caller had just been told
361
+ // was created successfully (visible to the engine's dispatch loop
362
+ // one moment, gone from work_items the next). Mirroring pre-commit
363
+ // means "SQL says the row exists" and "JSON mirror has the row" can
364
+ // never observably disagree across processes.
365
+ if (wrote) {
366
+ try { _mirrorJsonFromSql(scope, options.mirrorPath); } catch { /* best-effort — mirror failure must not block the SQL write */ }
367
+ }
347
368
  return { wrote, result: after };
348
369
  });
349
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2343",
3
+ "version": "0.1.2344",
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"