@yemi33/minions 0.1.1703 → 0.1.1704

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,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1703 (2026-05-04)
3
+ ## 0.1.1704 (2026-05-04)
4
4
 
5
5
  ### Features
6
+ - Add manual run control for schedules
7
+ - deduplicate work item creation
6
8
  - isolate review metadata
7
9
 
8
10
  ## 0.1.1702 (2026-05-04)
@@ -302,6 +302,7 @@ function renderSchedules(schedules) {
302
302
  '<td>' + enabledBadge + '</td>' +
303
303
  '<td><span class="pr-date">' + escHtml(lastRun) + '</span></td>' +
304
304
  '<td style="white-space:nowrap">' +
305
+ '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();runScheduleNow(\'' + escHtml(s.id) + '\',this)" title="Run now">Run now</button>' +
305
306
  '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';border-color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';margin-right:4px" onclick="event.stopPropagation();toggleScheduleEnabled(\'' + escHtml(s.id) + '\',' + !s.enabled + ')" title="' + (s.enabled ? 'Disable' : 'Enable') + '">' + (s.enabled ? '&#x23F8;' : '&#x25B6;') + '</button>' +
306
307
  '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();openEditScheduleModal(\'' + escHtml(s.id) + '\')" title="Edit">&#x270E;</button>' +
307
308
  '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteSchedule(\'' + escHtml(s.id) + '\')" title="Delete">&#x2715;</button>' +
@@ -336,6 +337,7 @@ function openScheduleDetail(id) {
336
337
 
337
338
  document.getElementById('modal-title').innerHTML = escHtml(s.title || s.id) +
338
339
  ' <div style="display:flex;gap:4px;margin-top:4px">' +
340
+ '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--green)" onclick="runScheduleNow(\'' + escHtml(s.id) + '\',this)">Run now</button>' +
339
341
  '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--blue)" onclick="closeModal();openEditScheduleModal(\'' + escHtml(s.id) + '\')">Edit</button>' +
340
342
  '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + '" onclick="toggleScheduleEnabled(\'' + escHtml(s.id) + '\',' + !s.enabled + ');closeModal()">' + (s.enabled ? 'Disable' : 'Enable') + '</button>' +
341
343
  '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--red)" onclick="deleteSchedule(\'' + escHtml(s.id) + '\');closeModal()">Delete</button>' +
@@ -544,6 +546,26 @@ async function toggleScheduleEnabled(id, enabled) {
544
546
  } catch (e) { _showScheduleError('Toggle error: ' + e.message); refresh(); }
545
547
  }
546
548
 
549
+ async function runScheduleNow(id, btn) {
550
+ if (btn) { btn.textContent = 'Running...'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; }
551
+ try {
552
+ const res = await fetch('/api/schedules/run-now', {
553
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
554
+ body: JSON.stringify({ id })
555
+ });
556
+ if (res.ok) {
557
+ showToast('cmd-toast', 'Schedule run queued', true);
558
+ refresh();
559
+ } else {
560
+ const d = await res.json().catch(() => ({}));
561
+ showToast('cmd-toast', 'Run failed: ' + (d.error || 'unknown'), false);
562
+ }
563
+ } catch (e) {
564
+ showToast('cmd-toast', 'Run error: ' + e.message, false);
565
+ }
566
+ if (btn) { btn.textContent = 'Run now'; btn.style.pointerEvents = ''; btn.style.opacity = ''; }
567
+ }
568
+
547
569
  async function deleteSchedule(id) {
548
570
  if (!confirm('Delete scheduled task "' + id + '"?')) return;
549
571
  showToast('cmd-toast', 'Schedule deleted', true);
@@ -562,4 +584,4 @@ async function deleteSchedule(id) {
562
584
  // Expose _generateScheduleId globally for the inline oninput handler
563
585
  window._generateScheduleId = _generateScheduleId;
564
586
 
565
- window.MinionsSchedules = { renderSchedules, openCreateScheduleModal, openEditScheduleModal, openScheduleDetail, submitSchedule, toggleScheduleEnabled, deleteSchedule, _cronToHuman, _parseNaturalCron, _toggleCronMode, _quickSelectDays, _toggleDayPill, _updateCronPreview, _schedPrev, _schedNext, _schedSetView };
587
+ window.MinionsSchedules = { renderSchedules, openCreateScheduleModal, openEditScheduleModal, openScheduleDetail, submitSchedule, toggleScheduleEnabled, runScheduleNow, deleteSchedule, _cronToHuman, _parseNaturalCron, _toggleCronMode, _quickSelectDays, _toggleDayPill, _updateCronPreview, _schedPrev, _schedNext, _schedSetView };
package/dashboard.js CHANGED
@@ -126,6 +126,124 @@ function copyWorkItemPrFields(item, input, pr = null) {
126
126
  if (pr?.title || input.prTitle) item.prTitle = pr?.title || input.prTitle;
127
127
  if (pr?.url || input.prUrl) item.prUrl = pr?.url || input.prUrl;
128
128
  }
129
+
130
+ function normalizeWorkItemDedupText(value) {
131
+ return String(value == null ? '' : value)
132
+ .replace(/\r\n/g, '\n')
133
+ .replace(/[ \t]+$/gm, '')
134
+ .trim();
135
+ }
136
+
137
+ function resolveWorkItemDedupProject(item, wiPath = '') {
138
+ const projectName = normalizeWorkItemDedupText(item?.project || item?._project || item?._source);
139
+ if (projectName) {
140
+ const namedProject = PROJECTS.find(p => p?.name === projectName);
141
+ if (namedProject) return namedProject;
142
+ }
143
+ if (!wiPath) return null;
144
+ const resolvedWiPath = path.resolve(wiPath);
145
+ return PROJECTS.find(p => path.resolve(shared.projectWorkItemsPath(p)) === resolvedWiPath) || null;
146
+ }
147
+
148
+ function getWorkItemPrRefCandidates(item) {
149
+ return [
150
+ item?.targetPr,
151
+ item?.pr,
152
+ item?.prId,
153
+ item?.pullRequest,
154
+ item?.sourcePr,
155
+ item?.prUrl,
156
+ item?.pr_id,
157
+ item?.prNumber,
158
+ ].filter(value => value != null && value !== '');
159
+ }
160
+
161
+ function normalizeWorkItemDedupPrIdentity(item, project = null) {
162
+ if (!item || typeof item !== 'object') return '';
163
+ const candidates = getWorkItemPrRefCandidates(item);
164
+ for (const candidate of candidates) {
165
+ const scoped = shared.getPrScopeInfo(candidate);
166
+ if (scoped) return `${scoped.scope}#${scoped.prNumber}`;
167
+ }
168
+ const prNumber = candidates.reduce((found, candidate) => (
169
+ found ?? shared.getPrNumber(candidate)
170
+ ), null);
171
+ if (project && prNumber != null) {
172
+ const canonical = shared.getCanonicalPrId(project, prNumber);
173
+ if (canonical) return canonical;
174
+ }
175
+ if (prNumber != null) return `PR-${prNumber}`;
176
+ const prRef = getWorkItemPrRef(item) || item.pr_id || item.targetPr || item.prUrl || '';
177
+ return normalizeWorkItemDedupText(prRef).toLowerCase();
178
+ }
179
+
180
+ function workItemCreateFingerprint(item, options = {}) {
181
+ const project = resolveWorkItemDedupProject(item, options.wiPath);
182
+ return {
183
+ title: normalizeWorkItemDedupText(item?.title),
184
+ type: routing.normalizeWorkType(item?.type || item?.workType, WORK_TYPE.IMPLEMENT),
185
+ priority: normalizeWorkItemDedupText(item?.priority || 'medium').toLowerCase(),
186
+ description: normalizeWorkItemDedupText(item?.description),
187
+ prIdentity: normalizeWorkItemDedupPrIdentity(item, project),
188
+ };
189
+ }
190
+
191
+ function isActiveWorkItemCreateStatus(status) {
192
+ return status === WI_STATUS.PENDING || status === WI_STATUS.DISPATCHED || status === WI_STATUS.QUEUED;
193
+ }
194
+
195
+ function isWithinWorkItemCreateDedupWindow(item, nowMs, windowMs) {
196
+ const createdMs = Date.parse(item?.created || item?.createdAt || item?.created_at || '');
197
+ if (!Number.isFinite(createdMs)) return true;
198
+ return nowMs - createdMs <= windowMs;
199
+ }
200
+
201
+ function findDuplicateWorkItemCreate(items, candidate, options = {}) {
202
+ if (!Array.isArray(items)) return null;
203
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
204
+ const windowMs = Number.isFinite(options.windowMs) ? options.windowMs : shared.ENGINE_DEFAULTS.workItemCreateDedupWindowMs;
205
+ const candidateFingerprint = workItemCreateFingerprint(candidate, options);
206
+ return items.find(item => {
207
+ if (!isActiveWorkItemCreateStatus(item?.status)) return false;
208
+ if (!isWithinWorkItemCreateDedupWindow(item, nowMs, windowMs)) return false;
209
+ const existingFingerprint = workItemCreateFingerprint(item, options);
210
+ return existingFingerprint.title === candidateFingerprint.title &&
211
+ existingFingerprint.type === candidateFingerprint.type &&
212
+ existingFingerprint.priority === candidateFingerprint.priority &&
213
+ existingFingerprint.description === candidateFingerprint.description &&
214
+ existingFingerprint.prIdentity === candidateFingerprint.prIdentity;
215
+ }) || null;
216
+ }
217
+
218
+ function createWorkItemWithDedup(wiPath, item, options = {}) {
219
+ let result = null;
220
+ mutateWorkItems(wiPath, items => {
221
+ const existing = findDuplicateWorkItemCreate(items, item, { ...options, wiPath });
222
+ if (existing) {
223
+ result = { created: false, item: existing, duplicateOf: existing.id };
224
+ return items;
225
+ }
226
+ items.push(item);
227
+ result = { created: true, item };
228
+ return items;
229
+ });
230
+ return result || { created: false, item: null };
231
+ }
232
+
233
+ function resolveWorkItemsCreateTarget(projectName, projects = PROJECTS) {
234
+ const project = String(projectName || '').trim();
235
+ let targetProject = null;
236
+ if (project) {
237
+ targetProject = projects.find(p => p.name === project) || (projects.length > 0 ? projects[0] : null);
238
+ if (!targetProject) return { error: 'No projects configured' };
239
+ } else if (projects.length === 1) {
240
+ targetProject = projects[0];
241
+ }
242
+ return {
243
+ project: targetProject,
244
+ wiPath: targetProject ? shared.projectWorkItemsPath(targetProject) : path.join(MINIONS_DIR, 'work-items.json'),
245
+ };
246
+ }
129
247
  function linkPullRequestForTracking({ url, title, project: projectName, autoObserve, context, workItemId }, config = CONFIG, options = {}) {
130
248
  if (!url) {
131
249
  const err = new Error('url required');
@@ -1531,20 +1649,21 @@ async function executeCCActions(actions) {
1531
1649
  // Mark oneShot so any discovered PR is tagged _contextOnly (skips eval loop).
1532
1650
  const ccOneShotTypes = new Set(['review', 'explore', 'test']);
1533
1651
  const isOneShot = action.oneShot === true || (action.oneShot !== false && ccOneShotTypes.has(workType));
1534
- shared.mutateJsonFileLocked(wiPath, items => {
1535
- if (!Array.isArray(items)) items = [];
1536
- const item = {
1537
- id, title: action.title, type: workType,
1538
- priority: action.priority || 'medium', description: action.description || '',
1539
- status: WI_STATUS.PENDING, created: new Date().toISOString(),
1540
- createdBy: 'command-center', project: targetProject?.name || project,
1541
- ...(agentHints.length ? { preferred_agent: agentHints[0], agents: agentHints } : {}),
1542
- ...(isOneShot ? { oneShot: true } : {}),
1543
- };
1544
- copyWorkItemPrFields(item, action, linkedPr);
1545
- items.push(item);
1546
- return items;
1547
- }, { defaultValue: [] });
1652
+ const item = {
1653
+ id, title: action.title.trim(), type: workType,
1654
+ priority: action.priority || 'medium', description: action.description || '',
1655
+ status: WI_STATUS.PENDING, created: new Date().toISOString(),
1656
+ createdBy: 'command-center', project: targetProject?.name || project,
1657
+ ...(agentHints.length ? { preferred_agent: agentHints[0], agents: agentHints } : {}),
1658
+ ...(isOneShot ? { oneShot: true } : {}),
1659
+ };
1660
+ copyWorkItemPrFields(item, action, linkedPr);
1661
+ const createResult = createWorkItemWithDedup(wiPath, item);
1662
+ if (!createResult.created) {
1663
+ const duplicateId = createResult.duplicateOf || createResult.item?.id;
1664
+ results.push({ type: action.type, id: duplicateId, ok: true, duplicate: true, duplicateOf: duplicateId });
1665
+ break;
1666
+ }
1548
1667
  results.push({ type: action.type, id, ok: true });
1549
1668
 
1550
1669
  // Pre-flight routing check: warn the user if no agent is currently available so the new
@@ -2840,22 +2959,17 @@ const server = http.createServer(async (req, res) => {
2840
2959
  try {
2841
2960
  const body = await readBody(req);
2842
2961
  if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
2843
- let wiPath;
2844
- if (body.project) {
2845
- // Write to project-specific queue
2846
- const targetProject = PROJECTS.find(p => p.name === body.project) || (PROJECTS.length > 0 ? PROJECTS[0] : null);
2847
- if (!targetProject) return jsonReply(res, 400, { error: 'No projects configured' });
2848
- wiPath = shared.projectWorkItemsPath(targetProject);
2849
- } else {
2850
- // Write to central queue — agent decides which project
2851
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
2852
- }
2962
+ const target = resolveWorkItemsCreateTarget(body.project);
2963
+ if (target.error) return jsonReply(res, 400, { error: target.error });
2964
+ const wiPath = target.wiPath;
2965
+ const targetProject = target.project;
2853
2966
  const id = 'W-' + shared.uid();
2854
2967
  const item = {
2855
2968
  id, title: body.title.trim(), type: routing.normalizeWorkType(body.type, WORK_TYPE.IMPLEMENT),
2856
2969
  priority: body.priority || 'medium', description: body.description || '',
2857
2970
  status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
2858
2971
  };
2972
+ if (targetProject) item.project = targetProject.name;
2859
2973
  if (body.scope) item.scope = body.scope;
2860
2974
  // Agent assignment normalization: `agent` and `agents` are routing hints.
2861
2975
  // Use agentLock/hardAgent only for the rare case where an item must wait
@@ -2870,18 +2984,11 @@ const server = http.createServer(async (req, res) => {
2870
2984
  if (body.skipPr) item.skipPr = true;
2871
2985
  if (body.oneShot) item.oneShot = true;
2872
2986
  copyWorkItemPrFields(item, body);
2873
- let dupId = null;
2874
- mutateJsonFileLocked(wiPath, (items) => {
2875
- if (!Array.isArray(items)) items = [];
2876
- const existing = items.find(i =>
2877
- i.title === item.title &&
2878
- (i.status === WI_STATUS.PENDING || i.status === WI_STATUS.DISPATCHED || i.status === WI_STATUS.QUEUED)
2879
- );
2880
- if (existing) { dupId = existing.id; return items; }
2881
- items.push(item);
2882
- return items;
2883
- });
2884
- if (dupId) return jsonReply(res, 200, { ok: true, id: dupId, duplicate: true });
2987
+ const createResult = createWorkItemWithDedup(wiPath, item);
2988
+ if (!createResult.created) {
2989
+ const duplicateId = createResult.duplicateOf || createResult.item?.id;
2990
+ return jsonReply(res, 200, { ok: true, id: duplicateId, duplicate: true, duplicateOf: duplicateId });
2991
+ }
2885
2992
  return jsonReply(res, 200, { ok: true, id });
2886
2993
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2887
2994
  }
@@ -5446,6 +5553,69 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5446
5553
  return jsonReply(res, 200, { ok: true });
5447
5554
  }
5448
5555
 
5556
+ async function handleSchedulesRunNow(req, res) {
5557
+ const body = await readBody(req);
5558
+ const { id } = body;
5559
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
5560
+
5561
+ reloadConfig();
5562
+ const sched = (CONFIG.schedules || []).find(s => s.id === id);
5563
+ if (!sched) return jsonReply(res, 404, { error: 'Schedule not found' });
5564
+
5565
+ const schedulerMod = require('./engine/scheduler');
5566
+ let item;
5567
+ try {
5568
+ item = schedulerMod.createScheduledWorkItem(sched);
5569
+ } catch (e) {
5570
+ return jsonReply(res, 400, { error: e.message });
5571
+ }
5572
+
5573
+ let meeting = null;
5574
+ if (item.type === WORK_TYPE.MEETING) {
5575
+ const { createMeeting } = require('./engine/meeting');
5576
+ meeting = createMeeting({
5577
+ title: item.title,
5578
+ agenda: item.description,
5579
+ participants: Array.isArray(sched.participants) ? sched.participants : [],
5580
+ });
5581
+ } else {
5582
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
5583
+ let duplicate = null;
5584
+ mutateJsonFileLocked(centralPath, (items) => {
5585
+ if (!Array.isArray(items)) items = [];
5586
+ duplicate = items.find(i =>
5587
+ i._scheduleId === item._scheduleId &&
5588
+ i.status !== WI_STATUS.DONE &&
5589
+ i.status !== WI_STATUS.FAILED
5590
+ );
5591
+ if (!duplicate) items.push(item);
5592
+ return items;
5593
+ }, { defaultValue: [] });
5594
+ if (duplicate) {
5595
+ return jsonReply(res, 409, {
5596
+ error: 'Schedule already has an active work item',
5597
+ id: duplicate.id,
5598
+ scheduleId: sched.id,
5599
+ });
5600
+ }
5601
+ }
5602
+
5603
+ const runEntry = schedulerMod.recordScheduleRun(sched.id, item.id);
5604
+ try {
5605
+ shared.mutateControl(control => ({ ...control, _wakeupAt: Date.now() }));
5606
+ } catch (e) {
5607
+ shared.log('warn', `Schedule run-now wakeup failed for ${sched.id}: ${e.message}`);
5608
+ }
5609
+ invalidateStatusCache();
5610
+ return jsonReply(res, 200, {
5611
+ ok: true,
5612
+ id: item.id,
5613
+ scheduleId: sched.id,
5614
+ lastRun: runEntry?.lastRun || null,
5615
+ ...(meeting ? { meetingId: meeting.id } : {}),
5616
+ });
5617
+ }
5618
+
5449
5619
  async function handleSchedulesParseNatural(req, res) {
5450
5620
  const body = await readBody(req);
5451
5621
  const { text } = body;
@@ -6427,6 +6597,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6427
6597
  { method: 'POST', path: '/api/schedules', desc: 'Create a new schedule', params: 'cron, title, id?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesCreate },
6428
6598
  { method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
6429
6599
  { method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
6600
+ { method: 'POST', path: '/api/schedules/run-now', desc: 'Manually enqueue the work item for a schedule', params: 'id', handler: handleSchedulesRunNow },
6430
6601
 
6431
6602
  // Watches
6432
6603
  { method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
@@ -6757,6 +6928,9 @@ module.exports = {
6757
6928
  _resolveSkillReadPath,
6758
6929
  DOC_CHAT_DOCUMENT_DELIMITER,
6759
6930
  _ccValidateAction,
6931
+ _findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
6932
+ _createWorkItemWithDedup: createWorkItemWithDedup,
6933
+ _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
6760
6934
  executeCCActions,
6761
6935
  };
6762
6936
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-04T07:34:20.525Z"
4
+ "cachedAt": "2026-05-04T07:35:19.967Z"
5
5
  }
@@ -140,6 +140,42 @@ function shouldRunNow(schedule, lastRunAt) {
140
140
  return true;
141
141
  }
142
142
 
143
+ function createScheduledWorkItem(sched) {
144
+ if (!sched || !sched.id || !sched.title) {
145
+ throw new Error('schedule id and title are required');
146
+ }
147
+ const workItemId = `sched-${sched.id}-${Date.now()}`;
148
+ return {
149
+ id: workItemId,
150
+ title: resolveScheduleTemplateVars(sched.title),
151
+ type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
152
+ priority: sched.priority || 'medium',
153
+ description: resolveScheduleTemplateVars(sched.description || sched.title),
154
+ status: WI_STATUS.PENDING,
155
+ created: ts(),
156
+ createdBy: 'scheduler',
157
+ agent: sched.agent || null,
158
+ ...(sched.agentLock === true || sched.hardAgent === true ? { agentLock: true } : {}),
159
+ project: sched.project || null,
160
+ _scheduleId: sched.id,
161
+ };
162
+ }
163
+
164
+ function writeScheduleRunEntry(runs, scheduleId, workItemId) {
165
+ const existing = typeof runs[scheduleId] === 'object' && runs[scheduleId] ? runs[scheduleId] : {};
166
+ runs[scheduleId] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId };
167
+ return runs[scheduleId];
168
+ }
169
+
170
+ function recordScheduleRun(scheduleId, workItemId) {
171
+ let entry = null;
172
+ mutateJsonFileLocked(SCHEDULE_RUNS_PATH, (runs) => {
173
+ entry = writeScheduleRunEntry(runs, scheduleId, workItemId);
174
+ return runs;
175
+ }, { defaultValue: {} });
176
+ return entry;
177
+ }
178
+
143
179
  /**
144
180
  * Discover work items from configured schedules.
145
181
  * @param {object} config -- full config object
@@ -164,21 +200,8 @@ function discoverScheduledWork(config) {
164
200
  // Substitute schedule-time template vars (e.g. {{date}}) before the work
165
201
  // item is written — single-pass playbook rendering can't reach placeholders
166
202
  // embedded inside task_description, so they must be resolved up front.
167
- const workItemId = `sched-${sched.id}-${Date.now()}`;
168
- work.push({
169
- id: workItemId,
170
- title: resolveScheduleTemplateVars(sched.title),
171
- type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
172
- priority: sched.priority || 'medium',
173
- description: resolveScheduleTemplateVars(sched.description || sched.title),
174
- status: WI_STATUS.PENDING,
175
- created: ts(),
176
- createdBy: 'scheduler',
177
- agent: sched.agent || null,
178
- ...(sched.agentLock === true || sched.hardAgent === true ? { agentLock: true } : {}),
179
- project: sched.project || null,
180
- _scheduleId: sched.id,
181
- });
203
+ const workItem = createScheduledWorkItem(sched);
204
+ work.push(workItem);
182
205
 
183
206
  // Record run time AND work-item ID at dispatch time — preserve existing
184
207
  // completion fields (lastResult, lastCompletedAt). Writing lastWorkItemId
@@ -186,12 +209,20 @@ function discoverScheduledWork(config) {
186
209
  // the dispatched work item crashes or the engine restarts before
187
210
  // lifecycle.runPostCompletionHooks runs. This is the fix that closes
188
211
  // the double-fire window alongside the same-minute guard (W-mo3zu273f8tm).
189
- const existing = typeof runs[sched.id] === 'object' && runs[sched.id] ? runs[sched.id] : {};
190
- runs[sched.id] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId };
212
+ writeScheduleRunEntry(runs, sched.id, workItem.id);
191
213
  }
192
214
  }, { defaultValue: {} });
193
215
 
194
216
  return work;
195
217
  }
196
218
 
197
- module.exports = { parseCronExpr, parseCronField, shouldRunNow, discoverScheduledWork, resolveScheduleTemplateVars, SCHEDULE_RUNS_PATH };
219
+ module.exports = {
220
+ parseCronExpr,
221
+ parseCronField,
222
+ shouldRunNow,
223
+ discoverScheduledWork,
224
+ createScheduledWorkItem,
225
+ recordScheduleRun,
226
+ resolveScheduleTemplateVars,
227
+ SCHEDULE_RUNS_PATH,
228
+ };
package/engine/shared.js CHANGED
@@ -770,6 +770,7 @@ const ENGINE_DEFAULTS = {
770
770
  worktreeCreateRetries: 1, // retry once on transient timeout/lock races
771
771
  worktreeRoot: '../worktrees',
772
772
  worktreeCountCacheTtl: 30000, // 30s — TTL for cached _countWorktrees() result in dashboard
773
+ workItemCreateDedupWindowMs: 15 * 60 * 1000, // 15min — collapse duplicate CC/API create races
773
774
  idleAlertMinutes: 15,
774
775
  fanOutTimeout: null, // falls back to agentTimeout
775
776
  restartGracePeriod: 1200000, // 20min
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1703",
3
+ "version": "0.1.1704",
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"