@yemi33/minions 0.1.2344 → 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/docs/managed-spawn.md +27 -0
- package/engine/lifecycle.js +10 -0
- package/engine/managed-spawn.js +39 -0
- package/engine/qa-sessions.js +38 -0
- package/engine.js +48 -14
- package/package.json +1 -1
- package/playbooks/qa-session-setup.md +8 -0
package/docs/managed-spawn.md
CHANGED
|
@@ -104,6 +104,33 @@ 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
|
+
|
|
107
134
|
### Non-PATH native launcher binaries (e.g. Android SDK's `emulator.exe` / `adb.exe`)
|
|
108
135
|
|
|
109
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).
|
package/engine/lifecycle.js
CHANGED
|
@@ -6789,6 +6789,16 @@ function autoDispatchLiveValidationWi(meta, config) {
|
|
|
6789
6789
|
// "Validate: Validate: ..." cascade (W-mr9u39az000db5c2 / issue #708).
|
|
6790
6790
|
if (item.meta && item.meta.liveValidationFor) return;
|
|
6791
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
|
+
|
|
6792
6802
|
const projectName = meta?.project?.name;
|
|
6793
6803
|
if (!projectName) return;
|
|
6794
6804
|
|
package/engine/managed-spawn.js
CHANGED
|
@@ -694,6 +694,45 @@ function buildManagedSpawnHint(opts) {
|
|
|
694
694
|
'',
|
|
695
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.',
|
|
696
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
|
+
'',
|
|
697
736
|
'### Verify before exit',
|
|
698
737
|
'',
|
|
699
738
|
'After you write the file, query the engine to confirm acceptance:',
|
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
|
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
|