@yemi33/minions 0.1.2081 → 0.1.2083

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.
@@ -138,6 +138,11 @@ const LIMITS = {
138
138
  targetFieldMax: 500,
139
139
  projectMax: 64,
140
140
  summaryMax: 2000,
141
+ // W-mpq6xqzj000606d0 — multi-project QA Session: cap on co-services per
142
+ // session. Each entry is a separate SETUP WI fan-out, so the cap doubles as
143
+ // a guardrail against an operator queueing dozens of parallel dev-up
144
+ // services from a single form submit.
145
+ projectsMax: 5,
141
146
  };
142
147
 
143
148
  // Mirrors engine/qa-runbooks.js _isSafeId — kebab-case ≤64 chars, no leading/
@@ -286,6 +291,37 @@ function validateSpec(spec) {
286
291
  }
287
292
  }
288
293
 
294
+ // W-mpq6xqzj000606d0 — multi-project mode. `spec.projects` is the canonical
295
+ // field; `spec.project` (legacy single-string) is still accepted above and
296
+ // normalized into `projects` by createSession. Both may legitimately be
297
+ // empty/null = "central work-items target", but if BOTH are supplied they
298
+ // must agree (the dashboard back-compat wrapper sends just one). We don't
299
+ // hard-fail on duplication here — createSession picks projects[] first.
300
+ if (spec.projects !== undefined && spec.projects !== null) {
301
+ if (!Array.isArray(spec.projects)) {
302
+ errors.push('projects must be an array of strings when present');
303
+ } else if (spec.projects.length > LIMITS.projectsMax) {
304
+ errors.push(`projects exceeds ${LIMITS.projectsMax} entries`);
305
+ } else {
306
+ const seen = new Set();
307
+ for (const p of spec.projects) {
308
+ if (typeof p !== 'string' || p.length === 0) {
309
+ errors.push('projects entries must be non-empty strings');
310
+ break;
311
+ }
312
+ if (p.length > LIMITS.projectMax) {
313
+ errors.push(`projects entry exceeds ${LIMITS.projectMax} chars`);
314
+ break;
315
+ }
316
+ if (seen.has(p)) {
317
+ errors.push(`projects contains duplicate entry: ${p}`);
318
+ break;
319
+ }
320
+ seen.add(p);
321
+ }
322
+ }
323
+ }
324
+
289
325
  return { ok: errors.length === 0, errors };
290
326
  }
291
327
 
@@ -318,6 +354,19 @@ function createSession(spec) {
318
354
  throw err;
319
355
  }
320
356
 
357
+ // W-mpq6xqzj000606d0 — normalize legacy `project` and new `projects` into a
358
+ // single canonical `projects: string[] | null` (null = central). When both
359
+ // are present, `projects` wins. Empty string in legacy `project` is treated
360
+ // as central (matches the dashboard input convention).
361
+ let projects = null;
362
+ if (Array.isArray(spec.projects) && spec.projects.length > 0) {
363
+ projects = spec.projects.slice();
364
+ } else if (typeof spec.project === 'string' && spec.project.length > 0) {
365
+ projects = [spec.project];
366
+ }
367
+ const primaryProject = projects && projects.length > 0 ? projects[0] : null;
368
+ const coServices = projects && projects.length > 1 ? projects.slice(1) : [];
369
+
321
370
  const id = 'qas-' + uid();
322
371
  const now = ts();
323
372
  const session = {
@@ -333,8 +382,25 @@ function createSession(spec) {
333
382
  logs: !!(spec.capture && spec.capture.logs),
334
383
  },
335
384
  runner: spec.runner || null,
336
- project: spec.project || null,
385
+ // Canonical multi-project field. `project` (legacy single-string) is
386
+ // mirrored for back-compat readers (qa.js card render, /api/status,
387
+ // older lifecycle hooks); new code keys off `projects`.
388
+ projects,
389
+ project: primaryProject,
337
390
  },
391
+ // W-mpq6xqzj000606d0 — denormalized fast-path fields used by the dispatch
392
+ // chain (queueSetup / handleSetupComplete) without re-deriving from
393
+ // spec.projects on every call.
394
+ primaryProject,
395
+ coServices,
396
+ // Per-project SETUP completion map. Only populated when projects.length >
397
+ // 1 (multi-project fan-out). Single-project sessions skip this map and
398
+ // use session.state directly.
399
+ setupStatus: null,
400
+ // primaryWiPath is stamped at queueSetup time so handleSetupComplete can
401
+ // queue DRAFT on the right work-items file without re-resolving the
402
+ // project through dashboard config.
403
+ primaryWiPath: null,
338
404
  // Per-phase WI links — back-filled by setSessionWorkItem when the
