@yemi33/minions 0.1.2343 → 0.1.2345
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 +47 -1
- package/engine/lifecycle.js +81 -55
- package/engine/managed-spawn.js +73 -7
- package/engine/qa-sessions.js +62 -0
- package/engine/shared.js +24 -5
- package/engine/work-items-store.js +22 -1
- package/engine.js +48 -14
- package/package.json +1 -1
- package/playbooks/qa-session-setup.md +8 -0
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,52 @@ 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
|
+
### Native app / emulator-backed targets (issue #712)
|
|
108
|
+
|
|
109
|
+
Not every managed-spawn target is a TCP-bound HTTP dev server. Native mobile/desktop apps (e.g. an Android Gradle/Kotlin app) have no HTTP endpoint to probe. Use a `command` healthcheck instead of `http`, and declare the emulator/simulator/device **boot** — not the app itself — as the long-lived spec:
|
|
110
|
+
|
|
111
|
+
1. Spawn only the long-lived environment dependency (emulator/simulator/device boot, or an already-running device-farm session) as the managed-spawn spec.
|
|
112
|
+
2. Defer the one-shot build + install + launch (e.g. a 30-45 minute Gradle assemble/installDebug/am start) to the DRAFT/EXECUTE phase rather than declaring it as the spec's `cmd` — managed-spawn owns persistent processes, not one-shot commands.
|
|
113
|
+
3. Poll the boot/ready signal with a `command` healthcheck. For Android, `adb shell getprop sys.boot_completed` matched against `^1$` is the canonical ready signal.
|
|
114
|
+
|
|
115
|
+
```jsonc
|
|
116
|
+
{
|
|
117
|
+
"name": "android-emulator",
|
|
118
|
+
"cmd": "emulator",
|
|
119
|
+
"args": ["-avd", "Pixel_6_API_34", "-no-snapshot", "-no-window"],
|
|
120
|
+
"healthcheck": {
|
|
121
|
+
"type": "command",
|
|
122
|
+
"cmd": "adb",
|
|
123
|
+
"args": ["shell", "getprop", "sys.boot_completed"],
|
|
124
|
+
"shell": false,
|
|
125
|
+
"expect_regex": "^1$",
|
|
126
|
+
"interval_s": 5,
|
|
127
|
+
"timeout_s": 180
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Known device-farm tooling: `adb` and `emulator` are both on the managed-spawn executable allowlist (device/emulator shell + install + logcat; `emulator -list-avds` discovers configured AVDs before picking one). `maestro` is a declarative mobile UI-flow runner already wired in as a built-in QA runner adapter (`engine/qa-runners.js`) for the DRAFT/EXECUTE phases — it is not itself on the managed-spawn `cmd`/healthcheck allowlist. The full worked example — including `ttl_minutes`, `attrs`, `written_by`/`wi_id` — is inlined in [`buildManagedSpawnHint`](../engine/managed-spawn.js).
|
|
133
|
+
|
|
134
|
+
### Non-PATH native launcher binaries (e.g. Android SDK's `emulator.exe` / `adb.exe`)
|
|
135
|
+
|
|
136
|
+
`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).
|
|
137
|
+
|
|
138
|
+
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:
|
|
139
|
+
|
|
140
|
+
```jsonc
|
|
141
|
+
"healthcheck": {
|
|
142
|
+
"type": "command",
|
|
143
|
+
"cmd": "\"C:/Program Files (x86)/Android/Sdk/platform-tools/adb.exe\" shell getprop sys.boot_completed",
|
|
144
|
+
"shell": true,
|
|
145
|
+
"expect_regex": "^1$",
|
|
146
|
+
"interval_s": 2,
|
|
147
|
+
"timeout_s": 120
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
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`).
|
|
152
|
+
|
|
107
153
|
## Lifecycle
|
|
108
154
|
|
|
109
155
|
```
|
|
@@ -219,7 +265,7 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js` (`ENGINE_DEFAUL
|
|
|
219
265
|
| `promptContextMaxBytes` | `2048` | Auto-injected `## Live managed processes` block cap. |
|
|
220
266
|
| `requireGitWorkdir` | `true` | Reject specs whose `cwd` isn't inside a git worktree (root or any ancestor up to `gitWorktreeMaxParentDepth`). |
|
|
221
267
|
| `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`. |
|
|
268
|
+
| `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
269
|
| `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
270
|
| `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
271
|
|
package/engine/lifecycle.js
CHANGED
|
@@ -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 (
|
|
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).
|
|
6756
6768
|
//
|
|
6757
|
-
// Deduplicates per
|
|
6758
|
-
// meta.liveValidationFor === codingWiId
|
|
6759
|
-
//
|
|
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.
|
|
@@ -6778,6 +6789,16 @@ function autoDispatchLiveValidationWi(meta, config) {
|
|
|
6778
6789
|
// "Validate: Validate: ..." cascade (W-mr9u39az000db5c2 / issue #708).
|
|
6779
6790
|
if (item.meta && item.meta.liveValidationFor) return;
|
|
6780
6791
|
|
|
6792
|
+
// Issue #716 — QA Session infrastructure WIs (SETUP/DRAFT/EXECUTE, tagged
|
|
6793
|
+
// via meta.sessionId and always dispatched with skipPr:true) never own a
|
|
6794
|
+
// real code diff, so there is nothing for a live-validation WI to validate.
|
|
6795
|
+
// Without this guard, a QA Session WI whose type happens to be one of the
|
|
6796
|
+
// configured liveValidation.type entries (qa-sessions uses WORK_TYPE.TEST
|
|
6797
|
+
// for all three phases) spawns spurious "Validate: QA Session ..." WIs the
|
|
6798
|
+
// moment any PR reference resolves against it (see the skipPr guard below
|
|
6799
|
+
// resolveWorkItemPrRecord in engine.js for the matching pre-dispatch fix).
|
|
6800
|
+
if (item.skipPr || (item.meta && item.meta.sessionId)) return;
|
|
6801
|
+
|
|
6781
6802
|
const projectName = meta?.project?.name;
|
|
6782
6803
|
if (!projectName) return;
|
|
6783
6804
|
|
|
@@ -6791,10 +6812,18 @@ function autoDispatchLiveValidationWi(meta, config) {
|
|
|
6791
6812
|
|
|
6792
6813
|
const lvTypes = Array.isArray(lv.type) ? lv.type : [lv.type];
|
|
6793
6814
|
|
|
6794
|
-
//
|
|
6795
|
-
//
|
|
6796
|
-
const
|
|
6797
|
-
if (
|
|
6815
|
+
// Candidate validation types = configured live types minus the completing
|
|
6816
|
+
// item's own type (avoid an infinite validate-the-validation loop).
|
|
6817
|
+
const candidates = lvTypes.filter(t => t && t !== item.type);
|
|
6818
|
+
if (candidates.length === 0) return;
|
|
6819
|
+
|
|
6820
|
+
// Collapse to a SINGLE validation type: prefer the canonical `test` type when
|
|
6821
|
+
// it is a configured live type, else the first configured live type. Either
|
|
6822
|
+
// way the chosen type is a member of lvTypes, so the validation WI routes to
|
|
6823
|
+
// the live checkout (not a worktree).
|
|
6824
|
+
const validationType = candidates.includes(WORK_TYPE.TEST)
|
|
6825
|
+
? WORK_TYPE.TEST
|
|
6826
|
+
: candidates[0];
|
|
6798
6827
|
|
|
6799
6828
|
// Resolve PR reference: prefer the canonical stamped _pr field (set by
|
|
6800
6829
|
// stampWiPrRef which runs earlier in runPostCompletionHooks), then fall
|
|
@@ -6817,51 +6846,48 @@ function autoDispatchLiveValidationWi(meta, config) {
|
|
|
6817
6846
|
mutateWorkItems(wiPath, items => {
|
|
6818
6847
|
if (!Array.isArray(items)) return items;
|
|
6819
6848
|
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
);
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
continue;
|
|
6836
|
-
}
|
|
6849
|
+
// Dedup: only one validation WI in flight per coding WI. Any non-terminal
|
|
6850
|
+
// WI already tracking this coding WI (meta.liveValidationFor) blocks a
|
|
6851
|
+
// second dispatch, regardless of its validation type. (Back-compat: WIs
|
|
6852
|
+
// created before meta.liveValidationType existed are matched too, since
|
|
6853
|
+
// we key solely on liveValidationFor.)
|
|
6854
|
+
const existing = items.find(i =>
|
|
6855
|
+
i &&
|
|
6856
|
+
i.meta &&
|
|
6857
|
+
i.meta.liveValidationFor === codingWiId &&
|
|
6858
|
+
!PLAN_TERMINAL_STATUSES.has(i.status)
|
|
6859
|
+
);
|
|
6860
|
+
if (existing) {
|
|
6861
|
+
log('info', `liveValidation: dedup — non-terminal validation WI ${existing.id} already exists for ${codingWiId}`);
|
|
6862
|
+
return items;
|
|
6863
|
+
}
|
|
6837
6864
|
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6865
|
+
const proposedTitle = 'Validate: ' + (item.title || codingWiId);
|
|
6866
|
+
// Defense-in-depth (belt-and-suspenders alongside the meta.liveValidationFor
|
|
6867
|
+
// guard above): never create a doubly-nested "Validate: Validate: ..."
|
|
6868
|
+
// title even if some future caller manages to invoke this function
|
|
6869
|
+
// directly on an already-validation-tagged item.
|
|
6870
|
+
if (/^Validate:\s*Validate:/.test(proposedTitle)) {
|
|
6871
|
+
log('warn', `liveValidation: refusing to create nested "Validate: Validate:" WI for ${codingWiId} (type=${validationType})`);
|
|
6872
|
+
return items;
|
|
6873
|
+
}
|
|
6847
6874
|
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6875
|
+
const validationWi = {
|
|
6876
|
+
id: 'W-' + shared.uid(),
|
|
6877
|
+
title: proposedTitle,
|
|
6878
|
+
type: validationType,
|
|
6879
|
+
status: WI_STATUS.PENDING,
|
|
6880
|
+
depends_on: [codingWiId],
|
|
6881
|
+
references: [prRef],
|
|
6882
|
+
meta: { liveValidationFor: codingWiId, liveValidationType: validationType },
|
|
6883
|
+
project: projectName,
|
|
6884
|
+
priority: item.priority || 'medium',
|
|
6885
|
+
created: ts(),
|
|
6886
|
+
createdBy: 'lifecycle:live-validation-auto-dispatch',
|
|
6887
|
+
};
|
|
6861
6888
|
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
}
|
|
6889
|
+
items.push(validationWi);
|
|
6890
|
+
log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${validationType}) for coding WI ${codingWiId}`);
|
|
6865
6891
|
return items;
|
|
6866
6892
|
});
|
|
6867
6893
|
} 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',
|
|
@@ -676,6 +694,45 @@ function buildManagedSpawnHint(opts) {
|
|
|
676
694
|
'',
|
|
677
695
|
'Equivalent flags for other runtimes: `pnpm --filter <pkg>`, `yarn workspace <pkg>`, `npm run -w <pkg>`, `lage run <task> --to <pkg>`. Keeping `cwd` at the root makes the spec portable across machines (no hard-coded package path) and avoids per-package `node_modules` resolution surprises.',
|
|
678
696
|
'',
|
|
697
|
+
'### Native app / emulator-backed targets (W-mr9w1p8n000i08c0, issue #712)',
|
|
698
|
+
'',
|
|
699
|
+
'Not every target is a TCP-bound HTTP dev server. Native mobile/desktop apps (e.g. an Android Gradle/Kotlin app) have no HTTP endpoint to probe — do NOT force an `http` healthcheck onto them. Use a `command`-type healthcheck instead, and treat the emulator/simulator/device **boot** as the long-lived "service" you declare here, not the one-shot build+install+launch.',
|
|
700
|
+
'',
|
|
701
|
+
'**Pattern:**',
|
|
702
|
+
'1. Spawn only the long-lived environment dependency (emulator/simulator/device boot, or an already-running device-farm session) as the managed-spawn spec. It has no meaningful exit — it just needs to stay alive and reach a ready state.',
|
|
703
|
+
'2. Defer the actual one-shot build + install + launch (e.g. a 30-45 minute Gradle assemble/installDebug/am start) to the DRAFT/EXECUTE phase instead of declaring it as this spec\'s `cmd` — a one-shot command is not something managed-spawn should own as a persistent process.',
|
|
704
|
+
'3. Use a `command` healthcheck that polls the boot/ready signal and asserts on stdout via `expect_regex`, not an HTTP `expect_status`. For Android, `adb shell getprop sys.boot_completed` polled until it matches `^1$` is the canonical boot-ready signal.',
|
|
705
|
+
'',
|
|
706
|
+
'Example — Android emulator boot as the managed spec:',
|
|
707
|
+
'',
|
|
708
|
+
'```jsonc',
|
|
709
|
+
'{',
|
|
710
|
+
' "specs": [',
|
|
711
|
+
' {',
|
|
712
|
+
' "name": "android-emulator",',
|
|
713
|
+
' "cmd": "emulator",',
|
|
714
|
+
' "args": ["-avd", "Pixel_6_API_34", "-no-snapshot", "-no-window"],',
|
|
715
|
+
' "cwd": "' + minionsDir + '",',
|
|
716
|
+
' "ttl_minutes": ' + ttl + ',',
|
|
717
|
+
' "attrs": { "avd": "Pixel_6_API_34" },',
|
|
718
|
+
' "healthcheck": {',
|
|
719
|
+
' "type": "command",',
|
|
720
|
+
' "cmd": "adb",',
|
|
721
|
+
' "args": ["shell", "getprop", "sys.boot_completed"],',
|
|
722
|
+
' "shell": false,',
|
|
723
|
+
' "expect_regex": "^1$",',
|
|
724
|
+
' "interval_s": 5,',
|
|
725
|
+
' "timeout_s": 180',
|
|
726
|
+
' }',
|
|
727
|
+
' }',
|
|
728
|
+
' ],',
|
|
729
|
+
' "written_by": "' + agentId + '",',
|
|
730
|
+
' "wi_id": "' + wiId + '"',
|
|
731
|
+
'}',
|
|
732
|
+
'```',
|
|
733
|
+
'',
|
|
734
|
+
'Known device-farm tooling to reach for instead of re-deriving a pattern from scratch: `adb` and `emulator` (both on the managed-spawn executable allowlist — device/emulator shell, install, logcat; `emulator -list-avds` discovers configured AVDs before you pick one for the spec), and `maestro` (declarative mobile UI-flow runner already wired in as a built-in QA runner adapter — see `engine/qa-runners.js` — for the DRAFT/EXECUTE phases, not for the managed-spawn `cmd`/healthcheck allowlist itself).',
|
|
735
|
+
'',
|
|
679
736
|
'### Verify before exit',
|
|
680
737
|
'',
|
|
681
738
|
'After you write the file, query the engine to confirm acceptance:',
|
|
@@ -711,9 +768,9 @@ function buildManagedSpawnHint(opts) {
|
|
|
711
768
|
// ctx) close-handler call site spawns each
|
|
712
769
|
// then persists them together).
|
|
713
770
|
// 5. removeManagedSpec(name) → locked unlink of the entry; best-effort
|
|
714
|
-
// process
|
|
715
|
-
//
|
|
716
|
-
// the entry is missing.
|
|
771
|
+
// full-process-tree kill (shared.killImmediate)
|
|
772
|
+
// of the recorded PID OUTSIDE the lock
|
|
773
|
+
// callback. No-op when the entry is missing.
|
|
717
774
|
// 6. listManagedSpecs({project}) → reads the state file, optionally
|
|
718
775
|
// filters by owner_project. Used later
|
|
719
776
|
// by item 4 (dashboard) + item 6
|
|
@@ -897,8 +954,14 @@ function removeManagedSpec(name) {
|
|
|
897
954
|
}, { defaultValue: _initialStateShape() });
|
|
898
955
|
// Kill OUTSIDE the lock — never run process ops inside a lock callback
|
|
899
956
|
// (copilot-instructions.md "Keep lock callbacks synchronous and fast").
|
|
957
|
+
// Use killImmediate (full process-tree kill: `taskkill /PID <pid> /F /T` on
|
|
958
|
+
// Windows, recursive pgrep-based descendant walk on Unix) rather than
|
|
959
|
+
// killByPidImmediate (single-PID only). Managed specs are frequently
|
|
960
|
+
// launcher-style processes (e.g. Android emulator.exe spawning a detached
|
|
961
|
+
// qemu-system-x86_64 child) whose descendants survive a single-PID kill and
|
|
962
|
+
// leak indefinitely (opg-microsoft/minions#710).
|
|
900
963
|
if (killPid != null) {
|
|
901
|
-
try { shared.
|
|
964
|
+
try { shared.killImmediate({ pid: killPid }); }
|
|
902
965
|
catch (e) { log('warn', 'managed-spawn removeManagedSpec: kill ' + killPid + ' failed: ' + e.message); }
|
|
903
966
|
}
|
|
904
967
|
}
|
|
@@ -1246,8 +1309,11 @@ function restartManagedSpec(name) {
|
|
|
1246
1309
|
healthcheck: existing.healthcheck || null,
|
|
1247
1310
|
};
|
|
1248
1311
|
// Kill the old PID before respawn (best-effort; outside of any lock).
|
|
1312
|
+
// Full process-tree kill (see removeManagedSpec above) — a single-PID kill
|
|
1313
|
+
// here would leave launcher descendants (e.g. qemu-system-x86_64 under an
|
|
1314
|
+
// Android emulator.exe) running after "restart" respawns a fresh launcher.
|
|
1249
1315
|
if (Number.isInteger(existing.pid) && existing.pid > 0) {
|
|
1250
|
-
try { shared.
|
|
1316
|
+
try { shared.killImmediate({ pid: existing.pid }); }
|
|
1251
1317
|
catch (e) { log('warn', 'restartManagedSpec: kill of old PID ' + existing.pid + ' failed: ' + e.message); }
|
|
1252
1318
|
}
|
|
1253
1319
|
const ctx = {
|
package/engine/qa-sessions.js
CHANGED
|
@@ -681,6 +681,39 @@ function markDone(id, patch) { return transitionSession(id, QA_SESSION_STATE.DON
|
|
|
681
681
|
function markFailed(id, patch) { return transitionSession(id, QA_SESSION_STATE.FAILED, patch); }
|
|
682
682
|
function markKilled(id, patch) { return transitionSession(id, QA_SESSION_STATE.KILLED, patch); }
|
|
683
683
|
|
|
684
|
+
// Issue #716 — a lifecycle completion hook (handleSetupComplete/
|
|
685
|
+
// handleDraftComplete/handleExecuteComplete) can race a human/engine action
|
|
686
|
+
// that already pushed the session to a terminal state (failed/done/killed) —
|
|
687
|
+
// e.g. a retried SETUP dispatch finally succeeds AFTER the first attempt's
|
|
688
|
+
// timeout already marked the session failed. Before this guard, that late
|
|
689
|
+
// completion fell straight into `transitionSession`, which throws on the
|
|
690
|
+
// illegal `failed -> drafting` (etc.) transition; the throw was swallowed by
|
|
691
|
+
// the caller's try/catch (engine/lifecycle.js) and logged as a bare warning,
|
|
692
|
+
// silently discarding real completed work (e.g. a working managed-spawn
|
|
693
|
+
// setup) with no trace for a human to act on. This guard intercepts BEFORE
|
|
694
|
+
// the throw, logs loudly, and writes an inbox note so the orphaned success
|
|
695
|
+
// is visible instead of vanishing into engine logs.
|
|
696
|
+
//
|
|
697
|
+
// Returns true when the session is already terminal (caller must return
|
|
698
|
+
// early without applying opts); false when the caller should proceed.
|
|
699
|
+
function _guardAlreadyTerminal(sessionId, session, phase, opts) {
|
|
700
|
+
if (!session || !TERMINAL_STATES.has(session.state)) return false;
|
|
701
|
+
const detail = `qa-sessions: ${phase} completion for session ${sessionId} arrived after the ` +
|
|
702
|
+
`session was already terminal (state=${session.state}). opts.success=${!!opts.success}. ` +
|
|
703
|
+
'This completion is NOT applied — the session stays ' + session.state + '.' +
|
|
704
|
+
(opts.success
|
|
705
|
+
? ' The underlying dispatch reported SUCCESS, so real completed work may now be orphaned' +
|
|
706
|
+
' — a human should inspect and manually resume/requeue the session if needed.'
|
|
707
|
+
: '');
|
|
708
|
+
log('warn', detail);
|
|
709
|
+
try {
|
|
710
|
+
// Lazy require (mirrors engine/live-checkout.js#_defaultWriteInboxNote) to
|
|
711
|
+
// sidestep any require-cycle between dispatch.js and qa-sessions.js.
|
|
712
|
+
require('./dispatch').writeInboxAlert(`qa-session-late-completion-${sessionId}`, detail);
|
|
713
|
+
} catch (e) { log('warn', `qa-sessions: writeInboxAlert for late completion ${sessionId} failed: ${e.message}`); }
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
|
|
684
717
|
// ── Work-item builders (pure) ──────────────────────────────────────────────
|
|
685
718
|
//
|
|
686
719
|
// Each builder returns a WI spec ready for mutateWorkItems().push + addToDispatch.
|
|
@@ -1014,6 +1047,7 @@ function queueSetup(sessionId, opts = {}) {
|
|
|
1014
1047
|
function handleSetupComplete(sessionId, opts = {}) {
|
|
1015
1048
|
const session = getSession(sessionId);
|
|
1016
1049
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
1050
|
+
if (_guardAlreadyTerminal(sessionId, session, 'SETUP', opts)) return null;
|
|
1017
1051
|
|
|
1018
1052
|
const isMulti = session.setupStatus && typeof session.setupStatus === 'object'
|
|
1019
1053
|
&& Object.keys(session.setupStatus).length > 1;
|
|
@@ -1123,6 +1157,9 @@ function handleSetupComplete(sessionId, opts = {}) {
|
|
|
1123
1157
|
function handleDraftComplete(sessionId, opts = {}) {
|
|
1124
1158
|
const session = getSession(sessionId);
|
|
1125
1159
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
1160
|
+
if (_guardAlreadyTerminal(sessionId, session, 'DRAFT', opts)) {
|
|
1161
|
+
return { nextState: session.state, queuedExecuteWi: null };
|
|
1162
|
+
}
|
|
1126
1163
|
if (!opts.success) {
|
|
1127
1164
|
markFailed(sessionId, {
|
|
1128
1165
|
failureClass: 'qa-session-draft-failed',
|
|
@@ -1170,6 +1207,7 @@ function handleDraftComplete(sessionId, opts = {}) {
|
|
|
1170
1207
|
function handleExecuteComplete(sessionId, opts = {}) {
|
|
1171
1208
|
const session = getSession(sessionId);
|
|
1172
1209
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
1210
|
+
if (_guardAlreadyTerminal(sessionId, session, 'EXECUTE', opts)) return session.state;
|
|
1173
1211
|
// qa-run terminal status (when known) trumps dispatch-level success — a
|
|
1174
1212
|
// passing assertion run with an exit-1 wrapper still reports a passed
|
|
1175
1213
|
// qa-run; we mark the session done. Conversely, a failed/errored qa-run
|
|
@@ -1305,6 +1343,29 @@ function summarizeSessionsForStatus() {
|
|
|
1305
1343
|
return { total: sessions.length, sig };
|
|
1306
1344
|
}
|
|
1307
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* Cheap summary of non-terminal ("active") sessions for the QA nav sidebar
|
|
1348
|
+
* badge (issue opg-microsoft/minions#717 — QA nav has no visibility signal).
|
|
1349
|
+
* Mirrors summarizeSessionsForStatus but excludes TERMINAL_STATES so the
|
|
1350
|
+
* count matches what a user cares about: sessions still in flight
|
|
1351
|
+
* (pending/spawning/drafting/awaiting-approval/executing), not the full
|
|
1352
|
+
* history of done/failed/killed records.
|
|
1353
|
+
*
|
|
1354
|
+
* @returns {{ active: number, sig: string }}
|
|
1355
|
+
*/
|
|
1356
|
+
function summarizeActiveSessionsForStatus() {
|
|
1357
|
+
const sessions = _readSessions();
|
|
1358
|
+
if (!Array.isArray(sessions) || sessions.length === 0) return { active: 0, sig: '' };
|
|
1359
|
+
let active = 0;
|
|
1360
|
+
let sig = '';
|
|
1361
|
+
for (const s of sessions) {
|
|
1362
|
+
if (!s || TERMINAL_STATES.has(s.state)) continue;
|
|
1363
|
+
active++;
|
|
1364
|
+
sig += (s.id || '') + ':' + (s.state || '') + ',';
|
|
1365
|
+
}
|
|
1366
|
+
return { active, sig };
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1308
1369
|
module.exports = {
|
|
1309
1370
|
// Constants
|
|
1310
1371
|
QA_SESSION_STATE,
|
|
@@ -1356,6 +1417,7 @@ module.exports = {
|
|
|
1356
1417
|
dismissSession,
|
|
1357
1418
|
// Status
|
|
1358
1419
|
summarizeSessionsForStatus,
|
|
1420
|
+
summarizeActiveSessionsForStatus,
|
|
1359
1421
|
// Internals (exposed for tests)
|
|
1360
1422
|
_isSafeSessionId,
|
|
1361
1423
|
};
|
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/engine.js
CHANGED
|
@@ -7115,19 +7115,30 @@ async function discoverFromPrs(config, project) {
|
|
|
7115
7115
|
const autoFixBuilds = !autoFixPaused && (config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds);
|
|
7116
7116
|
const autoFixConflicts = !autoFixPaused && (config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts);
|
|
7117
7117
|
|
|
7118
|
-
// Collect active PR dispatches to prevent simultaneous review+fix on same PR
|
|
7118
|
+
// Collect active PR dispatches to prevent simultaneous review+fix on same PR.
|
|
7119
|
+
// Issue #722: `meta.pr.id` alone is not proof the dispatch actually touches
|
|
7120
|
+
// the PR's branch — a tracking-only dispatch (e.g. a follow-up work item
|
|
7121
|
+
// whose `pr_followup.parent_pr_id` merely references the PR for context,
|
|
7122
|
+
// engine.js `dispatchLinkedPr` ~line 8551) carries `meta.pr` while running
|
|
7123
|
+
// on a completely unrelated `meta.branch`. Recording each dispatch's own
|
|
7124
|
+
// branch here lets the loop below only trip the guard on real git-resource
|
|
7125
|
+
// overlap instead of freezing all automation on the PR indefinitely.
|
|
7119
7126
|
const dispatch = getDispatch();
|
|
7120
|
-
const
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7127
|
+
const activePrDispatchBranches = new Map(); // canonicalPrId -> Set<normalized branch | null>
|
|
7128
|
+
for (const d of (dispatch.active || [])) {
|
|
7129
|
+
if (!d.meta?.pr?.id) continue;
|
|
7130
|
+
const dispatchProject = d.meta?.project?.name
|
|
7131
|
+
? (shared.resolveProjectSource(d.meta.project.name, shared.getProjects(config), { allowCentral: false }).project || d.meta.project)
|
|
7132
|
+
: (d.meta?.project || null);
|
|
7133
|
+
const canonicalId = shared.getCanonicalPrId(dispatchProject, d.meta.pr, d.meta.pr?.url || '');
|
|
7134
|
+
if (!canonicalId) continue;
|
|
7135
|
+
if (!activePrDispatchBranches.has(canonicalId)) activePrDispatchBranches.set(canonicalId, new Set());
|
|
7136
|
+
// `null` is a sentinel for "branch unknown" — legacy/defensive dispatches
|
|
7137
|
+
// with no meta.branch fall back to the old conservative (always-skip)
|
|
7138
|
+
// behavior rather than risk a false negative.
|
|
7139
|
+
activePrDispatchBranches.get(canonicalId).add(d.meta?.branch ? normalizePrBranch(d.meta.branch) : null);
|
|
7140
|
+
}
|
|
7141
|
+
const activePrIds = new Set(activePrDispatchBranches.keys());
|
|
7131
7142
|
|
|
7132
7143
|
for (const pr of prs) {
|
|
7133
7144
|
if (pr.status !== PR_STATUS.ACTIVE) continue;
|
|
@@ -7140,8 +7151,21 @@ async function discoverFromPrs(config, project) {
|
|
|
7140
7151
|
if (!shared.isPrCompatibleWithProject(project, pr, pr.url || '')) continue;
|
|
7141
7152
|
const prDisplayId = shared.getPrDisplayId(pr);
|
|
7142
7153
|
const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
|
|
7143
|
-
if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
|
|
7144
7154
|
const prBranchForMutex = resolvePrBranch(pr);
|
|
7155
|
+
if (activePrIds.has(prCanonicalId)) {
|
|
7156
|
+
const dispatchBranches = activePrDispatchBranches.get(prCanonicalId);
|
|
7157
|
+
const prOwnBranch = normalizePrBranch(prBranchForMutex);
|
|
7158
|
+
// Guard trips (skip PR) when: any active dispatch's branch is unknown
|
|
7159
|
+
// (conservative fallback), or the PR's own branch is unknown (can't
|
|
7160
|
+
// prove non-overlap), or a dispatch's branch actually matches the PR's
|
|
7161
|
+
// branch (real overlap). Otherwise every active dispatch referencing
|
|
7162
|
+
// this PR id is tracking-only on a different branch — let discovery
|
|
7163
|
+
// proceed.
|
|
7164
|
+
const realOverlap = !prOwnBranch
|
|
7165
|
+
|| [...dispatchBranches].some(b => b === null || b === prOwnBranch);
|
|
7166
|
+
if (realOverlap) continue; // Skip PRs with active dispatch (prevent race)
|
|
7167
|
+
log('info', `Race-guard: PR ${prDisplayId} (${prCanonicalId}) referenced by ${dispatchBranches.size} active dispatch(es) tracking-only on unrelated branch(es) — not blocking automation`);
|
|
7168
|
+
}
|
|
7145
7169
|
// Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
|
|
7146
7170
|
if (prBranchForMutex && isBranchActive(prBranchForMutex)) {
|
|
7147
7171
|
log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${prBranchForMutex} locked by another agent`);
|
|
@@ -8455,7 +8479,17 @@ function discoverFromWorkItems(config, project) {
|
|
|
8455
8479
|
shared.autoEnrollPrFromWorkItem(item, project, MINIONS_DIR);
|
|
8456
8480
|
} catch (e) { log('warn', `auto-enroll PR for ${item.id}: ${e.message}`); }
|
|
8457
8481
|
|
|
8458
|
-
|
|
8482
|
+
// Issue #716 — `item.skipPr` marks work items that never own a PR (QA
|
|
8483
|
+
// Session SETUP/DRAFT/EXECUTE WIs are the canonical example: `skipPr:
|
|
8484
|
+
// true`, `oneShot: true`). Their descriptions legitimately embed the
|
|
8485
|
+
// *target's* PR reference as prose/JSON (e.g. `Target: {"kind":"pr",
|
|
8486
|
+
// "prId":"..."}`), which the LOOSE getWorkItemPrRef text-scan (used by
|
|
8487
|
+
// resolveWorkItemPrRecord) misreads as "this work item is about that PR"
|
|
8488
|
+
// — resolving/attaching an unrelated PR record, corrupting branchName
|
|
8489
|
+
// resolution below, and (via stampWiPrRef/autoDispatchLiveValidationWi)
|
|
8490
|
+
// mislabeling the WI's own `_pr` field. skipPr WIs must never enter PR
|
|
8491
|
+
// context inference.
|
|
8492
|
+
const linkedPr = item.skipPr ? null : resolveWorkItemPrRecord(item, project);
|
|
8459
8493
|
const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
|
|
8460
8494
|
const prBranch = linkedPr?.branch || '';
|
|
8461
8495
|
const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2345",
|
|
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"
|
|
@@ -77,6 +77,14 @@ live instance:
|
|
|
77
77
|
project README "Run locally / Getting Started" section, a `Makefile` `dev`
|
|
78
78
|
target, or a docker-compose service that exposes an HTTP port. Pick the
|
|
79
79
|
smallest command that brings the app up and binds to a TCP port.
|
|
80
|
+
- **Native app / emulator-backed target (no HTTP surface)?** — e.g. an
|
|
81
|
+
Android Gradle/Kotlin app, an iOS simulator target. Do not force an HTTP
|
|
82
|
+
healthcheck onto it. Spawn only the long-lived environment dependency
|
|
83
|
+
(emulator/simulator/device boot) here and defer the one-shot
|
|
84
|
+
build+install+launch to DRAFT/EXECUTE; use a `command` healthcheck (e.g.
|
|
85
|
+
`adb shell getprop sys.boot_completed` polled for `^1$`) as the readiness
|
|
86
|
+
signal. See the "Native app / emulator-backed targets" section of the
|
|
87
|
+
`managed_spawn` block below for the full worked example.
|
|
80
88
|
3. **Write the managed-spawn sidecar** to
|
|
81
89
|
`agents/{{agent_id}}/managed-spawn.json` (relative to the Minions root)
|
|
82
90
|
with **exactly one** spec named **`{{managed_spawn_name}}`**. Use the JSON
|