@yemi33/minions 0.1.1709 → 0.1.1711

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1711 (2026-05-04)
4
+
5
+ ### Other
6
+ - Ensure meetings advance after terminal outcomes
7
+
8
+ ## 0.1.1710 (2026-05-04)
9
+
10
+ ### Other
11
+ - Fix meeting lifecycle edge cases
12
+
3
13
  ## 0.1.1709 (2026-05-04)
4
14
 
5
15
  ### Fixes
@@ -969,7 +969,7 @@ async function ccExecuteAction(action, targetTabId) {
969
969
  status.style.color = 'var(--green)';
970
970
  }
971
971
  ccAddMessage('action', status.outerHTML, false, targetTabId);
972
- if (['dispatch','fix','implement','explore','review','test'].includes(action.type)) wakeEngine();
972
+ if (['dispatch','fix','implement','explore','review','test','create-meeting'].includes(action.type)) wakeEngine();
973
973
  refresh();
974
974
  return;
975
975
  }
@@ -1175,9 +1175,10 @@ async function ccExecuteAction(action, targetTabId) {
1175
1175
  break;
1176
1176
  }
1177
1177
  case 'create-meeting': {
1178
+ var meetingParticipants = (Array.isArray(action.participants) && action.participants.length > 0) ? action.participants : (action.agents || []);
1178
1179
  var res6 = await fetch('/api/meetings', {
1179
1180
  method: 'POST', headers: { 'Content-Type': 'application/json' },
1180
- body: JSON.stringify({ title: action.title, agenda: action.agenda, participants: action.agents, rounds: action.rounds, project: action.project })
1181
+ body: JSON.stringify({ title: action.title, agenda: action.agenda, participants: meetingParticipants, rounds: action.rounds, project: action.project })
1181
1182
  });
1182
1183
  if (!res6.ok) { var d6 = await res6.json().catch(function() { return {}; }); throw new Error(d6.error || 'Meeting create failed'); }
1183
1184
  var d6r = await res6.json();
package/dashboard.js CHANGED
@@ -1539,6 +1539,27 @@ function _parseWatchInterval(val) {
1539
1539
  return Math.max(60000, Math.round(u === 's' ? n * 1000 : u === 'm' ? n * 60000 : n * 3600000));
1540
1540
  }
1541
1541
 
1542
+ function normalizeMeetingParticipants(participants) {
1543
+ if (!Array.isArray(participants)) return [];
1544
+ const seen = new Set();
1545
+ const normalized = [];
1546
+ for (const participant of participants) {
1547
+ const id = String(participant || '').trim();
1548
+ if (!id || seen.has(id)) continue;
1549
+ seen.add(id);
1550
+ normalized.push(id);
1551
+ }
1552
+ return normalized;
1553
+ }
1554
+
1555
+ function meetingParticipantsFromAction(action) {
1556
+ return normalizeMeetingParticipants(
1557
+ Array.isArray(action?.participants) && action.participants.length > 0
1558
+ ? action.participants
1559
+ : action?.agents
1560
+ );
1561
+ }
1562
+
1542
1563
  // Required-field validator for CC actions. Returns null when valid, an error string when not.
1543
1564
  // Centralises field-required checks so the model can't quietly emit a malformed action and have
1544
1565
  // the server silently fall back to placeholder values (e.g. "Untitled"). The handler invokes this
@@ -1567,6 +1588,12 @@ function _ccValidateAction(action) {
1567
1588
  case 'plan':
1568
1589
  if (!action.title) return 'plan action missing required field: title';
1569
1590
  return null;
1591
+ case 'create-meeting': {
1592
+ if (!action.title || typeof action.title !== 'string' || !action.title.trim()) return 'create-meeting action missing required field: title';
1593
+ if (!action.agenda || typeof action.agenda !== 'string' || !action.agenda.trim()) return 'create-meeting action missing required field: agenda';
1594
+ if (meetingParticipantsFromAction(action).length < 2) return 'create-meeting action requires at least 2 participants';
1595
+ return null;
1596
+ }
1570
1597
  default:
1571
1598
  return null; // unknown types fall through to existing handler / generic fallback
1572
1599
  }
@@ -1794,6 +1821,17 @@ async function executeCCActions(actions) {
1794
1821
  results.push({ type: 'create-watch', id: watch.id, ok: true });
1795
1822
  break;
1796
1823
  }
1824
+ case 'create-meeting': {
1825
+ const { createMeeting } = require('./engine/meeting');
1826
+ const meeting = createMeeting({
1827
+ title: action.title.trim(),
1828
+ agenda: action.agenda.trim(),
1829
+ participants: meetingParticipantsFromAction(action),
1830
+ });
1831
+ invalidateStatusCache();
1832
+ results.push({ type: 'create-meeting', id: meeting.id, ok: true });
1833
+ break;
1834
+ }
1797
1835
  case 'delete-watch': {
1798
1836
  const deleted = watchesMod.deleteWatch(action.id);
1799
1837
  if (deleted) invalidateStatusCache();
@@ -6721,9 +6759,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6721
6759
  { method: 'POST', path: '/api/meetings', desc: 'Create a team meeting', params: 'title, agenda, participants[]', handler: async (req, res) => {
6722
6760
  const body = await readBody(req);
6723
6761
  const { title, agenda, participants } = body;
6724
- if (!title || !agenda) return jsonReply(res, 400, { error: 'title and agenda required' });
6762
+ if (typeof title !== 'string' || !title.trim() || typeof agenda !== 'string' || !agenda.trim()) {
6763
+ return jsonReply(res, 400, { error: 'title and agenda required' });
6764
+ }
6765
+ if (!Array.isArray(participants)) return jsonReply(res, 400, { error: 'participants must be an array' });
6766
+ const meetingParticipants = normalizeMeetingParticipants(participants);
6767
+ if (meetingParticipants.length < 2) return jsonReply(res, 400, { error: 'at least 2 participants required' });
6725
6768
  const { createMeeting } = require('./engine/meeting');
6726
- const meeting = createMeeting({ title, agenda, participants: participants || [] });
6769
+ const meeting = createMeeting({ title: title.trim(), agenda: agenda.trim(), participants: meetingParticipants });
6727
6770
  invalidateStatusCache();
6728
6771
  return jsonReply(res, 200, { ok: true, meeting });
6729
6772
  }},
@@ -6923,6 +6966,8 @@ module.exports = {
6923
6966
  _filterCcTabSessions,
6924
6967
  _getVersionCheckInterval,
6925
6968
  _parseWatchInterval,
6969
+ _normalizeMeetingParticipants: normalizeMeetingParticipants,
6970
+ _meetingParticipantsFromAction: meetingParticipantsFromAction,
6926
6971
  parsePinnedEntries,
6927
6972
  _parseDocChatResultText,
6928
6973
  _messageRequestsOrchestration,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-04T16:53:06.222Z"
4
+ "cachedAt": "2026-05-04T17:19:35.871Z"
5
5
  }
@@ -2662,7 +2662,14 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2662
2662
  if (type === WORK_TYPE.MEETING && meta?.meetingId) {
2663
2663
  try {
2664
2664
  const { collectMeetingFindings } = require('./meeting');
2665
- collectMeetingFindings(meta.meetingId, agentId, meta.roundName, stdout, structuredCompletion);
2665
+ collectMeetingFindings(meta.meetingId, agentId, meta.roundName, stdout, structuredCompletion, meta.round, {
2666
+ success: effectiveSuccess,
2667
+ result,
2668
+ code,
2669
+ completionStatus,
2670
+ agentReportedFailure,
2671
+ summary: resultSummary,
2672
+ });
2666
2673
  } catch (err) { log('warn', `Meeting collect: ${err.message}`); }
2667
2674
  }
2668
2675
 
package/engine/meeting.js CHANGED
@@ -20,6 +20,106 @@ const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
20
20
  // tests can redirect the meetings directory without patching module internals.
21
21
  const MEETINGS_DIR = path.join(shared.MINIONS_DIR, 'meetings');
22
22
  const MEETING_NOTE_ARTIFACT_ROOT = path.join(shared.MINIONS_DIR, 'notes', 'inbox');
23
+ const TERMINAL_MEETING_STATUSES = new Set(['completed', 'archived']);
24
+ const ROUND_STATUS_BY_NAME = {
25
+ investigate: 'investigating',
26
+ debate: 'debating',
27
+ conclude: 'concluding',
28
+ };
29
+ const ROUND_NUMBER_BY_NAME = { investigate: 1, debate: 2, conclude: 3 };
30
+ const ACTIVE_MEETING_STATUSES = new Set(Object.values(ROUND_STATUS_BY_NAME));
31
+
32
+ function isTerminalMeetingStatus(status) {
33
+ return TERMINAL_MEETING_STATUSES.has(String(status || '').toLowerCase());
34
+ }
35
+
36
+ function expectedMeetingStatusForRound(roundName) {
37
+ return ROUND_STATUS_BY_NAME[String(roundName || '').toLowerCase()] || null;
38
+ }
39
+
40
+ function roundKeyFor(roundName, round) {
41
+ const numeric = Number(round);
42
+ if (Number.isFinite(numeric) && numeric > 0) return String(numeric);
43
+ return String(ROUND_NUMBER_BY_NAME[String(roundName || '').toLowerCase()] || 1);
44
+ }
45
+
46
+ function getRoundFailures(meeting, roundName, round, create = false) {
47
+ if (!meeting.roundFailures || typeof meeting.roundFailures !== 'object') {
48
+ if (!create) return {};
49
+ meeting.roundFailures = {};
50
+ }
51
+ const key = roundKeyFor(roundName, round);
52
+ if (!meeting.roundFailures[key] || typeof meeting.roundFailures[key] !== 'object') {
53
+ if (!create) return {};
54
+ meeting.roundFailures[key] = {};
55
+ }
56
+ return meeting.roundFailures[key];
57
+ }
58
+
59
+ function hasRoundFailure(meeting, roundName, agentId, round = meeting.round) {
60
+ return Boolean(getRoundFailures(meeting, roundName, round, false)[agentId]);
61
+ }
62
+
63
+ function hasRoundSuccess(meeting, roundName, agentId) {
64
+ if (roundName === 'investigate') return Boolean(meeting.findings?.[agentId]);
65
+ if (roundName === 'debate') return Boolean(meeting.debate?.[agentId]);
66
+ return Boolean(meeting.conclusion && meeting.conclusion.agent === agentId);
67
+ }
68
+
69
+ function hasRoundTerminalOutcome(meeting, roundName, agentId, round = meeting.round) {
70
+ return hasRoundSuccess(meeting, roundName, agentId) || hasRoundFailure(meeting, roundName, agentId, round);
71
+ }
72
+
73
+ function allParticipantsFinishedRound(meeting, roundName, round = meeting.round) {
74
+ const participants = Array.isArray(meeting.participants) ? meeting.participants : [];
75
+ return participants.length > 0 && participants.every(agentId =>
76
+ hasRoundTerminalOutcome(meeting, roundName, agentId, round)
77
+ );
78
+ }
79
+
80
+ function formatRoundFailuresForConclusion(meeting, roundName, round = meeting.round) {
81
+ const failures = getRoundFailures(meeting, roundName, round, false);
82
+ return Object.entries(failures)
83
+ .map(([agent, failure]) => `- **${agent}**: ${failure.reason || 'Agent failed before producing a meeting contribution.'}`)
84
+ .join('\n') || '- No structured failure details were captured.';
85
+ }
86
+
87
+ function buildFailedMeetingConclusion(meeting, agents, reason) {
88
+ const base = buildTimedOutMeetingConclusion(meeting, agents)
89
+ .replace('*Auto-generated — conclusion round timed out.*', '*Auto-generated — conclusion round failed.*');
90
+ return `${base}\n\n## Conclusion Failure\n${reason || formatRoundFailuresForConclusion(meeting, 'conclude', meeting.round)}`;
91
+ }
92
+
93
+ function advanceMeetingIfRoundComplete(meeting, roundName, meetingId, config = null) {
94
+ if (roundName === 'investigate') {
95
+ if (!allParticipantsFinishedRound(meeting, roundName, meeting.round)) return false;
96
+ meeting.status = 'debating';
97
+ meeting.round = 2;
98
+ meeting.roundStartedAt = ts();
99
+ log('info', `Meeting ${meetingId}: all findings finished — advancing to debate`);
100
+ return true;
101
+ }
102
+ if (roundName === 'debate') {
103
+ if (!allParticipantsFinishedRound(meeting, roundName, meeting.round)) return false;
104
+ meeting.status = 'concluding';
105
+ meeting.round = 3;
106
+ meeting.roundStartedAt = ts();
107
+ log('info', `Meeting ${meetingId}: all debate responses finished — advancing to conclusion`);
108
+ return true;
109
+ }
110
+ if (roundName === 'conclude' && !meeting.conclusion) {
111
+ const agents = (config || queries.getConfig()).agents || {};
112
+ const autoConclusion = buildFailedMeetingConclusion(meeting, agents);
113
+ meeting.conclusion = { content: autoConclusion, agent: 'system', submittedAt: ts() };
114
+ meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'conclusion', content: autoConclusion, at: ts() });
115
+ meeting.status = 'completed';
116
+ meeting.completedAt = ts();
117
+ writeMeetingTranscriptToInbox(meeting, meetingId, agents);
118
+ log('warn', `Meeting ${meetingId}: conclusion failed — auto-generated fallback conclusion`);
119
+ return true;
120
+ }
121
+ return false;
122
+ }
23
123
 