339
405
  // dashboard endpoint or lifecycle hook queues the next phase.
340
406
  workItems: { setup: null, draft: null, execute: null },
@@ -492,7 +558,10 @@ function transitionSession(id, toState, patch = {}) {
492
558
  if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
493
559
  // Whitelist mutable fields to keep transitionSession from rewriting
494
560
  // immutable spec/createdAt fields by mistake.
495
- for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth']) {
561
+ // W-mpq6xqzj000606d0 added `setupStatus` and `primaryWiPath` so the
562
+ // multi-project fan-in handler can stamp per-project completion state
563
+ // through the same locked path used by every other mutation.
564
+ for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', 'primaryWiPath']) {
496
565
  if (Object.prototype.hasOwnProperty.call(patch, field)) {
497
566
  session[field] = patch[field];
498
567
  }
@@ -524,8 +593,24 @@ function markKilled(id, patch) { return transitionSession(id, QA_SESSION_STATE.K
524
593
  // pulling dispatch into the unit test path. They're also called by the
525
594
  // lifecycle chain helpers below to queue the next phase.
526
595
 
527
- function _baseWorkItem(session, phase, { title, description, project }) {
596
+ function _baseWorkItem(session, phase, { title, description, project, primary, coServices, primaryProject }) {
528
597
  const wiId = 'W-' + uid();
598
+ // W-mpq6xqzj000606d0 — multi-project fan-out: SETUP work items carry
599
+ // `primary: boolean` + `coServices: string[]` + `primaryProject: string` on
600
+ // meta.qaSession. Single-project sessions default to primary=true /
601
+ // coServices=[] so existing single-target prompts render unchanged.
602
+ // DRAFT/EXECUTE always omit these (primary-only) — pass `primary:
603
+ // undefined` to keep meta clean.
604
+ const qaMeta = {
605
+ target: session.spec.target,
606
+ flowsRaw: session.spec.flowsRaw,
607
+ mode: session.spec.mode,
608
+ capture: session.spec.capture,
609
+ runner: session.spec.runner,
610
+ };
611
+ if (primary !== undefined) qaMeta.primary = !!primary;
612
+ if (Array.isArray(coServices)) qaMeta.coServices = coServices.slice();
613
+ if (primaryProject) qaMeta.primaryProject = String(primaryProject);
529
614
  const wi = {
530
615
  id: wiId,
531
616
  title,
@@ -543,13 +628,7 @@ function _baseWorkItem(session, phase, { title, description, project }) {
543
628
  meta: {
544
629
  sessionId: session.id,
545
630
  sessionPhase: phase,
546
- qaSession: {
547
- target: session.spec.target,
548
- flowsRaw: session.spec.flowsRaw,
549
- mode: session.spec.mode,
550
- capture: session.spec.capture,
551
- runner: session.spec.runner,
552
- },
631
+ qaSession: qaMeta,
553
632
  playbook: 'qa-session-' + phase,
554
633
  },
555
634
  };
@@ -562,21 +641,34 @@ function _baseWorkItem(session, phase, { title, description, project }) {
562
641
  * Build the SETUP work item. The agent resolves the target, sets up a
563
642
  * worktree, and writes a managed-spawn.json sidecar. Engine then spawns the
564
643
  * service and the healthcheck gate drives the next transition.
644
+ *
645
+ * W-mpq6xqzj000606d0 — When the session has multiple projects (fan-out),
646
+ * `primary` distinguishes the orchestrator WI (true; carries the full
647
+ * coServices list) from each co-service WI (false; coServices === []). The
648
+ * session's DRAFT phase keys off the primary's wiPath.
565
649
  */
566
- function buildSetupWorkItem(session, { project } = {}) {
650
+ function buildSetupWorkItem(session, { project, primary, coServices, primaryProject } = {}) {
651
+ const isPrimary = primary === undefined ? true : !!primary;
652
+ const co = Array.isArray(coServices) ? coServices.slice() : [];
567
653
  return _baseWorkItem(session, SESSION_PHASE.SETUP, {
568
- title: `QA Session SETUP: ${_summarizeTarget(session.spec.target)}`,
654
+ title: `QA Session SETUP: ${_summarizeTarget(session.spec.target)}${isPrimary && co.length ? ` (primary +${co.length} co-services)` : (!isPrimary ? ' (co-service)' : '')}`,
569
655
  description: [
570
656
  `QA Session ${session.id} — SETUP phase.`,
571
657
  '',
572
658
  `Target: ${JSON.stringify(session.spec.target)}`,
573
659
  `Flows: ${session.spec.flowsRaw}`,
660
+ isPrimary
661
+ ? (co.length ? `Role: PRIMARY (co-services: ${co.join(', ')})` : 'Role: PRIMARY (single-project session)')
662
+ : 'Role: CO-SERVICE (dev-up only; primary owns the test orchestration)',
574
663
  '',
575
664
  'Resolve the target to a worktree, inspect the codebase for the dev-up command,',
576
- `and write \`agents/<your-id>/managed-spawn.json\` with name=\`${session.managedSpawnName}\`.`,
665
+ `and write \`agents/<your-id>/managed-spawn.json\` with name=\`${session.managedSpawnName}${isPrimary ? '' : '-' + project}\`.`,
577
666
  'See `playbooks/qa-session-setup.md` for the full contract.',
578
667
  ].join('\n'),
579
668
  project,
669
+ primary: isPrimary,
670
+ coServices: co,
671
+ primaryProject: primaryProject || (isPrimary ? project : undefined),
580
672
  });
581
673
  }
582
674
 
@@ -661,6 +753,23 @@ function _summarizeTarget(target) {
661
753
  }
662
754
  }
663
755
 
756
+ /**
757
+ * W-mpq6xqzj000606d0 — Back-compat shim: return the canonical projects[]
758
+ * array for a session, regardless of whether the record was written under
759
+ * the legacy single-`project` schema or the new `projects[]` schema. Returns
760
+ * `[]` for "central" sessions (no project) so callers can simply check
761
+ * `length > 1` for multi-project mode.
762
+ */
763
+ function _resolveSessionProjects(session) {
764
+ if (!session || typeof session !== 'object') return [];
765
+ const fromArr = session.spec && Array.isArray(session.spec.projects) ? session.spec.projects : null;
766
+ if (fromArr && fromArr.length > 0) return fromArr.slice();
767
+ const fromStr = session.spec && typeof session.spec.project === 'string' && session.spec.project.length > 0
768
+ ? session.spec.project
769
+ : null;
770
+ return fromStr ? [fromStr] : [];
771
+ }
772
+
664
773
  // ── Cross-WI dispatch chain helpers ─────────────────────────────────────────
665
774
  //
666
775
  // These are the integration entry points the lifecycle hook + dashboard
@@ -693,26 +802,113 @@ function _queueWorkItem(wi, wiPath) {
693
802
 
694
803
  /**
695
804
  * Called by the POST /api/qa/session handler immediately after createSession.
696
- * Validates pending → spawning, queues the SETUP WI, returns the queued WI id.
805
+ * Validates pending → spawning, queues the SETUP WI(s), returns the queued
806
+ * primary WI id (back-compat).
807
+ *
808
+ * W-mpq6xqzj000606d0 — multi-project fan-out. Two calling shapes are
809
+ * accepted:
810
+ * 1. Legacy single-target: `{ wiPath, project }` (project may be null for
811
+ * central). Builds and queues one SETUP WI exactly like before.
812
+ * 2. Multi-target: `{ resolvedTargets: [{ project, wiPath }, ...] }`. The
813
+ * FIRST entry is treated as the primary (drives DRAFT/EXECUTE later);
814
+ * the rest are co-services. One SETUP WI is queued per entry, and
815
+ * `session.setupStatus` is initialized with a `pending` entry per
816
+ * project so handleSetupComplete can fan-in.
697
817
  *
698
818
  * @param {string} sessionId
699
819
  * @param {object} opts
700
- * @param {string} opts.wiPath - resolved work-items path (central or per-project)
701
- * @param {string} [opts.project] - project name (set on the WI)
820
+ * @param {string} [opts.wiPath] - legacy single-target wiPath
821
+ * @param {string} [opts.project] - legacy single-target project name
822
+ * @param {Array<{project:?string, wiPath:string}>} [opts.resolvedTargets]
823
+ * @returns {string} the queued PRIMARY SETUP WI id
702
824
  */
703
- function queueSetup(sessionId, { wiPath, project } = {}) {
704
- if (!_isNonEmptyString(wiPath)) throw new Error('qa-sessions: queueSetup requires wiPath');
825
+ function queueSetup(sessionId, opts = {}) {
826
+ let targets = Array.isArray(opts.resolvedTargets) ? opts.resolvedTargets.slice() : null;
827
+ if (!targets || targets.length === 0) {
828
+ if (!_isNonEmptyString(opts.wiPath)) {
829
+ throw new Error('qa-sessions: queueSetup requires wiPath or resolvedTargets');
830
+ }
831
+ targets = [{ project: opts.project || null, wiPath: opts.wiPath }];
832
+ }
833
+ // Defensive: each target needs a wiPath. project may be null for central.
834
+ for (const t of targets) {
835
+ if (!t || !_isNonEmptyString(t.wiPath)) {
836
+ throw new Error('qa-sessions: queueSetup target missing wiPath');
837
+ }
838
+ }
839
+ if (targets.length > LIMITS.projectsMax) {
840
+ throw new Error(`qa-sessions: queueSetup target count exceeds ${LIMITS.projectsMax}`);
841
+ }
842
+
705
843
  const session = getSession(sessionId);
706
844
  if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
845
+
846
+ const primaryTarget = targets[0];
847
+ const coServiceProjects = targets.slice(1).map(t => t.project || null).filter(Boolean);
848
+ const isMulti = targets.length > 1;
849
+
850
+ // Initialize setupStatus + primaryWiPath BEFORE transitioning so the
851
+ // fan-in handler sees the full project list even on a racing completion.
852
+ // Single-project sessions skip the map to keep blast radius minimal.
853
+ const setupStatusPatch = isMulti
854
+ ? targets.reduce((acc, t) => {
855
+ const key = t.project || '__central__';
856
+ acc[key] = { state: 'pending', wiId: null, error: null, completedAt: null };
857
+ return acc;
858
+ }, {})
859
+ : null;
860
+
707
861
  // transitionSession enforces pending → spawning. If the session is already
708
862
  // past pending (createSession + queueSetup called twice), the throw bubbles
709
863
  // up to the dashboard handler and surfaces as a 409 — better than silently
710
864
  // double-queueing.
711
- markSpawning(sessionId);
712
- const wi = buildSetupWorkItem(session, { project: project || session.spec.project || null });
713
- _queueWorkItem(wi, wiPath);
714
- setSessionWorkItem(sessionId, SESSION_PHASE.SETUP, wi.id);
715
- return wi.id;
865
+ markSpawning(sessionId, {
866
+ setupStatus: setupStatusPatch,
867
+ primaryWiPath: primaryTarget.wiPath,
868
+ });
869
+
870
+ // Build + queue one WI per target. Primary is index 0; coServices is the
871
+ // full list of OTHER project names. Each SETUP WI gets its own meta so
872
+ // the agent prompt clearly states its role.
873
+ let primaryWiId = null;
874
+ const builtWiIds = {};
875
+ for (let i = 0; i < targets.length; i++) {
876
+ const t = targets[i];
877
+ const isPrimary = i === 0;
878
+ const wi = buildSetupWorkItem(session, {
879
+ project: t.project || null,
880
+ primary: isPrimary,
881
+ coServices: isPrimary ? coServiceProjects : [],
882
+ primaryProject: primaryTarget.project || null,
883
+ });
884
+ _queueWorkItem(wi, t.wiPath);
885
+ builtWiIds[t.project || '__central__'] = wi.id;
886
+ if (isPrimary) primaryWiId = wi.id;
887
+ }
888
+ // Stamp the primary WI id into workItems.setup (back-compat: dashboard
889
+ // cards index off this single field).
890
+ setSessionWorkItem(sessionId, SESSION_PHASE.SETUP, primaryWiId);
891
+
892
+ // Back-fill each per-project setupStatus entry with its wiId for multi-
893
+ // project sessions.
894
+ if (isMulti) {
895
+ const updated = getSession(sessionId);
896
+ const nextStatus = { ...(updated.setupStatus || setupStatusPatch) };
897
+ for (const [key, wiId] of Object.entries(builtWiIds)) {
898
+ if (nextStatus[key]) nextStatus[key] = { ...nextStatus[key], wiId };
899
+ }
900
+ mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
901
+ if (!Array.isArray(sessions)) sessions = [];
902
+ const s = sessions.find(x => x && x.id === sessionId);
903
+ if (s) {
904
+ s.setupStatus = nextStatus;
905
+ s.updatedAt = ts();
906
+ }
907
+ return sessions;
908
+ }, { defaultValue: [] });
909
+ }
910
+
911
+ return primaryWiId;
716
912
  }
717
913
 
718
914
  /**
@@ -721,36 +917,113 @@ function queueSetup(sessionId, { wiPath, project } = {}) {
721
917
  * marking the dispatch successful), so we advance to drafting and queue the
722
918
  * DRAFT WI. On failure we record the failureClass and mark the session failed.
723
919
  *
920
+ * W-mpq6xqzj000606d0 — fan-in for multi-project sessions. When
921
+ * `session.setupStatus` is populated (length > 1 targets queued), each
922
+ * completion updates one entry. The session only advances to drafting once
923
+ * ALL entries are `success`. Any failure marks the entire session failed
924
+ * with a per-project error map encoded into session.error.
925
+ *
724
926
  * @param {string} sessionId
725
927
  * @param {object} opts
726
928
  * @param {boolean} opts.success
727
- * @param {string} [opts.wiPath] - required when success=true
728
- * @param {string} [opts.project]
929
+ * @param {string} [opts.wiPath] - required when success=true (single-project) OR for the PRIMARY target on multi-project DRAFT queueing
930
+ * @param {string} [opts.project] - which project the completing WI was for (multi-project routing key)
729
931
  * @param {string} [opts.failureClass]
730
932
  * @param {string} [opts.reason]
731
- * @returns {string|null} the queued DRAFT WI id on success, null on failure
933
+ * @returns {string|null} the queued DRAFT WI id on success (single OR last-to-complete in multi), null otherwise
732
934
  */
733
935
  function handleSetupComplete(sessionId, opts = {}) {
734
936
  const session = getSession(sessionId);
735
937
  if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
736
- if (opts.success) {
737
- if (!_isNonEmptyString(opts.wiPath)) {
738
- throw new Error('qa-sessions: handleSetupComplete success requires wiPath');
938
+
939
+ const isMulti = session.setupStatus && typeof session.setupStatus === 'object'
940
+ && Object.keys(session.setupStatus).length > 1;
941
+
942
+ // ── Single-project fast path: original behavior, untouched semantics ──
943
+ if (!isMulti) {
944
+ if (opts.success) {
945
+ if (!_isNonEmptyString(opts.wiPath)) {
946
+ throw new Error('qa-sessions: handleSetupComplete success requires wiPath');
947
+ }
948
+ markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
949
+ const updated = getSession(sessionId);
950
+ const wi = buildDraftWorkItem(updated, { project: opts.project || updated.spec.project || null });
951
+ _queueWorkItem(wi, opts.wiPath);
952
+ setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
953
+ return wi.id;
739
954
  }
740
- markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
741
- // Re-read to pick up the state change for the DRAFT WI builder.
742
- const updated = getSession(sessionId);
743
- const wi = buildDraftWorkItem(updated, { project: opts.project || updated.spec.project || null });
744
- _queueWorkItem(wi, opts.wiPath);
745
- setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
746
- return wi.id;
955
+ markFailed(sessionId, {
956
+ failureClass: opts.failureClass || 'qa-session-setup-failed',
957
+ error: opts.reason || null,
958
+ summary: opts.reason || 'SETUP phase failed',
959
+ });
960
+ return null;
747
961
  }
748
- markFailed(sessionId, {
749
- failureClass: opts.failureClass || 'qa-session-setup-failed',
750
- error: opts.reason || null,
751
- summary: opts.reason || 'SETUP phase failed',
752
- });
753
- return null;
962
+
963
+ // ── Multi-project fan-in path ─────────────────────────────────────────
964
+ const project = opts.project || null;
965
+ const key = project || '__central__';
966
+
967
+ // Update this project's entry under lock. Capture the merged map so we
968
+ // can decide on a transition without re-reading.
969
+ let mergedStatus = null;
970
+ mutateJsonFileLocked(qaSessionsPath(), (sessions) => {
971
+ if (!Array.isArray(sessions)) sessions = [];
972
+ const s = sessions.find(x => x && x.id === sessionId);
973
+ if (!s || !s.setupStatus) return sessions;
974
+ if (!s.setupStatus[key]) {
975
+ // Unknown project — log via error rather than crashing the lifecycle
976
+ // hook. Treat as if the WI for that project failed so the session
977
+ // doesn't stall waiting for an entry that will never arrive.
978
+ s.setupStatus[key] = { state: 'pending', wiId: null, error: null, completedAt: null };
979
+ }
980
+ s.setupStatus[key] = {
981
+ ...s.setupStatus[key],
982
+ state: opts.success ? 'success' : 'failed',
983
+ error: opts.success ? null : (opts.reason || opts.failureClass || 'unknown'),
984
+ completedAt: ts(),
985
+ };
986
+ s.updatedAt = ts();
987
+ mergedStatus = JSON.parse(JSON.stringify(s.setupStatus));
988
+ return sessions;
989
+ }, { defaultValue: [] });
990
+
991
+ if (!mergedStatus) return null;
992
+
993
+ const entries = Object.entries(mergedStatus);
994
+ const stillPending = entries.some(([, v]) => v.state === 'pending');
995
+ const anyFailed = entries.some(([, v]) => v.state === 'failed');
996
+
997
+ if (stillPending) {
998
+ // Wait for sibling WIs to report in. No state transition yet.
999
+ return null;
1000
+ }
1001
+
1002
+ if (anyFailed) {
1003
+ const errors = entries
1004
+ .filter(([, v]) => v.state === 'failed')
1005
+ .map(([k, v]) => ({ project: k === '__central__' ? null : k, reason: v.error }));
1006
+ markFailed(sessionId, {
1007
+ failureClass: 'qa-session-setup-failed',
1008
+ error: JSON.stringify(errors),
1009
+ summary: `SETUP phase failed for ${errors.length}/${entries.length} project(s)`,
1010
+ });
1011
+ return null;
1012
+ }
1013
+
1014
+ // All success → advance to drafting and queue DRAFT on the PRIMARY's
1015
+ // wiPath (captured at queueSetup time).
1016
+ const fresh = getSession(sessionId);
1017
+ const draftWiPath = fresh && fresh.primaryWiPath;
1018
+ if (!_isNonEmptyString(draftWiPath)) {
1019
+ throw new Error('qa-sessions: handleSetupComplete multi-project missing session.primaryWiPath');
1020
+ }
1021
+ markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
1022
+ const updated = getSession(sessionId);
1023
+ const wi = buildDraftWorkItem(updated, { project: updated.primaryProject || updated.spec.project || null });
1024
+ _queueWorkItem(wi, draftWiPath);
1025
+ setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
1026
+ return wi.id;
754
1027
  }
755
1028
 
756
1029
  /**
@@ -1005,4 +1278,5 @@ module.exports = {
1005
1278
  summarizeSessionsForStatus,
1006
1279
  // Internals (exposed for tests)
1007
1280
  _isSafeSessionId,
1281
+ _resolveSessionProjects,
1008
1282
  };
package/engine.js CHANGED
@@ -5200,6 +5200,23 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
5200
5200
  .join(',')
5201
5201
  : '',
5202
5202
  session_mode: (item.meta && item.meta.qaSession && item.meta.qaSession.mode) || '',
5203
+ // W-mpq6xqzj000606d0 — Multi-project QA Session fan-out vars. SETUP fan-out
5204
+ // queues one WI per project with meta.qaSession.primary boolean +
5205
+ // coServices array (set by qa-sessions._baseWorkItem). Surface them here as
5206
+ // template vars so the SETUP playbook can branch on {{role}} and so the
5207
+ // primary's prompt sees the canonical primary_project + co_services_json
5208
+ // it needs to poll each co-service's managed-spawn health before
5209
+ // finalizing. Single-project sessions never set qaSession.primary, so all
5210
+ // three vars resolve to empty strings (filtered via PLAYBOOK_OPTIONAL_VARS).
5211
+ role: (item.meta && item.meta.qaSession && typeof item.meta.qaSession.primary === 'boolean'
5212
+ ? (item.meta.qaSession.primary ? 'primary' : 'co-service')
5213
+ : ''),
5214
+ primary_project: (item.meta && item.meta.qaSession && item.meta.qaSession.primaryProject)
5215
+ ? String(item.meta.qaSession.primaryProject)
5216
+ : '',
5217
+ co_services_json: (item.meta && item.meta.qaSession && Array.isArray(item.meta.qaSession.coServices)
5218
+ ? JSON.stringify(item.meta.qaSession.coServices)
5219
+ : ''),
5203
5220
  // P-f9a2e1b4 — Runner adapter briefs. The DRAFT playbook consumes
5204
5221
  // {{runner_brief}} (runner.generateBrief() output); EXECUTE consumes
5205
5222
  // {{runner_execute_brief}} (runner.executeBrief() output) plus
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2081",
3
+ "version": "0.1.2083",
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"
@@ -24,9 +24,41 @@ A user asked Minions to QA the following target and flows:
24
24
  - **Runner hint (optional explicit runner):** `{{runner_hint}}`
25
25
  - **Capture:** `{{capture}}`
26
26
  - **Mode:** `{{session_mode}}`
27
+ - **Role (multi-project fan-out):** `{{role}}` *(empty = single-project session, behave as if `primary`)*
28
+ - **Primary project (multi-project fan-out):** `{{primary_project}}`
29
+ - **Co-services JSON (multi-project fan-out):** `{{co_services_json}}`
27
30
 
28
31
  {{additional_context}}
29
32
 
33
+ ## Multi-project fan-out (W-mpq6xqzj000606d0)
34
+
35
+ When the user picks **multiple projects** from the QA Session form, the engine
36
+ queues one SETUP work item **per project** in parallel. Each WI sees the same
37
+ target/flows/managed_spawn_name, but with a different `{{role}}` value:
38
+
39
+ - **`{{role}} == "primary"`** *(or empty for single-project sessions)*: you are
40
+ the orchestrator. You own the canonical `{{managed_spawn_name}}` (no suffix)
41
+ and your work item's wiPath is the one that gets the DRAFT phase queued
42
+ on it once **all** SETUP WIs succeed. If `{{co_services_json}}` is non-empty
43
+ (e.g. `["api","worker"]`), the DRAFT and EXECUTE agents will poll each of
44
+ those co-services' managed-spawn health (name = `{{managed_spawn_name}}-<project>`)
45
+ via `/api/managed-processes/by-name` before treating the system as ready.
46
+ - **`{{role}} == "co-service"`**: you are a supporting service (DB, API,
47
+ worker, etc.). Your managed-spawn `name` MUST be `{{managed_spawn_name}}-<your-project>`
48
+ (the engine appends `-<project>` automatically when computing the spec name).
49
+ You do **not** write tests, do not queue DRAFT — just stand up your service,
50
+ declare its healthcheck, and exit clean. The primary's DRAFT/EXECUTE will
51
+ reach you over the network (use a deterministic port; document it in
52
+ `managed-spawn.json` `ports[]`).
53
+
54
+ Any co-service SETUP that fails (sidecar invalid, healthcheck miss, build
55
+ error) **fails the entire session** with `failure_class: 'qa-session-setup-failed'`
56
+ and a per-project error JSON in `session.error`. The primary's WI does NOT
57
+ get the DRAFT phase queued in that case.
58
+
59
+ For single-project sessions (`{{co_services_json}}` empty or `[]`), ignore
60
+ this section — the original single-WI flow applies unchanged.
61
+
30
62
  ## What "qa-session-setup" means
31
63
 
32
64
  A `qa-session-setup` task is the **first** of three chained work items the
@@ -186,25 +186,33 @@ When the user describes a UI/E2E flow they want validated against a *live, runni
186
186
  - `mode` — `"confirm"` (default — pauses at `awaiting-approval` so the user can review the drafted test before EXECUTE fires) or `"auto"` (chains straight from DRAFT to EXECUTE). Pick `"confirm"` unless the user said "just run it" / "no review needed" / "auto".
187
187
  - `capture` — optional `{ video?: bool, screenshots?: bool, logs?: bool }`. Default is everything false. Set what the user asked for.
188
188
  - `runner` — optional kebab-case name to force a specific runner (`"playwright"`, `"maestro"`, or a plugin). Omit to let the engine auto-detect (Maestro wins when the project has `.maestro/`; Playwright is the safe default).
189
- - `project` — REQUIRED when multiple projects are configured (mirrors `/api/work-items`). Omit for the central path.
189
+ - `project` — REQUIRED (legacy single-project form) when multiple projects are configured (mirrors `/api/work-items`). Omit for the central path. **Multi-project form (W-mpq6xqzj000606d0):** pass `projects: string[]` (≤5) instead of `project` to QA across multiple services in parallel. The first entry is the **primary** (owns DRAFT/EXECUTE and the canonical managed-spawn name); the rest are **co-services** (each gets a SETUP work item that stands up its dev-up command, named `qa-session-<id>-<project>`). All co-services must pass first-healthcheck before DRAFT fires; any failure fails the whole session. Use this when the user says "QA the api+worker against PR #X" or "smoke test the full stack on `develop`".
190
190
 
191
- **Worked example — PR target, confirm mode (default):**
191
+ **Worked example — PR target, confirm mode (default), single project:**
192
192
  ```
193
193
  curl -s -X POST http://localhost:{{dashboard_port}}/api/qa/session \
194
194
  -H 'Content-Type: application/json' \
195
195
  -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
196
- -d '{"target":{"kind":"pr","prId":"github:yemi33/MyApp#1234"},"flowsRaw":"Open the homepage, click Login, enter test@example.com / hunter2, and verify the dashboard renders with the user'\''s name in the header.","mode":"confirm","capture":{"screenshots":true,"logs":true},"project":"MyApp"}'
196
+ -d '{"target":{"kind":"pr","prId":"github:yemi33/MyApp#1234"},"flowsRaw":"Open the homepage, click Login, enter test@example.com / hunter2, and verify the dashboard renders with the user'\''s name in the header.","mode":"confirm","capture":{"screenshots":true,"logs":true},"projects":["MyApp"]}'
197
197
  ```
198
198
 
199
- **Worked example — current worktree, auto mode, video capture:**
199
+ **Worked example — current worktree, auto mode, video capture, single project:**
200
200
  ```
201
201
  curl -s -X POST http://localhost:{{dashboard_port}}/api/qa/session \
202
202
  -H 'Content-Type: application/json' \
203
203
  -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
204
- -d '{"target":{"kind":"current"},"flowsRaw":"Add three items to the cart, go to checkout, complete the payment form with the Stripe test card, and verify the success page.","mode":"auto","capture":{"video":true,"screenshots":true},"project":"MyApp"}'
204
+ -d '{"target":{"kind":"current"},"flowsRaw":"Add three items to the cart, go to checkout, complete the payment form with the Stripe test card, and verify the success page.","mode":"auto","capture":{"video":true,"screenshots":true},"projects":["MyApp"]}'
205
205
  ```
206
206
 
207
- **Response:** `{ sessionId, state: "spawning", setupWorkItemId, managedSpawnName }`. Tell the user the session id so they can watch it at `/qa` and steer it via the `/approve` (run the drafted test), `/edit` (re-draft with feedback), `/dismiss` (accept the draft without running), `/cancel` (give up), or `/kill` (cancel + tear down the managed-spawn) endpoints listed in `GET /api/routes`.
207
+ **Worked example multi-project fan-out (primary + 2 co-services):**
208
+ ```
209
+ curl -s -X POST http://localhost:{{dashboard_port}}/api/qa/session \
210
+ -H 'Content-Type: application/json' \
211
+ -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
212
+ -d '{"target":{"kind":"branch","branch":"develop"},"flowsRaw":"Place an order on the storefront and verify it shows up in the admin panel within 5 seconds.","mode":"confirm","capture":{"video":true,"logs":true},"projects":["storefront","api","worker"]}'
213
+ ```
214
+
215
+ **Response:** `{ sessionId, state: "spawning", setupWorkItemId, managedSpawnName, projects? }`. `setupWorkItemId` is the **primary's** WI id; `projects` is the canonical array (single-project sessions omit it). Tell the user the session id so they can watch it at `/qa` and steer it via the `/approve` (run the drafted test), `/edit` (re-draft with feedback), `/dismiss` (accept the draft without running), `/cancel` (give up), or `/kill` (cancel + tear down the managed-spawn) endpoints listed in `GET /api/routes`.
208
216
 
209
217
  **Do not also dispatch a `/api/work-items` `implement` or `test` for the same QA request.** The QA Session pipeline owns its own SETUP → DRAFT → EXECUTE work items end-to-end; firing a parallel work-item is the same double-dispatch class that the "Never both" rule above forbids. If the user asks for both a QA pass AND a code change, do them as two separate, sequential calls — QA Session for the behavioural check, work-item for the fix.
210
218