@yemi33/minions 0.1.2342 → 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.
- package/dashboard/js/refresh.js +11 -0
- package/dashboard/layout.html +1 -1
- package/dashboard.js +8 -0
- package/docs/managed-spawn.md +20 -1
- package/engine/lifecycle.js +88 -46
- package/engine/managed-spawn.js +34 -7
- package/engine/qa-sessions.js +24 -0
- package/engine/shared.js +24 -5
- package/engine/work-items-store.js +22 -1
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -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:
|
package/dashboard/layout.html
CHANGED
|
@@ -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
|
|
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',
|
package/docs/managed-spawn.md
CHANGED
|
@@ -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
|
|
package/engine/lifecycle.js
CHANGED
|
@@ -6747,21 +6747,48 @@ 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 (
|
|
6752
|
-
//
|
|
6753
|
-
//
|
|
6754
|
-
//
|
|
6755
|
-
//
|
|
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).
|
|
6768
|
+
//
|
|
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.
|
|
6756
6772
|
//
|
|
6757
|
-
//
|
|
6758
|
-
//
|
|
6759
|
-
//
|
|
6760
|
-
//
|
|
6773
|
+
// W-mr9u39az000db5c2 — guard against a self-referential recursive cascade.
|
|
6774
|
+
// When liveValidation.type overlaps with a coding WI's own type set (e.g.
|
|
6775
|
+
// ["test","verify","setup"] configured on a project that ALSO dispatches
|
|
6776
|
+
// "setup"/"test"/"fix" coding work), a completed validation WI (type
|
|
6777
|
+
// "setup") is itself a WI whose type is in liveValidation.type. The old
|
|
6778
|
+
// item.type-only filter let that validation WI's own completion re-trigger
|
|
6779
|
+
// this hook and spawn ANOTHER validation WI (title-prefixed "Validate:
|
|
6780
|
+
// Validate: ..."), repeating indefinitely. Any WI that was itself created
|
|
6781
|
+
// as a liveValidation follow-up (tagged via meta.liveValidationFor at
|
|
6782
|
+
// creation, below) must never re-trigger this hook, regardless of its type.
|
|
6761
6783
|
function autoDispatchLiveValidationWi(meta, config) {
|
|
6762
6784
|
const item = meta?.item;
|
|
6763
6785
|
if (!item?.id || !item?.type) return;
|
|
6764
6786
|
|
|
6787
|
+
// Never re-trigger off the completion of a WI that was itself auto-created
|
|
6788
|
+
// as a live-validation follow-up — that's what causes the infinite
|
|
6789
|
+
// "Validate: Validate: ..." cascade (W-mr9u39az000db5c2 / issue #708).
|
|
6790
|
+
if (item.meta && item.meta.liveValidationFor) return;
|
|
6791
|
+
|
|
6765
6792
|
const projectName = meta?.project?.name;
|
|
6766
6793
|
if (!projectName) return;
|
|
6767
6794
|
|
|
@@ -6775,10 +6802,18 @@ function autoDispatchLiveValidationWi(meta, config) {
|
|
|
6775
6802
|
|
|
6776
6803
|
const lvTypes = Array.isArray(lv.type) ? lv.type : [lv.type];
|
|
6777
6804
|
|
|
6778
|
-
//
|
|
6779
|
-
//
|
|
6780
|
-
const
|
|
6781
|
-
if (
|
|
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];
|
|
6782
6817
|
|
|
6783
6818
|
// Resolve PR reference: prefer the canonical stamped _pr field (set by
|
|
6784
6819
|
// stampWiPrRef which runs earlier in runPostCompletionHooks), then fall
|
|
@@ -6801,41 +6836,48 @@ function autoDispatchLiveValidationWi(meta, config) {
|
|
|
6801
6836
|
mutateWorkItems(wiPath, items => {
|
|
6802
6837
|
if (!Array.isArray(items)) return items;
|
|
6803
6838
|
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
);
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
continue;
|
|
6820
|
-
}
|
|
6821
|
-
|
|
6822
|
-
const validationWi = {
|
|
6823
|
-
id: 'W-' + shared.uid(),
|
|
6824
|
-
title: 'Validate: ' + (item.title || codingWiId),
|
|
6825
|
-
type: validationType,
|
|
6826
|
-
status: WI_STATUS.PENDING,
|
|
6827
|
-
depends_on: [codingWiId],
|
|
6828
|
-
references: [prRef],
|
|
6829
|
-
meta: { liveValidationFor: codingWiId, liveValidationType: validationType },
|
|
6830
|
-
project: projectName,
|
|
6831
|
-
priority: item.priority || 'medium',
|
|
6832
|
-
created: ts(),
|
|
6833
|
-
createdBy: 'lifecycle:live-validation-auto-dispatch',
|
|
6834
|
-
};
|
|
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
|
+
}
|
|
6835
6854
|
|
|
6836
|
-
|
|
6837
|
-
|
|
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;
|
|
6838
6863
|
}
|
|
6864
|
+
|
|
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
|
+
};
|
|
6878
|
+
|
|
6879
|
+
items.push(validationWi);
|
|
6880
|
+
log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${validationType}) for coding WI ${codingWiId}`);
|
|
6839
6881
|
return items;
|
|
6840
6882
|
});
|
|
6841
6883
|
} catch (err) {
|
package/engine/managed-spawn.js
CHANGED
|
@@ -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
|
|
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
|
|
715
|
-
//
|
|
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.
|
|
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.
|
|
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 = {
|
package/engine/qa-sessions.js
CHANGED
|
@@ -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
|
|
3062
|
-
//
|
|
3063
|
-
// (engine/lifecycle.js autoDispatchLiveValidationWi).
|
|
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.
|
|
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"
|