@yemi33/minions 0.1.1960 → 0.1.1962
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/engine/lifecycle.js +66 -14
- package/engine/pipeline.js +22 -7
- package/engine/playbook.js +54 -1
- package/engine/shared.js +6 -4
- package/engine/worktree-pool.js +2 -83
- package/engine.js +21 -35
- package/package.json +1 -1
package/engine/lifecycle.js
CHANGED
|
@@ -2332,6 +2332,38 @@ function checkForLearnings(agentId, agentInfo, taskDesc) {
|
|
|
2332
2332
|
log('info', `${agentInfo?.name || agentId} didn't write learnings — no follow-up queued`);
|
|
2333
2333
|
}
|
|
2334
2334
|
|
|
2335
|
+
// E2.a (W-mp7goxe4000p75f7): name-normalised dedup against the personal-scope
|
|
2336
|
+
// skill dir. Strips a trailing `-[a-z0-9]{4,8}` random-suffix on the candidate
|
|
2337
|
+
// and re-checks against existing stems; also does simple prefix-match.
|
|
2338
|
+
// Returns the matched existing stem, or null if no near-duplicate.
|
|
2339
|
+
// Read-only — never deletes or renames existing skills.
|
|
2340
|
+
function _findNearDuplicateSkill(personalSkillRoot, candidateName) {
|
|
2341
|
+
if (!personalSkillRoot || !candidateName) return null;
|
|
2342
|
+
let entries;
|
|
2343
|
+
try {
|
|
2344
|
+
entries = fs.readdirSync(personalSkillRoot, { withFileTypes: true })
|
|
2345
|
+
.filter(d => d.isDirectory())
|
|
2346
|
+
.map(d => d.name);
|
|
2347
|
+
} catch { return null; }
|
|
2348
|
+
if (entries.length === 0) return null;
|
|
2349
|
+
|
|
2350
|
+
// 1. Strip trailing `-[a-z0-9]{4,8}` random suffix and re-check.
|
|
2351
|
+
const stripped = candidateName.replace(/-[a-z0-9]{4,8}$/, '');
|
|
2352
|
+
if (stripped !== candidateName && entries.includes(stripped)) {
|
|
2353
|
+
return stripped;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// 2. Prefix-match against existing stems. Either direction:
|
|
2357
|
+
// - candidate `foo-bar-x4y2` vs existing `foo-bar`
|
|
2358
|
+
// - candidate `foo` vs existing `foo-bar`
|
|
2359
|
+
for (const existing of entries) {
|
|
2360
|
+
if (existing === candidateName) continue;
|
|
2361
|
+
if (candidateName.startsWith(existing + '-')) return existing;
|
|
2362
|
+
if (existing.startsWith(candidateName + '-')) return existing;
|
|
2363
|
+
}
|
|
2364
|
+
return null;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2335
2367
|
function skillWriteTargets(runtimeName, project = null) {
|
|
2336
2368
|
try {
|
|
2337
2369
|
const runtime = resolveRuntime(runtimeName || 'claude');
|
|
@@ -2408,22 +2440,42 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeN
|
|
|
2408
2440
|
const personalSkillRoot = skillWriteTargets(effectiveRuntime).personal;
|
|
2409
2441
|
const skillDir = path.join(personalSkillRoot, name.replace(/[^a-z0-9-]/g, '-'));
|
|
2410
2442
|
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
2411
|
-
if (
|
|
2412
|
-
// Native skill format: only name + description in frontmatter. The
|
|
2413
|
-
// `Auto-extracted` marker is an HTML comment so the dashboard's
|
|
2414
|
-
// autoGenerated detection picks it up without polluting the body
|
|
2415
|
-
// an agent reads.
|
|
2416
|
-
const description = m('description') || m('trigger') || `Auto-extracted skill from ${agentName}`;
|
|
2417
|
-
const body = fmMatch[2] || '';
|
|
2418
|
-
const marker = `<!-- Auto-extracted by ${agentName} on ${dateStamp()} -->`;
|
|
2419
|
-
const ccContent = `---\nname: ${name}\ndescription: ${description}\n---\n\n${marker}\n\n${body.trim()}\n`;
|
|
2420
|
-
if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
|
|
2421
|
-
shared.safeWrite(skillPath, ccContent);
|
|
2422
|
-
try { require('./queries').invalidateSkillsCache(); } catch {}
|
|
2423
|
-
log('info', `Extracted skill "${name}" from ${agentName} → ${skillPath}`);
|
|
2424
|
-
} else {
|
|
2443
|
+
if (fs.existsSync(skillPath)) {
|
|
2425
2444
|
log('info', `Skill "${name}" already exists, skipping`);
|
|
2445
|
+
continue;
|
|
2446
|
+
}
|
|
2447
|
+
// E2.a (W-mp7goxe4000p75f7): catch random-suffix and prefix-match
|
|
2448
|
+
// near-duplicates BEFORE writing. Logs + flags for human review;
|
|
2449
|
+
// no existing skills are deleted or renamed.
|
|
2450
|
+
const nearDup = _findNearDuplicateSkill(personalSkillRoot, name.replace(/[^a-z0-9-]/g, '-'));
|
|
2451
|
+
if (nearDup) {
|
|
2452
|
+
log('warn', `Skill "${name}" appears to be a near-duplicate of "${nearDup}", skipping`);
|
|
2453
|
+
const flaggedPath = path.join(ENGINE_DIR, 'skills-flagged.json');
|
|
2454
|
+
mutateJsonFileLocked(flaggedPath, (data) => {
|
|
2455
|
+
const arr = Array.isArray(data) ? data : [];
|
|
2456
|
+
arr.push({
|
|
2457
|
+
timestamp: ts(),
|
|
2458
|
+
candidate: name,
|
|
2459
|
+
existing: nearDup,
|
|
2460
|
+
agent: agentName,
|
|
2461
|
+
dispatchId: dispatchItem?.id || null,
|
|
2462
|
+
});
|
|
2463
|
+
return arr;
|
|
2464
|
+
});
|
|
2465
|
+
continue;
|
|
2426
2466
|
}
|
|
2467
|
+
// Native skill format: only name + description in frontmatter. The
|
|
2468
|
+
// `Auto-extracted` marker is an HTML comment so the dashboard's
|
|
2469
|
+
// autoGenerated detection picks it up without polluting the body
|
|
2470
|
+
// an agent reads.
|
|
2471
|
+
const description = m('description') || m('trigger') || `Auto-extracted skill from ${agentName}`;
|
|
2472
|
+
const body = fmMatch[2] || '';
|
|
2473
|
+
const marker = `<!-- Auto-extracted by ${agentName} on ${dateStamp()} -->`;
|
|
2474
|
+
const ccContent = `---\nname: ${name}\ndescription: ${description}\n---\n\n${marker}\n\n${body.trim()}\n`;
|
|
2475
|
+
if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
|
|
2476
|
+
shared.safeWrite(skillPath, ccContent);
|
|
2477
|
+
try { require('./queries').invalidateSkillsCache(); } catch {}
|
|
2478
|
+
log('info', `Extracted skill "${name}" from ${agentName} → ${skillPath}`);
|
|
2427
2479
|
|
|
2428
2480
|
}
|
|
2429
2481
|
}
|
package/engine/pipeline.js
CHANGED
|
@@ -8,7 +8,7 @@ const fs = require('fs');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const queries = require('./queries');
|
|
11
|
-
const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
11
|
+
const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
12
12
|
const routing = require('./routing');
|
|
13
13
|
const http = require('http');
|
|
14
14
|
const { parseCronExpr, shouldRunNow } = require('./scheduler');
|
|
@@ -388,13 +388,20 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
388
388
|
const projectSlug = _pipelineProjectSlug(project);
|
|
389
389
|
const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}${projectResolution.projects.length > 1 || project ? '-' + projectSlug : ''}`;
|
|
390
390
|
const wiPath = _pipelineWorkItemsPath(project);
|
|
391
|
+
const wiType = routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE);
|
|
392
|
+
// W-mp8ho6w500034a58: read-only stages don't commit, so a pipeline
|
|
393
|
+
// branch label is meaningless to them — omit it entirely so the
|
|
394
|
+
// dispatcher's read-only fast-path runs without ceremony.
|
|
395
|
+
const wiBranch = READ_ONLY_ROOT_TASK_TYPES.has(wiType)
|
|
396
|
+
? null
|
|
397
|
+
: `pipeline/${run.pipelineId}/${stage.id}`;
|
|
391
398
|
mutateWorkItems(wiPath, workItems => {
|
|
392
399
|
if (workItems.some(w => w.id === id)) { createdIds.push(id); return workItems; }
|
|
393
400
|
workItems.push({
|
|
394
401
|
id,
|
|
395
402
|
title: item.title || stage.title,
|
|
396
403
|
description: item.description || stage.description || '',
|
|
397
|
-
type:
|
|
404
|
+
type: wiType,
|
|
398
405
|
priority: item.priority || stage.priority || 'medium',
|
|
399
406
|
// Agent is a soft routing hint unless agentLock/hardAgent is set.
|
|
400
407
|
...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
|
|
@@ -403,7 +410,7 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
403
410
|
status: WI_STATUS.PENDING,
|
|
404
411
|
created: ts(),
|
|
405
412
|
createdBy: 'pipeline:' + run.pipelineId,
|
|
406
|
-
branch:
|
|
413
|
+
...(wiBranch ? { branch: wiBranch } : {}),
|
|
407
414
|
_pipelineRun: run.runId,
|
|
408
415
|
_pipelineStage: stage.id,
|
|
409
416
|
});
|
|
@@ -430,11 +437,18 @@ function executeTaskStageLegacy(stage, stageState, run, config) {
|
|
|
430
437
|
const item = items[i % items.length];
|
|
431
438
|
const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}`;
|
|
432
439
|
if (workItems.some(w => w.id === id)) { createdIds.push(id); continue; }
|
|
440
|
+
const wiType = routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE);
|
|
441
|
+
// W-mp8ho6w500034a58: read-only stages don't commit, so the branch
|
|
442
|
+
// label is meaningless — omit it so dispatch takes the read-only path
|
|
443
|
+
// without recomputing a worktree placement that will never be used.
|
|
444
|
+
const wiBranch = READ_ONLY_ROOT_TASK_TYPES.has(wiType)
|
|
445
|
+
? null
|
|
446
|
+
: `pipeline/${run.pipelineId}/${stage.id}`;
|
|
433
447
|
workItems.push({
|
|
434
448
|
id,
|
|
435
449
|
title: item.title || stage.title,
|
|
436
450
|
description: item.description || stage.description || '',
|
|
437
|
-
type:
|
|
451
|
+
type: wiType,
|
|
438
452
|
priority: item.priority || stage.priority || 'medium',
|
|
439
453
|
// Agent is a soft routing hint unless agentLock/hardAgent is set.
|
|
440
454
|
...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
|
|
@@ -442,7 +456,7 @@ function executeTaskStageLegacy(stage, stageState, run, config) {
|
|
|
442
456
|
status: WI_STATUS.PENDING,
|
|
443
457
|
created: ts(),
|
|
444
458
|
createdBy: 'pipeline:' + run.pipelineId,
|
|
445
|
-
branch:
|
|
459
|
+
...(wiBranch ? { branch: wiBranch } : {}),
|
|
446
460
|
_pipelineRun: run.runId,
|
|
447
461
|
_pipelineStage: stage.id,
|
|
448
462
|
});
|
|
@@ -571,7 +585,8 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
571
585
|
id: wiId, title: `Convert plan to PRD: ${existingPlanFile}`,
|
|
572
586
|
type: WORK_TYPE.PLAN_TO_PRD, priority: 'high', status: WI_STATUS.PENDING,
|
|
573
587
|
planFile: existingPlanFile, created: ts(), createdBy: 'pipeline:' + run.pipelineId,
|
|
574
|
-
|
|
588
|
+
// W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
|
|
589
|
+
_pipelineRun: run.runId, _pipelineStage: stage.id,
|
|
575
590
|
...(project ? { project: project.name } : {}),
|
|
576
591
|
});
|
|
577
592
|
}
|
|
@@ -665,7 +680,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
665
680
|
planFile: path.basename(filePath),
|
|
666
681
|
created: ts(),
|
|
667
682
|
createdBy: 'pipeline:' + run.pipelineId,
|
|
668
|
-
|
|
683
|
+
// W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
|
|
669
684
|
_pipelineRun: run.runId,
|
|
670
685
|
_pipelineStage: stage.id,
|
|
671
686
|
...(project ? { project: project.name } : {}),
|
package/engine/playbook.js
CHANGED
|
@@ -619,6 +619,54 @@ function buildSystemPrompt(agentId, config, project) {
|
|
|
619
619
|
return prompt;
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
+
// E2.b (W-mp7goxe4000p75f7): build the `## Existing Skills (do not duplicate)`
|
|
623
|
+
// section for buildAgentContext. Lists name + first-line description for every
|
|
624
|
+
// personal-scope/installed-plugin skill discovered by queries.getSkills().
|
|
625
|
+
// Caps at ~2KB by truncating alphabetically with a "_...and N more_" tail.
|
|
626
|
+
// Returns '' when there are no personal-scope skills to advertise.
|
|
627
|
+
const EXISTING_SKILLS_MAX_BYTES = 2048;
|
|
628
|
+
const PERSONAL_SKILL_SCOPES = new Set(['claude-code', 'copilot', 'agent-skill', 'plugin', 'copilot-plugin']);
|
|
629
|
+
|
|
630
|
+
function _buildExistingSkillsSection(config) {
|
|
631
|
+
let skills;
|
|
632
|
+
try {
|
|
633
|
+
skills = queries.getSkills(config) || [];
|
|
634
|
+
} catch { return ''; }
|
|
635
|
+
const personal = skills
|
|
636
|
+
.filter(s => PERSONAL_SKILL_SCOPES.has(s.scope))
|
|
637
|
+
.map(s => ({
|
|
638
|
+
name: String(s.name || '').trim(),
|
|
639
|
+
description: String(s.description || '').split('\n')[0].trim(),
|
|
640
|
+
}))
|
|
641
|
+
.filter(s => s.name)
|
|
642
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
643
|
+
if (personal.length === 0) return '';
|
|
644
|
+
|
|
645
|
+
const header = `## Existing Skills (do not duplicate)\n\nThese personal-scope skills already exist on disk. Do NOT emit a \`\`\`skill block whose name duplicates or near-duplicates one of these — the engine will flag it for human review and skip the write.\n\n`;
|
|
646
|
+
const lines = personal.map(s => s.description ? `- ${s.name} — ${s.description}` : `- ${s.name}`);
|
|
647
|
+
|
|
648
|
+
let body = lines.join('\n') + '\n\n';
|
|
649
|
+
if (Buffer.byteLength(header + body, 'utf8') <= EXISTING_SKILLS_MAX_BYTES) {
|
|
650
|
+
return header + body;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Truncate alphabetically until we fit, leaving room for the tail line.
|
|
654
|
+
let kept = lines.length;
|
|
655
|
+
while (kept > 0) {
|
|
656
|
+
const dropped = lines.length - kept;
|
|
657
|
+
const tail = `_...and ${dropped} more (alphabetical truncation; full list on disk)_\n\n`;
|
|
658
|
+
body = lines.slice(0, kept).join('\n') + '\n' + tail;
|
|
659
|
+
if (Buffer.byteLength(header + body, 'utf8') <= EXISTING_SKILLS_MAX_BYTES) {
|
|
660
|
+
return header + body;
|
|
661
|
+
}
|
|
662
|
+
kept--;
|
|
663
|
+
}
|
|
664
|
+
// Pathological: header alone exceeds the cap. Return at least the header
|
|
665
|
+
// with a tail showing every skill was dropped, so the agent still sees the
|
|
666
|
+
// section heading.
|
|
667
|
+
return header + `_...and ${lines.length} more (alphabetical truncation; full list on disk)_\n\n`;
|
|
668
|
+
}
|
|
669
|
+
|
|
622
670
|
// Bulk context: history, notes, conventions, skills — prepended to user/task prompt.
|
|
623
671
|
// This is the content that grows over time and would bloat the system prompt.
|
|
624
672
|
function buildAgentContext(agentId, config, project) {
|
|
@@ -671,7 +719,12 @@ function buildAgentContext(agentId, config, project) {
|
|
|
671
719
|
|
|
672
720
|
appendIndex('Knowledge Base Reference', getKnowledgeBaseIndex(), 8192);
|
|
673
721
|
|
|
674
|
-
context += `## Reference Files\n\nKnowledge base entries are in \`knowledge/{category}/*.md\`, and project-local playbooks live in \`projects/<project>/playbooks/\`. Runtime-native skills and commands
|
|
722
|
+
context += `## Reference Files\n\nKnowledge base entries are in \`knowledge/{category}/*.md\`, and project-local playbooks live in \`projects/<project>/playbooks/\`. Runtime-native skills and commands live in their native indexes (the catalog of names + descriptions is injected below as "Existing Skills"; full skill bodies are not).\n\n`;
|
|
723
|
+
|
|
724
|
+
// E2.b (W-mp7goxe4000p75f7): inject existing personal-scope skill catalog
|
|
725
|
+
// so agents can see what already exists before emitting a near-duplicate
|
|
726
|
+
// skill block. Caps at ~2KB; truncates alphabetically with a tail count.
|
|
727
|
+
context += _buildExistingSkillsSection(config);
|
|
675
728
|
|
|
676
729
|
// Minions awareness: what's in flight, who's doing what
|
|
677
730
|
const dispatch = getDispatch();
|
package/engine/shared.js
CHANGED
|
@@ -2883,10 +2883,12 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
|
|
|
2883
2883
|
* the drive-root preflight that fires when MINIONS_DIR sits one level
|
|
2884
2884
|
* below a filesystem root (resolveProjectRootDir's collapse case).
|
|
2885
2885
|
*
|
|
2886
|
-
* NOTE: Pipeline branches
|
|
2887
|
-
*
|
|
2888
|
-
*
|
|
2889
|
-
* branch
|
|
2886
|
+
* NOTE (W-mp8ho6w500034a58): Pipeline branches no longer override this.
|
|
2887
|
+
* Read-only pipeline stages don't commit, so a `pipeline/...` branch is a
|
|
2888
|
+
* meaningless label for them — the dispatcher short-circuits read-only WIs
|
|
2889
|
+
* regardless of branch name, and `engine/pipeline.js` now omits the branch
|
|
2890
|
+
* field for read-only stages. Only code-mutating pipeline stages need a
|
|
2891
|
+
* worktree, and they take the normal code-mutating path below.
|
|
2890
2892
|
*
|
|
2891
2893
|
* @param {{ localPath?: string|null }|null|undefined} project
|
|
2892
2894
|
* @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
|
package/engine/worktree-pool.js
CHANGED
|
@@ -148,85 +148,6 @@ function tryBorrow(projectName, dispatchId, opts) {
|
|
|
148
148
|
return borrowed;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
/**
|
|
152
|
-
* Register a brand-new worktree as a borrowed pool member. Used when a fresh
|
|
153
|
-
* worktree is created (no idle slot available) and the pool has room — we
|
|
154
|
-
* track it from creation so the eventual return is a simple state flip.
|
|
155
|
-
*
|
|
156
|
-
* Honors capacity inside the locked mutation: if the project is already at or
|
|
157
|
-
* over `poolSize`, the registration is rejected and the worktree stays
|
|
158
|
-
* untracked (cleanup will reap it normally).
|
|
159
|
-
*/
|
|
160
|
-
function registerBorrowed(projectName, wtPath, dispatchId, opts) {
|
|
161
|
-
opts = opts || {};
|
|
162
|
-
if (!projectName || !wtPath || !dispatchId) return false;
|
|
163
|
-
const poolSize = Number.isFinite(opts.poolSize) ? Math.max(0, Math.floor(opts.poolSize)) : 0;
|
|
164
|
-
if (poolSize <= 0) return false;
|
|
165
|
-
const branch = opts.branch || '';
|
|
166
|
-
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
167
|
-
let registered = false;
|
|
168
|
-
mutateWorktreePool((data) => {
|
|
169
|
-
const entries = data.entries;
|
|
170
|
-
// Already tracked? Update the borrower fields and keep going.
|
|
171
|
-
const existingIdx = _findEntryByPath(entries, wtPath);
|
|
172
|
-
if (existingIdx !== -1) {
|
|
173
|
-
const e = entries[existingIdx];
|
|
174
|
-
e.state = POOL_STATE.BORROWED;
|
|
175
|
-
e.borrowedBy = dispatchId;
|
|
176
|
-
e.borrowedAt = _nowIso(now);
|
|
177
|
-
e.idleSince = null;
|
|
178
|
-
if (branch) e.lastBranch = branch;
|
|
179
|
-
e.lastUsed = e.borrowedAt;
|
|
180
|
-
registered = true;
|
|
181
|
-
return data;
|
|
182
|
-
}
|
|
183
|
-
// Capacity gate — count entries (idle + borrowed) for this project.
|
|
184
|
-
const projectCount = entries.filter(e => e && e.project === projectName).length;
|
|
185
|
-
if (projectCount >= poolSize) return data;
|
|
186
|
-
entries.push({
|
|
187
|
-
project: projectName,
|
|
188
|
-
path: wtPath,
|
|
189
|
-
state: POOL_STATE.BORROWED,
|
|
190
|
-
borrowedBy: dispatchId,
|
|
191
|
-
borrowedAt: _nowIso(now),
|
|
192
|
-
idleSince: null,
|
|
193
|
-
lastBranch: branch,
|
|
194
|
-
createdAt: _nowIso(now),
|
|
195
|
-
lastUsed: _nowIso(now),
|
|
196
|
-
});
|
|
197
|
-
registered = true;
|
|
198
|
-
return data;
|
|
199
|
-
});
|
|
200
|
-
return registered;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Mark a borrowed entry as idle. Caller is responsible for having already run
|
|
205
|
-
* the git reset/clean/checkout-detach/pull dance — this is purely a state
|
|
206
|
-
* flip. If the entry is unknown, no-op (caller can decide to register first).
|
|
207
|
-
*/
|
|
208
|
-
function markIdle(wtPath, opts) {
|
|
209
|
-
opts = opts || {};
|
|
210
|
-
if (!wtPath) return false;
|
|
211
|
-
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
212
|
-
const branch = opts.branch || '';
|
|
213
|
-
let flipped = false;
|
|
214
|
-
mutateWorktreePool((data) => {
|
|
215
|
-
const idx = _findEntryByPath(data.entries, wtPath);
|
|
216
|
-
if (idx === -1) return data;
|
|
217
|
-
const e = data.entries[idx];
|
|
218
|
-
e.state = POOL_STATE.IDLE;
|
|
219
|
-
e.borrowedBy = null;
|
|
220
|
-
e.borrowedAt = null;
|
|
221
|
-
e.idleSince = _nowIso(now);
|
|
222
|
-
e.lastUsed = _nowIso(now);
|
|
223
|
-
if (branch) e.lastBranch = branch;
|
|
224
|
-
flipped = true;
|
|
225
|
-
return data;
|
|
226
|
-
});
|
|
227
|
-
return flipped;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
151
|
/**
|
|
231
152
|
* Return a worktree to the pool — flip an existing borrowed entry to idle, or
|
|
232
153
|
* insert a fresh entry as idle when the project is under capacity. Both paths
|
|
@@ -353,8 +274,8 @@ function pruneStale(opts) {
|
|
|
353
274
|
}
|
|
354
275
|
|
|
355
276
|
/**
|
|
356
|
-
* Count entries (idle + borrowed) belonging to a project. Used
|
|
357
|
-
*
|
|
277
|
+
* Count entries (idle + borrowed) belonging to a project. Used for capacity
|
|
278
|
+
* gating diagnostics and pool-size dashboards.
|
|
358
279
|
*/
|
|
359
280
|
function countForProject(projectName) {
|
|
360
281
|
if (!projectName) return 0;
|
|
@@ -397,8 +318,6 @@ module.exports = {
|
|
|
397
318
|
isPoolMember,
|
|
398
319
|
getEntry,
|
|
399
320
|
tryBorrow,
|
|
400
|
-
registerBorrowed,
|
|
401
|
-
markIdle,
|
|
402
321
|
returnToPool,
|
|
403
322
|
evictEntry,
|
|
404
323
|
pruneStale,
|
package/engine.js
CHANGED
|
@@ -108,10 +108,6 @@ const CHECKPOINT_CAP_FAIL_REASON = 'Exceeded 3 checkpoint-resumes; manual interv
|
|
|
108
108
|
// re-aliased here for the existing call sites in this file.
|
|
109
109
|
const READ_ONLY_ROOT_TASK_TYPES = shared.READ_ONLY_ROOT_TASK_TYPES;
|
|
110
110
|
|
|
111
|
-
function isPipelineBranchName(branchName) {
|
|
112
|
-
return typeof branchName === 'string' && branchName.startsWith('pipeline/');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
111
|
// ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
|
|
116
112
|
|
|
117
113
|
const { mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch,
|
|
@@ -777,8 +773,13 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
777
773
|
// (caller defaults cwd to worktreeRootDir; drive-root collapse throws
|
|
778
774
|
// WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT — same fail-fast behavior as
|
|
779
775
|
// the legacy resolveProjectRootDir call this replaced).
|
|
780
|
-
//
|
|
781
|
-
//
|
|
776
|
+
// W-mp8ho6w500034a58: read-only task types (meeting/ask/explore/plan/plan-to-prd)
|
|
777
|
+
// never need a worktree — even when carrying a pipeline branch. Pipeline branches
|
|
778
|
+
// on read-only stages are a no-op label; the stage doesn't commit anything, so
|
|
779
|
+
// the worktree had no functional purpose and was only there to absorb a drive-
|
|
780
|
+
// root preflight that fired against MINIONS_DIR's parent. Read-only pipeline
|
|
781
|
+
// stages now short-circuit alongside any other read-only WI (see the gate at
|
|
782
|
+
// `if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type))` below).
|
|
782
783
|
const _preBranchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
|
|
783
784
|
let cwd, worktreeRootDir;
|
|
784
785
|
try {
|
|
@@ -799,29 +800,6 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
799
800
|
}
|
|
800
801
|
throw rootErr;
|
|
801
802
|
}
|
|
802
|
-
// Pipeline branches need a worktree even for read-only types (the worktree
|
|
803
|
-
// IS the pipeline's isolated workspace). When we detect a pipeline branch
|
|
804
|
-
// on a read-only type, recompute worktreeRootDir so the worktree creation
|
|
805
|
-
// block has a placement parent — and so the drive-root preflight still fires.
|
|
806
|
-
if (worktreeRootDir === null && isPipelineBranchName(_preBranchName)) {
|
|
807
|
-
try {
|
|
808
|
-
worktreeRootDir = shared.resolveProjectRootDir(project.localPath, MINIONS_DIR);
|
|
809
|
-
} catch (rootErr) {
|
|
810
|
-
if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
|
|
811
|
-
log('error', `spawnAgent: pipeline-branch rootDir resolution failed for ${id}: ${rootErr.message}`);
|
|
812
|
-
completeDispatch(
|
|
813
|
-
id,
|
|
814
|
-
DISPATCH_RESULT.ERROR,
|
|
815
|
-
rootErr.message.slice(0, 800),
|
|
816
|
-
'Pre-spawn worktree preflight rejected — see failure_class for the specific cause.',
|
|
817
|
-
{ failureClass: FAILURE_CLASS.WORKTREE_PREFLIGHT, agentRetryable: false },
|
|
818
|
-
);
|
|
819
|
-
cleanupTempAgent(agentId);
|
|
820
|
-
return null;
|
|
821
|
-
}
|
|
822
|
-
throw rootErr;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
803
|
// Legacy local alias: downstream git ops (worktree add, prune, fetch) and
|
|
826
804
|
// the `cwd === rootDir` safety warn at line ~1387 reference `rootDir`. For
|
|
827
805
|
// read-only rootless tasks (no worktree, no branch) this is null — the
|
|
@@ -910,14 +888,15 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
910
888
|
};
|
|
911
889
|
_phaseT.afterPrompt = Date.now();
|
|
912
890
|
|
|
913
|
-
if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type)
|
|
891
|
+
if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type)) {
|
|
914
892
|
// W-mp7havqf0007ce6b: read-only types (meeting/ask/explore/plan/plan-to-prd)
|
|
915
893
|
// short-circuit BEFORE the worktree-creation block. resolveSpawnPaths returns
|
|
916
894
|
// worktreeRootDir=null for read-only types, and path.resolve(null, ...) throws
|
|
917
|
-
// ("paths[0] must be of type string. Received null"). Pipeline branches
|
|
918
|
-
// exempt — they
|
|
919
|
-
//
|
|
920
|
-
//
|
|
895
|
+
// ("paths[0] must be of type string. Received null"). Pipeline branches used
|
|
896
|
+
// to be exempt — they don't need to be (W-mp8ho6w500034a58): read-only stages
|
|
897
|
+
// never commit, so a pipeline-branch label is meaningless for them and the
|
|
898
|
+
// forced worktree only existed to feed the drive-root preflight that this
|
|
899
|
+
// short-circuit now correctly avoids.
|
|
921
900
|
log('info', `${type}: read-only task with branch ${branchName} — skipping worktree, running in cwd ${cwd}`);
|
|
922
901
|
branchName = null;
|
|
923
902
|
worktreePath = null;
|
|
@@ -990,8 +969,15 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
990
969
|
if (borrowed && borrowed.path && fs.existsSync(borrowed.path)) {
|
|
991
970
|
try { shared.assertWorktreeOutsideProject(borrowed.path, rootDir); }
|
|
992
971
|
catch (assertErr) {
|
|
972
|
+
// Always evict before deciding what to do — leaves no BORROWED
|
|
973
|
+
// orphan tied to a dispatch that won't complete normally.
|
|
974
|
+
// pruneStale would self-heal in ~10 ticks via orphan-borrow, but
|
|
975
|
+
// that window is unnecessary.
|
|
976
|
+
const _evictReason = assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT'
|
|
977
|
+
? 'nested-in-project'
|
|
978
|
+
: 'assert-failed';
|
|
979
|
+
worktreePool.evictEntry(borrowed.path, _evictReason);
|
|
993
980
|
if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') {
|
|
994
|
-
worktreePool.evictEntry(borrowed.path, 'nested-in-project');
|
|
995
981
|
_failWorktreePreflight(assertErr); return null;
|
|
996
982
|
}
|
|
997
983
|
throw assertErr;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1962",
|
|
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"
|