@yemi33/minions 0.1.909 → 0.1.911

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,8 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.909 (2026-04-13)
3
+ ## 0.1.911 (2026-04-13)
4
4
 
5
5
  ### Features
6
+ - audit and harden log buffering implementation (#971)
7
+ - replace magic string 'active' with PR_STATUS.ACTIVE in lifecycle.js (#969)
8
+ - fix dashboard plan-pause nested lock violation (#968)
9
+ - add missing branch_name to central dispatch vars (#967)
10
+ - fix dispatch.js mutator fallback to use nullish coalescing (#966)
11
+ - fix stall recovery nested lock violation (#965)
12
+ - fix pending-rebases.json race condition with file locking (#964)
13
+ - add missing resolveWorkItemPath import in engine.js (#963)
6
14
  - bump plan-to-prd max turns from 20 to 35
7
15
  - add weekly dead code & deprecated cleanup pipeline
8
16
  - add red dot notification on CC tab when response completes (#934) (#946)
@@ -14,6 +22,9 @@
14
22
  - make KB sweep endpoint async with status polling (#933)
15
23
  - prohibit agents from self-merging their own PRs
16
24
 
25
+ ### Other
26
+ - [E2E] Architecture meeting Tier 1+2: 6 correctness bugs + 2 nested lock violations + log buffering (#972)
27
+
17
28
  ## 0.1.901 (2026-04-12)
18
29
 
19
30
  ### Fixes
package/dashboard.js CHANGED
@@ -2366,77 +2366,101 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2366
2366
  safeWrite(planPath, plan);
2367
2367
 
2368
2368
  // Propagate pause to materialized work items across all projects:
2369
- // kill any active agent process and reset non-completed items back to pending.
2370
- let reset = 0;
2369
+ // kill any active agent process and reset non-completed items to paused.
2370
+ // Pattern: collect-then-release-then-act to avoid nested locks and long lock holds.
2371
2371
  const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
2372
2372
  for (const proj of PROJECTS) {
2373
2373
  wiPaths.push(shared.projectWorkItemsPath(proj));
2374
2374
  }
2375
2375
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
2376
- const killedAgents = new Set();
2377
- const resetItemIds = new Set();
2378
2376
 
2379
- // Read dispatch inside the lock so PID list is consistent with state being modified
2380
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
2381
- for (const wiPath of wiPaths) {
2377
+ // Step 1: Read work items (read-only, no lock) to find plan items that are dispatched.
2378
+ const dispatchedItemIds = new Set();
2379
+ for (const wiPath of wiPaths) {
2380
+ try {
2381
+ const items = safeJsonArr(wiPath);
2382
+ for (const w of items) {
2383
+ if (w.sourcePlan !== body.file) continue;
2384
+ if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
2385
+ if (w.status === WI_STATUS.DISPATCHED && w.id) dispatchedItemIds.add(w.id);
2386
+ }
2387
+ } catch { /* file may not exist */ }
2388
+ }
2389
+
2390
+ // Step 2: Read dispatch.json (read-only, no lock) to collect kill targets.
2391
+ const killTargets = []; // { agent, pid, statusPath }
2392
+ const dispatch = safeJsonObj(dispatchPath);
2393
+ const activeEntries = Array.isArray(dispatch.active) ? dispatch.active : [];
2394
+ for (const d of activeEntries) {
2395
+ const itemId = d.meta?.item?.id;
2396
+ const matchesById = itemId && dispatchedItemIds.has(itemId);
2397
+ const matchesByKey = d.meta?.dispatchKey && [...dispatchedItemIds].some(id => d.meta.dispatchKey.includes(id));
2398
+ if (matchesById || matchesByKey) {
2399
+ const statusPath = path.join(MINIONS_DIR, 'agents', d.agent, 'status.json');
2382
2400
  try {
2383
- mutateWorkItems(wiPath, items => {
2384
- let changed = false;
2385
- for (const w of items) {
2386
- if (w.sourcePlan !== body.file) continue;
2387
- // Keep completed items as-is, reset everything else to pending.
2388
- if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
2389
-
2390
- if (w.status === WI_STATUS.DISPATCHED) {
2391
- // Kill the agent working on this item, if any.
2392
- const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
2393
- if (activeEntry) {
2394
- const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
2395
- try {
2396
- const agentStatus = safeJsonObj(statusPath);
2397
- if (agentStatus.pid) {
2398
- try {
2399
- const safePid = shared.validatePid(agentStatus.pid);
2400
- if (process.platform === 'win32') {
2401
- require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
2402
- } else {
2403
- process.kill(safePid, 'SIGTERM');
2404
- }
2405
- } catch { /* process may be dead or invalid PID */ }
2406
- }
2407
- agentStatus.status = 'idle';
2408
- delete agentStatus.currentTask;
2409
- delete agentStatus.dispatched;
2410
- safeWrite(statusPath, agentStatus);
2411
- } catch (e) { console.error('agent reset:', e.message); }
2412
- killedAgents.add(activeEntry.agent);
2413
- }
2414
- }
2401
+ const agentStatus = safeJsonObj(statusPath);
2402
+ killTargets.push({ agent: d.agent, pid: agentStatus.pid || null, statusPath });
2403
+ } catch { killTargets.push({ agent: d.agent, pid: null, statusPath }); }
2404
+ }
2405
+ }
2415
2406
 
2416
- if (w.status !== WI_STATUS.PAUSED) reset++;
2417
- w.status = WI_STATUS.PAUSED;
2418
- w._pausedBy = 'prd-pause';
2419
- delete w._resumedAt;
2420
- delete w.dispatched_at;
2421
- delete w.dispatched_to;
2422
- delete w.failReason;
2423
- delete w.failedAt;
2424
- changed = true;
2425
- if (w.id) resetItemIds.add(w.id);
2426
- }
2427
- });
2428
- } catch (e) { console.error('reset work items:', e.message); }
2407
+ // Step 3: Kill agent processes OUTSIDE any lock (expensive, may take seconds).
2408
+ const killedAgents = new Set();
2409
+ for (const target of killTargets) {
2410
+ if (target.pid) {
2411
+ try {
2412
+ const safePid = shared.validatePid(target.pid);
2413
+ if (process.platform === 'win32') {
2414
+ require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
2415
+ } else {
2416
+ process.kill(safePid, 'SIGTERM');
2417
+ }
2418
+ } catch { /* process may be dead or invalid PID */ }
2429
2419
  }
2420
+ // Reset agent status file (no lock needed — agent-specific file).
2421
+ try {
2422
+ const agentStatus = safeJsonObj(target.statusPath);
2423
+ agentStatus.status = 'idle';
2424
+ delete agentStatus.currentTask;
2425
+ delete agentStatus.dispatched;
2426
+ safeWrite(target.statusPath, agentStatus);
2427
+ } catch (e) { console.error('agent reset:', e.message); }
2428
+ killedAgents.add(target.agent);
2429
+ }
2430
+
2431
+ // Step 4: Mutate work-items.json per path — pause items (each lock held briefly, no nesting).
2432
+ let reset = 0;
2433
+ const resetItemIds = new Set();
2434
+ for (const wiPath of wiPaths) {
2435
+ try {
2436
+ mutateWorkItems(wiPath, items => {
2437
+ for (const w of items) {
2438
+ if (w.sourcePlan !== body.file) continue;
2439
+ if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
2440
+ if (w.status !== WI_STATUS.PAUSED) reset++;
2441
+ w.status = WI_STATUS.PAUSED;
2442
+ w._pausedBy = 'prd-pause';
2443
+ delete w._resumedAt;
2444
+ delete w.dispatched_at;
2445
+ delete w.dispatched_to;
2446
+ delete w.failReason;
2447
+ delete w.failedAt;
2448
+ if (w.id) resetItemIds.add(w.id);
2449
+ }
2450
+ });
2451
+ } catch (e) { console.error('reset work items:', e.message); }
2452
+ }
2430
2453
 
2431
- // Remove dispatch active entries for reset items or killed agents.
2432
- dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
2433
- dispatch.active = dispatch.active.filter(d => {
2454
+ // Step 5: Re-acquire dispatch lock to clean up active entries (brief lock, no nesting).
2455
+ mutateJsonFileLocked(dispatchPath, (dispatchData) => {
2456
+ dispatchData.active = Array.isArray(dispatchData.active) ? dispatchData.active : [];
2457
+ dispatchData.active = dispatchData.active.filter(d => {
2434
2458
  const itemId = d.meta?.item?.id;
2435
2459
  if (itemId && resetItemIds.has(itemId)) return false;
2436
2460
  if (killedAgents.has(d.agent)) return false;
2437
2461
  return true;
2438
2462
  });
2439
- return dispatch;
2463
+ return dispatchData;
2440
2464
  }, { defaultValue: { pending: [], active: [], completed: [] } });
2441
2465
 
2442
2466
  invalidateStatusCache();
@@ -30,7 +30,7 @@ function mutateDispatch(mutator) {
30
30
  dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
31
31
  dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
32
32
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
33
- return mutator(dispatch) || dispatch;
33
+ return mutator(dispatch) ?? dispatch;
34
34
  }, { defaultValue: defaultDispatch });
35
35
  // Invalidate the read cache so next getDispatch() sees fresh data
36
36
  try { require('./queries').invalidateDispatchCache(); } catch {}
@@ -962,7 +962,7 @@ function findDependentActivePrs(mergedItemId, config) {
962
962
  for (const p of projects) {
963
963
  const prs = safeJson(projectPrPath(p)) || [];
964
964
  for (const pr of prs) {
965
- if (!pr.branch || pr.status !== 'active') continue;
965
+ if (!pr.branch || pr.status !== PR_STATUS.ACTIVE) continue;
966
966
  const linked = (pr.prdItems || []).some(id => dependentWis.some(wi => wi.id === id));
967
967
  if (linked && !results.some(r => r.pr.id === pr.id)) {
968
968
  const wi = dependentWis.find(w => (pr.prdItems || []).includes(w.id));
@@ -1020,25 +1020,31 @@ async function rebaseBranchOntoMain(pr, project, config) {
1020
1020
  const PENDING_REBASES_PATH = path.join(ENGINE_DIR, 'pending-rebases.json');
1021
1021
 
1022
1022
  function queuePendingRebase(pr, project, mergedItemId) {
1023
- const pending = safeJson(PENDING_REBASES_PATH) || [];
1024
- if (pending.some(e => e.prId === pr.id)) return; // already queued
1025
- pending.push({ prId: pr.id, branch: pr.branch, projectName: project.name, mergedItemId, queuedAt: ts(), attempts: 0 });
1026
- safeWrite(PENDING_REBASES_PATH, pending);
1023
+ mutateJsonFileLocked(PENDING_REBASES_PATH, (pending) => {
1024
+ if (pending.some(e => e.prId === pr.id)) return; // already queued
1025
+ pending.push({ prId: pr.id, branch: pr.branch, projectName: project.name, mergedItemId, queuedAt: ts(), attempts: 0 });
1026
+ }, { defaultValue: [] });
1027
1027
  }
1028
1028
 
1029
1029
  async function processPendingRebases(config) {
1030
- const pending = safeJson(PENDING_REBASES_PATH) || [];
1031
- if (pending.length === 0) return;
1030
+ // Atomically drain the queue under lock so concurrent queuePendingRebase calls
1031
+ // during processing don't lose entries (they append to the now-empty file).
1032
+ let snapshot = [];
1033
+ mutateJsonFileLocked(PENDING_REBASES_PATH, (data) => {
1034
+ snapshot = [...data];
1035
+ return []; // drain file
1036
+ }, { defaultValue: [] });
1037
+ if (snapshot.length === 0) return;
1032
1038
 
1033
1039
  const remaining = [];
1034
- for (const entry of pending) {
1040
+ for (const entry of snapshot) {
1035
1041
  if (isBranchActive(entry.branch)) { remaining.push(entry); continue; }
1036
1042
 
1037
1043
  const project = shared.getProjects(config).find(p => p.name === entry.projectName);
1038
1044
  if (!project) continue;
1039
1045
 
1040
1046
  const prs = safeJson(projectPrPath(project)) || [];
1041
- const pr = prs.find(p => p.id === entry.prId && p.status === 'active');
1047
+ const pr = prs.find(p => p.id === entry.prId && p.status === PR_STATUS.ACTIVE);
1042
1048
  if (!pr) continue; // PR closed/merged since queuing
1043
1049
 
1044
1050
  const result = await rebaseBranchOntoMain(pr, project, config);
@@ -1052,7 +1058,12 @@ async function processPendingRebases(config) {
1052
1058
  }
1053
1059
  }
1054
1060
  }
1055
- safeWrite(PENDING_REBASES_PATH, remaining);
1061
+ // Merge remaining items back under lock — entries queued during processing are preserved
1062
+ if (remaining.length > 0) {
1063
+ mutateJsonFileLocked(PENDING_REBASES_PATH, (data) => {
1064
+ data.push(...remaining);
1065
+ }, { defaultValue: [] });
1066
+ }
1056
1067
  }
1057
1068
 
1058
1069
  // ─── Post-Merge / Post-Close Hooks ───────────────────────────────────────────
package/engine/shared.js CHANGED
@@ -8,7 +8,7 @@ const path = require('path');
8
8
 
9
9
  const MINIONS_DIR = process.env.MINIONS_TEST_DIR || path.resolve(__dirname, '..');
10
10
  const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
11
- const LOG_PATH = path.join(__dirname, 'log.json');
11
+ const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
12
12
 
13
13
  // ── Timestamps & Logging ────────────────────────────────────────────────────
14
14
  // Extracted from engine.js so engine/* modules can import directly without
package/engine.js CHANGED
@@ -138,7 +138,7 @@ const { renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS,
138
138
  const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconcilePrdStatuses, handlePostMerge, checkPlanCompletion,
139
139
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
140
140
  updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs,
141
- isItemCompleted, classifyFailure, processPendingRebases } = require('./engine/lifecycle');
141
+ isItemCompleted, classifyFailure, processPendingRebases, resolveWorkItemPath } = require('./engine/lifecycle');
142
142
 
143
143
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
144
144
 
@@ -2485,6 +2485,7 @@ function discoverCentralWorkItems(config) {
2485
2485
 
2486
2486
  const ap = assignedProject || (projects.length > 0 ? projects[0] : null);
2487
2487
  if (!ap) { log('warn', `Fan-out: skipping ${fanKey} — no projects configured`); continue; }
2488
+ const fanBranch = `fan/${item.id}/${agent.id}`;
2488
2489
  const vars = {
2489
2490
  ...buildBaseVars(agent.id, config, ap),
2490
2491
  item_id: item.id,
@@ -2495,6 +2496,7 @@ function discoverCentralWorkItems(config) {
2495
2496
  additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2496
2497
  scope_section: buildProjectContext(projects, assignedProject, true, agent.name, agent.role),
2497
2498
  project_path: ap?.localPath || '',
2499
+ branch_name: fanBranch,
2498
2500
  };
2499
2501
 
2500
2502
  // Build common vars: references, acceptance criteria, notes (ASK only), task context
@@ -2519,7 +2521,7 @@ function discoverCentralWorkItems(config) {
2519
2521
  prompt,
2520
2522
  meta: {
2521
2523
  dispatchKey: fanKey, source: 'central-work-item-fanout', item, parentKey: key,
2522
- branch: `fan/${item.id}/${agent.id}`,
2524
+ branch: fanBranch,
2523
2525
  deadline: item.timeout ? Date.now() + item.timeout : Date.now() + (config.engine?.fanOutTimeout || config.engine?.agentTimeout || DEFAULTS.agentTimeout)
2524
2526
  }
2525
2527
  });
@@ -2564,6 +2566,7 @@ function discoverCentralWorkItems(config) {
2564
2566
  additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2565
2567
  scope_section: buildProjectContext(projects, null, false, agentName, agentRole),
2566
2568
  project_path: firstProject?.localPath || '',
2569
+ branch_name: centralBranch,
2567
2570
  };
2568
2571
  const centralWtPath = firstProject?.localPath
2569
2572
  ? path.resolve(firstProject.localPath, config.engine?.worktreeRoot || '../worktrees', centralBranch)
@@ -2944,6 +2947,10 @@ async function tickInner() {
2944
2947
  for (const project of projects) {
2945
2948
  try {
2946
2949
  const wiPath = projectWorkItemsPath(project);
2950
+ // Collect keys to clear AFTER work-items lock is released (avoid nested locks)
2951
+ const dispatchKeysToClear = [];
2952
+ const cooldownKeysToClear = [];
2953
+
2947
2954
  mutateWorkItems(wiPath, items => {
2948
2955
  let changed = false;
2949
2956
  const failedIds = new Set(items.filter(w => w.status === WI_STATUS.FAILED).map(w => w.id));
@@ -2968,23 +2975,10 @@ async function tickInner() {
2968
2975
  delete item.dispatched_to;
2969
2976
  changed = true;
2970
2977
 
2971
- // Clear completed dispatch entries so isAlreadyDispatched doesn't block re-dispatch
2972
- try {
2973
- const key = `work-${project.name}-${item.id}`;
2974
- mutateDispatch((dp) => {
2975
- dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2976
- return dp;
2977
- });
2978
- } catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
2979
-
2980
- // Clear cooldown so item isn't blocked by exponential backoff
2981
- try {
2982
- const key = `work-${project.name}-${item.id}`;
2983
- if (dispatchCooldowns.has(key)) {
2984
- dispatchCooldowns.delete(key);
2985
- saveCooldowns();
2986
- }
2987
- } catch (e) { log('warn', 'stall recovery clear cooldown: ' + e.message); }
2978
+ // Collect dispatch + cooldown keys for clearing outside lock
2979
+ const key = `work-${project.name}-${item.id}`;
2980
+ dispatchKeysToClear.push(key);
2981
+ cooldownKeysToClear.push(key);
2988
2982
  }
2989
2983
  }
2990
2984
 
@@ -3002,19 +2996,33 @@ async function tickInner() {
3002
2996
  delete dep.failedAt;
3003
2997
  delete dep.dispatched_at;
3004
2998
  delete dep.dispatched_to;
3005
- // Clear dispatch entries for this dependent too
3006
- try {
3007
- const key = `work-${project.name}-${dep.id}`;
3008
- mutateDispatch((dp) => {
3009
- dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
3010
- return dp;
3011
- });
3012
- } catch (e) { log('warn', 'stall recovery clear dependent dispatch: ' + e.message); }
2999
+ // Collect dispatch key for clearing outside lock
3000
+ dispatchKeysToClear.push(`work-${project.name}-${dep.id}`);
3013
3001
  }
3014
3002
  }
3015
3003
  }
3016
3004
  }
3017
3005
  });
3006
+
3007
+ // Clear dispatch entries AFTER work-items lock is released (no nested locks)
3008
+ for (const key of dispatchKeysToClear) {
3009
+ try {
3010
+ mutateDispatch((dp) => {
3011
+ dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
3012
+ return dp;
3013
+ });
3014
+ } catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
3015
+ }
3016
+
3017
+ // Clear cooldowns AFTER work-items lock is released
3018
+ for (const key of cooldownKeysToClear) {
3019
+ try {
3020
+ if (dispatchCooldowns.has(key)) {
3021
+ dispatchCooldowns.delete(key);
3022
+ saveCooldowns();
3023
+ }
3024
+ } catch (e) { log('warn', 'stall recovery clear cooldown: ' + e.message); }
3025
+ }
3018
3026
  } catch (e) { log('warn', 'stall recovery process project: ' + e.message); }
3019
3027
  }
3020
3028
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.909",
3
+ "version": "0.1.911",
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"