@yemi33/minions 0.1.560 → 0.1.562

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.562 (2026-04-08)
4
+
5
+ ### Fixes
6
+ - auto-reset CC session on resume failure, add error logging
7
+
8
+ ## 0.1.561 (2026-04-08)
9
+
10
+ ### Fixes
11
+ - add branch-level mutex to prevent concurrent dispatch to same branch (closes #493) (#498)
12
+
3
13
  ## 0.1.560 (2026-04-08)
4
14
 
5
15
  ### Features
package/dashboard.js CHANGED
@@ -3368,9 +3368,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3368
3368
  if (result.code !== 0 || !result.text) {
3369
3369
  const debugInfo = result.code !== 0 ? `(exit code ${result.code})` : '(empty response)';
3370
3370
  const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
3371
- const retryHint = ccSession.sessionId
3372
- ? 'Your session is still active just send your message again to retry.'
3373
- : 'Try clicking **New Session** and sending your message again.';
3371
+ console.error(`[CC-stream] Failed: code=${result.code}, stderr=${(result.stderr || '').slice(0, 500)}, stdout_tail=${(result.raw || '').slice(-500)}`);
3372
+ // If resuming a session failed, auto-reset so next attempt starts fresh
3373
+ let retryHint;
3374
+ if (wasResume && result.code !== 0) {
3375
+ ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
3376
+ safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
3377
+ retryHint = 'Session was reset — send your message again to start fresh.';
3378
+ } else {
3379
+ retryHint = ccSession.sessionId
3380
+ ? 'Your session is still active — just send your message again to retry.'
3381
+ : 'Try clicking **New Session** and sending your message again.';
3382
+ }
3374
3383
  res.write('data: ' + JSON.stringify({ type: 'done', text: `I had trouble processing that ${debugInfo}. ${stderrTail ? 'Detail: ' + stderrTail : ''}\n\n${retryHint}`, actions: [], sessionId: ccSession.sessionId }) + '\n\n');
3375
3384
  res.end();
3376
3385
  return;
@@ -103,6 +103,21 @@ function isAlreadyDispatched(key) {
103
103
  return recentCompleted.some(d => d.meta?.dispatchKey === key);
104
104
  }
105
105
 
106
+ /**
107
+ * Check if a branch is currently locked by an active dispatch.
108
+ * Returns the conflicting dispatch item, or null if the branch is free.
109
+ */
110
+ function isBranchActive(branch) {
111
+ if (!branch) return null;
112
+ const { sanitizeBranch } = require('./shared');
113
+ const normalized = sanitizeBranch(branch);
114
+ const dispatch = queries.getDispatch();
115
+ return (dispatch.active || []).find(d => {
116
+ const dBranch = d.meta?.branch;
117
+ return dBranch && sanitizeBranch(dBranch) === normalized;
118
+ }) || null;
119
+ }
120
+
106
121
  module.exports = {
107
122
  COOLDOWN_PATH,
108
123
  dispatchCooldowns,
@@ -114,4 +129,5 @@ module.exports = {
114
129
  getCoalescedContexts,
115
130
  setCooldownFailure,
116
131
  isAlreadyDispatched,
132
+ isBranchActive,
117
133
  };
package/engine.js CHANGED
@@ -987,7 +987,7 @@ function updateSnapshot(config) {
987
987
 
988
988
  const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
989
989
  isOnCooldown, setCooldown, setCooldownWithContext, getCoalescedContexts,
990
- setCooldownFailure, isAlreadyDispatched } = require('./engine/cooldown');
990
+ setCooldownFailure, isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
991
991
 
992
992
 
993
993
 
@@ -1399,6 +1399,11 @@ async function discoverFromPrs(config, project) {
1399
1399
  for (const pr of prs) {
1400
1400
  if (pr.status !== 'active') continue;
1401
1401
  if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
1402
+ // Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
1403
+ if (pr.branch && isBranchActive(pr.branch)) {
1404
+ log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${pr.branch} locked by another agent`);
1405
+ continue;
1406
+ }
1402
1407
  // Skip human-authored PRs not linked to any work item — only auto-manage agent PRs
1403
1408
  // Manually-linked PRs with autoObserve are allowed through (they have _autoObserve flag)
1404
1409
  const isAgentPr = knownAgents.has((pr.agent || '').toLowerCase()) || (pr.prdItems && pr.prdItems.length > 0) || pr._autoObserve;
@@ -1678,6 +1683,16 @@ function discoverFromWorkItems(config, project) {
1678
1683
 
1679
1684
  const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
1680
1685
  const branchName = isShared ? item.featureBranch : (item.branch || `work/${item.id}`);
1686
+
1687
+ // Branch mutex: skip if target branch is locked by an active dispatch
1688
+ const branchConflict = isBranchActive(branchName);
1689
+ if (branchConflict) {
1690
+ if (item._pendingReason !== 'branch_locked') { item._pendingReason = 'branch_locked'; needsWrite = true; }
1691
+ skipped.gated++;
1692
+ log('info', `Branch mutex: skipping ${item.id} — branch ${branchName} locked by ${branchConflict.id} (${branchConflict.agent})`);
1693
+ continue;
1694
+ }
1695
+
1681
1696
  const vars = {
1682
1697
  ...buildBaseVars(agentId, config, project),
1683
1698
  item_id: item.id,
@@ -2158,6 +2173,14 @@ function discoverCentralWorkItems(config) {
2158
2173
  const firstProject = projects.length > 0 ? projects[0] : null;
2159
2174
  if (!firstProject) { log('warn', `Dispatch: skipping ${item.id} — no projects configured`); continue; }
2160
2175
 
2176
+ // Branch mutex: skip if target branch is locked by an active dispatch
2177
+ const centralBranch = item.branch || item.featureBranch || `work/${item.id}`;
2178
+ const centralBranchConflict = isBranchActive(centralBranch);
2179
+ if (centralBranchConflict) {
2180
+ log('info', `Branch mutex: skipping central ${item.id} — branch ${centralBranch} locked by ${centralBranchConflict.id} (${centralBranchConflict.agent})`);
2181
+ continue;
2182
+ }
2183
+
2161
2184
  const vars = {
2162
2185
  ...buildBaseVars(agentId, config, firstProject),
2163
2186
  item_id: item.id,
@@ -2172,9 +2195,6 @@ function discoverCentralWorkItems(config) {
2172
2195
  scope_section: buildProjectContext(projects, null, false, agentName, agentRole),
2173
2196
  project_path: firstProject?.localPath || '',
2174
2197
  };
2175
-
2176
- // Build common vars: references, acceptance criteria, checkpoint, notes, task context
2177
- const centralBranch = item.branch || `work/${item.id}`;
2178
2198
  const centralWtPath = firstProject?.localPath
2179
2199
  ? path.resolve(firstProject.localPath, config.engine?.worktreeRoot || '../worktrees', centralBranch)
2180
2200
  : '';
@@ -2639,6 +2659,11 @@ async function tickInner() {
2639
2659
 
2640
2660
  // Build set of agents currently active (one task per agent at a time).
2641
2661
  const busyAgents = new Set((dispatch.active || []).map(d => d.agent));
2662
+ // Branch mutex: track branches locked by active dispatches to prevent concurrent writes
2663
+ const lockedBranches = new Set();
2664
+ for (const d of (dispatch.active || [])) {
2665
+ if (d.meta?.branch) lockedBranches.add(sanitizeBranch(d.meta.branch));
2666
+ }
2642
2667
  const seenPendingIds = new Set();
2643
2668
  const toDispatch = [];
2644
2669
  let generalSlots = slotsAvailable;
@@ -2649,12 +2674,16 @@ async function tickInner() {
2649
2674
  continue;
2650
2675
  }
2651
2676
  if (busyAgents.has(item.agent)) continue;
2677
+ // Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
2678
+ const itemBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
2679
+ if (itemBranch && lockedBranches.has(itemBranch)) continue;
2652
2680
  // Items explicitly assigned to an agent bypass concurrency cap — dispatch if agent is free
2653
2681
  const isExplicitAssignment = !!item.meta?.item?.agent;
2654
2682
  if (!isExplicitAssignment && generalSlots <= 0) continue;
2655
2683
  seenPendingIds.add(item.id);
2656
2684
  toDispatch.push(item);
2657
2685
  busyAgents.add(item.agent);
2686
+ if (itemBranch) lockedBranches.add(itemBranch);
2658
2687
  if (!isExplicitAssignment) generalSlots--;
2659
2688
  }
2660
2689
 
@@ -2713,6 +2742,11 @@ async function tickInner() {
2713
2742
  const postDispatch = getDispatch();
2714
2743
  const postBusyAgents = new Set((postDispatch.active || []).map(d => d.agent));
2715
2744
  const postActiveCount = (postDispatch.active || []).length;
2745
+ // Rebuild locked branches from post-dispatch active set for skip-reason annotation
2746
+ const postLockedBranches = new Set();
2747
+ for (const d of (postDispatch.active || [])) {
2748
+ if (d.meta?.branch) postLockedBranches.add(sanitizeBranch(d.meta.branch));
2749
+ }
2716
2750
  let skipReasonChanged = false;
2717
2751
  for (const item of (postDispatch.pending || [])) {
2718
2752
  let reason = null;
@@ -2720,6 +2754,12 @@ async function tickInner() {
2720
2754
  reason = 'max_concurrency';
2721
2755
  } else if (postBusyAgents.has(item.agent)) {
2722
2756
  reason = 'agent_busy';
2757
+ } else {
2758
+ // Branch mutex: annotate items waiting for a branch to become free
2759
+ const pendingBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
2760
+ if (pendingBranch && postLockedBranches.has(pendingBranch)) {
2761
+ reason = 'branch_locked';
2762
+ }
2723
2763
  }
2724
2764
  if (item.skipReason !== reason) {
2725
2765
  item.skipReason = reason;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.560",
3
+ "version": "0.1.562",
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"