24
124
  function isEmptyMeetingContent(text) {
25
125
  const value = String(text || '').trim();
@@ -218,6 +318,15 @@ function buildTimedOutMeetingConclusion(meeting, agents) {
218
318
  return `*Auto-generated — conclusion round timed out.*\n\nThis summary is based on ${findingsCount} finding${findingsCount === 1 ? '' : 's'} and ${debateCount} debate response${debateCount === 1 ? '' : 's'}.\n\n## Findings Highlights\n${findingsHighlights.join('\n')}\n\n## Debate Takeaways\n${(debateTakeaways.length ? debateTakeaways : fallbackDebate).join('\n')}\n\n## Recommended Next Steps\n${nextSteps.join('\n')}`;
219
319
  }
220
320
 
321
+ function writeMeetingTranscriptToInbox(meeting, meetingId, agents) {
322
+ try {
323
+ const transcript = meeting.transcript.map(t =>
324
+ `### ${agents[t.agent]?.name || t.agent} (${t.type}, Round ${t.round})\n\n${t.content}`
325
+ ).join('\n\n---\n\n');
326
+ shared.writeToInbox('meeting', meetingId, `# Meeting Transcript: ${meeting.title}\n\n${transcript}`);
327
+ } catch (e) { log('warn', `Meeting ${meetingId} inbox write: ${e.message}`); }
328
+ }
329
+
221
330
  function getMeetings() {
222
331
  if (!fs.existsSync(MEETINGS_DIR)) return [];
223
332
  return fs.readdirSync(MEETINGS_DIR)
@@ -234,6 +343,8 @@ function getMeeting(id) {
234
343
  if (!m.debate) m.debate = {};
235
344
  if (!m.humanNotes) m.humanNotes = [];
236
345
  if (!m.participants) m.participants = [];
346
+ if (!m.transcript) m.transcript = [];
347
+ if (!m.roundFailures || typeof m.roundFailures !== 'object') m.roundFailures = {};
237
348
  }
238
349
  return m;
239
350
  }
@@ -257,6 +368,7 @@ function createMeeting({ title, agenda, participants }) {
257
368
  debate: {},
258
369
  conclusion: null,
259
370
  humanNotes: [],
371
+ roundFailures: {},
260
372
  transcript: [],
261
373
  };
262
374
  saveMeeting(meeting);
@@ -278,10 +390,11 @@ function discoverMeetingWork(config) {
278
390
  );
279
391
 
280
392
  for (const meeting of meetings) {
281
- if (meeting.status === 'completed') continue;
393
+ if (isTerminalMeetingStatus(meeting.status)) continue;
282
394
 
283
395
  const round = meeting.round || 1;
284
396
  const roundName = meeting.status; // investigating, debating, concluding
397
+ if (!ACTIVE_MEETING_STATUSES.has(roundName)) continue;
285
398
  const agents = config.agents || {};
286
399
 
287
400
  if (roundName === 'concluding') {
@@ -357,6 +470,8 @@ function discoverMeetingWork(config) {
357
470
  // Skip if already submitted for this round
358
471
  if (roundName === 'investigating' && meeting.findings?.[agentId]) continue;
359
472
  if (roundName === 'debating' && meeting.debate?.[agentId]) continue;
473
+ const dispatchRoundName = roundName === 'investigating' ? 'investigate' : 'debate';
474
+ if (hasRoundFailure(meeting, dispatchRoundName, agentId, round)) continue;
360
475
 
361
476
  const key = `meeting-${meeting.id}-r${round}-${agentId}`;
362
477
  if (activeKeys.has(key)) continue;
@@ -401,7 +516,7 @@ function discoverMeetingWork(config) {
401
516
  source: 'meeting',
402
517
  meetingId: meeting.id,
403
518
  round,
404
- roundName: roundName === 'investigating' ? 'investigate' : 'debate',
519
+ roundName: dispatchRoundName,
405
520
  }
406
521
  });
407
522
  }
@@ -413,20 +528,54 @@ function discoverMeetingWork(config) {
413
528
  * Collect findings from a completed meeting agent.
414
529
  * Called from runPostCompletionHooks when type === 'meeting'.
415
530
  */
416
- function collectMeetingFindings(meetingId, agentId, roundName, output, structuredCompletion = null) {
531
+ function collectMeetingFindings(meetingId, agentId, roundName, output, structuredCompletion = null, expectedRound = null, completionInfo = {}) {
417
532
  const meeting = getMeeting(meetingId);
418
533
  if (!meeting) return;
419
- if (meeting.status === 'completed' || meeting.status === 'archived') {
534
+ if (isTerminalMeetingStatus(meeting.status)) {
420
535
  log('info', `Ignoring late findings from ${agentId} for completed meeting ${meetingId}`);
421
536
  return;
422
537
  }
423
538
 
424
- const content = resolveMeetingContributionContent(output, structuredCompletion);
539
+ const expectedStatus = expectedMeetingStatusForRound(roundName);
540
+ if (!expectedStatus) {
541
+ log('warn', `Meeting ${meetingId}: ignoring ${agentId} output for unknown round "${roundName || '(empty)'}"`);
542
+ return;
543
+ }
544
+ if (meeting.status !== expectedStatus) {
545
+ log('info', `Ignoring stale ${roundName} output from ${agentId} for meeting ${meetingId} currently ${meeting.status}`);
546
+ return;
547
+ }
548
+ if (expectedRound !== null && expectedRound !== undefined && Number(meeting.round || 1) !== Number(expectedRound)) {
549
+ log('info', `Ignoring stale round ${expectedRound} output from ${agentId} for meeting ${meetingId} currently on round ${meeting.round || 1}`);
550
+ return;
551
+ }
552
+ if (hasRoundTerminalOutcome(meeting, roundName, agentId, meeting.round)) {
553
+ log('info', `Ignoring duplicate ${roundName} output from ${agentId} for meeting ${meetingId}`);
554
+ return;
555
+ }
425
556
 
426
- // Validate output — reject empty or placeholder responses
427
- if (isEmptyMeetingContent(content)) {
428
- log('warn', `Meeting ${meetingId}: agent ${agentId} returned empty output for ${roundName} — rejecting`);
429
- // Don't record it — agent will be re-dispatched on next tick
557
+ const content = resolveMeetingContributionContent(output, structuredCompletion);
558
+ const completionSucceeded = completionInfo?.success !== false;
559
+
560
+ if (!completionSucceeded || isEmptyMeetingContent(content)) {
561
+ const failures = getRoundFailures(meeting, roundName, meeting.round, true);
562
+ const reason = !completionSucceeded
563
+ ? (completionInfo?.reason || completionInfo?.completionStatus || 'Agent failed before completing the meeting round')
564
+ : 'Agent produced empty meeting output';
565
+ failures[agentId] = {
566
+ reason,
567
+ content: content || completionInfo?.summary || '',
568
+ submittedAt: ts(),
569
+ };
570
+ meeting.transcript.push({
571
+ round: meeting.round,
572
+ agent: agentId,
573
+ type: 'failure',
574
+ content: reason,
575
+ at: ts(),
576
+ });
577
+ log('warn', `Meeting ${meetingId}: agent ${agentId} failed ${roundName} — ${reason}`);
578
+ advanceMeetingIfRoundComplete(meeting, roundName, meetingId);
430
579
  saveMeeting(meeting);
431
580
  return;
432
581
  }
@@ -446,11 +595,7 @@ function collectMeetingFindings(meetingId, agentId, roundName, output, structure
446
595
  // Write transcript to inbox so agents learn from it (slug-based dedup)
447
596
  try {
448
597
  const config = queries.getConfig();
449
- const agents = config.agents || {};
450
- const transcript = meeting.transcript.map(t =>
451
- `### ${agents[t.agent]?.name || t.agent} (${t.type}, Round ${t.round})\n\n${t.content}`
452
- ).join('\n\n---\n\n');
453
- shared.writeToInbox('meeting', meetingId, `# Meeting Transcript: ${meeting.title}\n\n${transcript}`);
598
+ writeMeetingTranscriptToInbox(meeting, meetingId, config.agents || {});
454
599
  } catch (e) { log('warn', `Meeting ${meetingId} inbox write: ${e.message}`); }
455
600
 
456
601
  log('info', `Meeting ${meetingId} completed — transcript written to inbox`);
@@ -458,26 +603,7 @@ function collectMeetingFindings(meetingId, agentId, roundName, output, structure
458
603
  return;
459
604
  }
460
605
 
461
- // Check if all participants have submitted for this round
462
- const participantCount = meeting.participants.length;
463
- const allSubmitted =
464
- (meeting.status === 'investigating' && Object.keys(meeting.findings || {}).length >= participantCount) ||
465
- (meeting.status === 'debating' && Object.keys(meeting.debate || {}).length >= participantCount);
466
-
467
- if (allSubmitted) {
468
- // Advance to next round
469
- if (meeting.status === 'investigating') {
470
- meeting.status = 'debating';
471
- meeting.round = 2;
472
- meeting.roundStartedAt = ts();
473
- log('info', `Meeting ${meetingId}: all findings in — advancing to debate`);
474
- } else if (meeting.status === 'debating') {
475
- meeting.status = 'concluding';
476
- meeting.round = 3;
477
- meeting.roundStartedAt = ts();
478
- log('info', `Meeting ${meetingId}: all debate responses in — advancing to conclusion`);
479
- }
480
- }
606
+ advanceMeetingIfRoundComplete(meeting, roundName, meetingId);
481
607
 
482
608
  saveMeeting(meeting);
483
609
  }
@@ -494,21 +620,54 @@ function addMeetingNote(meetingId, note) {
494
620
  function _killMeetingDispatches(meetingId) {
495
621
  try {
496
622
  const DISPATCH_PATH = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
497
- const dispatch = safeJson(DISPATCH_PATH) || {};
498
- const toKill = (dispatch.active || []).filter(d => d.meta?.meetingId === meetingId);
499
- if (toKill.length === 0) return 0;
500
- // Remove from active and move to completed
623
+ const tmpDir = path.join(shared.MINIONS_DIR, 'engine', 'tmp');
624
+ const entriesToStop = [];
625
+ const filesToDelete = [];
501
626
  shared.mutateJsonFileLocked(DISPATCH_PATH, (dp) => {
502
- dp.active = (dp.active || []).filter(d => d.meta?.meetingId !== meetingId);
503
- dp.completed = dp.completed || [];
504
- for (const d of toKill) {
627
+ dp.pending = Array.isArray(dp.pending) ? dp.pending : [];
628
+ dp.active = Array.isArray(dp.active) ? dp.active : [];
629
+ dp.completed = Array.isArray(dp.completed) ? dp.completed : [];
630
+
631
+ for (const queue of ['pending', 'active']) {
632
+ const kept = [];
633
+ for (const d of dp[queue]) {
634
+ if (d.meta?.meetingId !== meetingId) {
635
+ kept.push(d);
636
+ continue;
637
+ }
638
+ entriesToStop.push(d);
639
+ filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
640
+ filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
641
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
642
+ filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
643
+ }
644
+ dp[queue] = kept;
645
+ }
646
+
647
+ for (const d of entriesToStop) {
505
648
  dp.completed.push({ ...d, result: DISPATCH_RESULT.ERROR, reason: 'Meeting ended/advanced by human', completed_at: ts() });
506
649
  }
507
650
  if (dp.completed.length > 100) dp.completed = dp.completed.slice(-100);
508
651
  return dp;
509
652
  }, { defaultValue: { pending: [], active: [], completed: [] } });
510
- log('info', `Killed ${toKill.length} active meeting dispatch(es) for ${meetingId}`);
511
- return toKill.length;
653
+
654
+ const pidsToKill = [];
655
+ for (const d of entriesToStop) {
656
+ try {
657
+ const pidFile = path.join(tmpDir, `pid-${d.id}.pid`);
658
+ const pid = shared.validatePid(fs.readFileSync(pidFile, 'utf8').trim());
659
+ pidsToKill.push(pid);
660
+ } catch { /* pending entries and already-finished agents may not have PID files */ }
661
+ }
662
+ for (const pid of pidsToKill) {
663
+ try { shared.killGracefully({ pid }); } catch { /* process may already be dead */ }
664
+ }
665
+ for (const fp of filesToDelete) {
666
+ try { fs.unlinkSync(fp); } catch { /* sidecar may not exist */ }
667
+ }
668
+
669
+ if (entriesToStop.length > 0) log('info', `Killed ${entriesToStop.length} meeting dispatch(es) for ${meetingId}`);
670
+ return entriesToStop.length;
512
671
  } catch (e) { log('warn', 'kill meeting dispatches: ' + e.message); return 0; }
513
672
  }
514
673
 
@@ -563,7 +722,8 @@ function deleteMeeting(id) {
563
722
 
564
723
  /**
565
724
  * Check for meeting rounds that have exceeded the timeout.
566
- * Auto-advances to the next round with whatever responses were received.
725
+ * Timeout is observational: rounds advance only after every participant has
726
+ * succeeded or failed, and conclusion waits for the conclusion agent outcome.
567
727
  * Called from engine.js tick cycle.
568
728
  */
569
729
  function checkMeetingTimeouts(config) {
@@ -572,10 +732,13 @@ function checkMeetingTimeouts(config) {
572
732
  || ENGINE_DEFAULTS.meetingRoundTimeout;
573
733
 
574
734
  for (const meeting of meetings) {
575
- if (meeting.status === 'completed') continue;
735
+ if (isTerminalMeetingStatus(meeting.status)) continue;
736
+ if (!ACTIVE_MEETING_STATUSES.has(meeting.status)) continue;
576
737
  if (!meeting.roundStartedAt) continue;
577
738
 
578
- const elapsed = Date.now() - new Date(meeting.roundStartedAt).getTime();
739
+ const roundStartedMs = new Date(meeting.roundStartedAt).getTime();
740
+ if (!Number.isFinite(roundStartedMs)) continue;
741
+ const elapsed = Date.now() - roundStartedMs;
579
742
  if (elapsed < timeout) continue;
580
743
 
581
744
  const respondedCount = meeting.status === 'investigating'
@@ -585,38 +748,23 @@ function checkMeetingTimeouts(config) {
585
748
  : 0;
586
749
  const totalCount = meeting.participants.length;
587
750
 
588
- if (meeting.status === 'investigating') {
589
- log('warn', `Meeting ${meeting.id}: round 1 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to debate`);
590
- meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 1 timed out — ${respondedCount}/${totalCount} findings received`, at: ts() });
591
- meeting.status = 'debating';
592
- meeting.round = 2;
593
- meeting.roundStartedAt = ts();
594
- saveMeeting(meeting);
595
- } else if (meeting.status === 'debating') {
596
- log('warn', `Meeting ${meeting.id}: round 2 timed out after ${Math.round(elapsed / 60000)}min ${respondedCount}/${totalCount} responded, advancing to conclusion`);
597
- meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 2 timed out ${respondedCount}/${totalCount} debate responses received`, at: ts() });
598
- meeting.status = 'concluding';
599
- meeting.round = 3;
600
- meeting.roundStartedAt = ts();
601
- saveMeeting(meeting);
751
+ const roundName = meeting.status === 'investigating'
752
+ ? 'investigate'
753
+ : meeting.status === 'debating'
754
+ ? 'debate'
755
+ : 'conclude';
756
+
757
+ if (roundName !== 'conclude') {
758
+ if (allParticipantsFinishedRound(meeting, roundName, meeting.round)) {
759
+ log('warn', `Meeting ${meeting.id}: round ${meeting.round} timed out after ${Math.round(elapsed / 60000)}min but all participants are terminal — advancing`);
760
+ meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round ${meeting.round} timed out after all participants finished`, at: ts() });
761
+ advanceMeetingIfRoundComplete(meeting, roundName, meeting.id, config);
762
+ saveMeeting(meeting);
763
+ } else {
764
+ log('warn', `Meeting ${meeting.id}: round ${meeting.round} timed out after ${Math.round(elapsed / 60000)}min — waiting for all participants to finish (${respondedCount}/${totalCount} succeeded)`);
765
+ }
602
766
  } else if (meeting.status === 'concluding') {
603
- log('warn', `Meeting ${meeting.id}: conclusion round timed out after ${Math.round(elapsed / 60000)}min — auto-summarizing`);
604
- const autoConclusion = buildTimedOutMeetingConclusion(meeting, config.agents || {});
605
- meeting.conclusion = { content: autoConclusion, agent: 'system', submittedAt: ts() };
606
- meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'conclusion', content: autoConclusion, at: ts() });
607
- meeting.status = 'completed';
608
- meeting.completedAt = ts();
609
-
610
- // Write transcript to inbox (same as normal conclusion path)
611
- try {
612
- const agents = config.agents || {};
613
- const transcript = meeting.transcript.map(t =>
614
- `### ${agents[t.agent]?.name || t.agent} (${t.type}, Round ${t.round})\n\n${t.content}`
615
- ).join('\n\n---\n\n');
616
- shared.writeToInbox('meeting', meeting.id, `# Meeting Transcript: ${meeting.title}\n\n${transcript}`);
617
- } catch (e) { log('warn', `Meeting ${meeting.id} inbox write: ${e.message}`); }
618
-
619
- saveMeeting(meeting);
767
+ log('warn', `Meeting ${meeting.id}: conclusion round timed out after ${Math.round(elapsed / 60000)}min — waiting for the conclusion agent to finish`);
620
768
  }
621
769
  }
622
770
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1709",
3
+ "version": "0.1.1711",
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"