@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.
@@ -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).
@@ -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
 
@@ -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:',
@@ -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 activePrIds = new Set(
7121
- (dispatch.active || [])
7122
- .filter(d => d.meta?.pr?.id)
7123
- .map(d => {
7124
- const dispatchProject = d.meta?.project?.name
7125
- ? (shared.resolveProjectSource(d.meta.project.name, shared.getProjects(config), { allowCentral: false }).project || d.meta.project)
7126
- : (d.meta?.project || null);
7127
- return shared.getCanonicalPrId(dispatchProject, d.meta.pr, d.meta.pr?.url || '');
7128
- })
7129
- .filter(Boolean)
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
- const linkedPr = resolveWorkItemPrRecord(item, project);
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.2344",
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