@yemi33/minions 0.1.2170 → 0.1.2172
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/dashboard.js +7 -9
- package/engine/ado.js +8 -3
- package/engine/github.js +51 -3
- package/engine/lifecycle.js +6 -6
- package/engine/playbook.js +2 -2
- package/engine/queries.js +17 -5
- package/engine/scheduler.js +182 -21
- package/engine/shared.js +4 -4
- package/engine/watch-actions.js +72 -0
- package/engine/watches.js +5 -5
- package/engine.js +152 -44
- package/package.json +1 -1
package/dashboard.js
CHANGED
|
@@ -5145,13 +5145,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
5145
5145
|
const wiPath = shared.projectWorkItemsPath(project);
|
|
5146
5146
|
let existingVerify = null;
|
|
5147
5147
|
mutateWorkItems(wiPath, items => {
|
|
5148
|
-
const v = items.find(w => w.sourcePlan === body.file && w.itemType ===
|
|
5149
|
-
if (v && (v.status ===
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
delete v.dispatched_at;
|
|
5154
|
-
v._retryCount = 0;
|
|
5148
|
+
const v = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
|
|
5149
|
+
if (v && (v.status === WI_STATUS.DONE || v.status === WI_STATUS.FAILED)) {
|
|
5150
|
+
// BUG-M10: use shared.reopenWorkItem so re-verify resets mirror
|
|
5151
|
+
// every other re-dispatch path (also sets _reopened=true).
|
|
5152
|
+
reopenWorkItem(v);
|
|
5155
5153
|
existingVerify = v;
|
|
5156
5154
|
} else if (v) {
|
|
5157
5155
|
existingVerify = v;
|
|
@@ -5176,7 +5174,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
5176
5174
|
if (project) {
|
|
5177
5175
|
const wiPath = shared.projectWorkItemsPath(project);
|
|
5178
5176
|
const items = safeJsonArr(wiPath);
|
|
5179
|
-
const verify = items.find(w => w.sourcePlan === body.file && w.itemType ===
|
|
5177
|
+
const verify = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
|
|
5180
5178
|
if (verify) {
|
|
5181
5179
|
invalidateStatusCache();
|
|
5182
5180
|
invalidatePlansCache();
|
|
@@ -5901,7 +5899,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
5901
5899
|
if (body.description !== undefined) wi.description = body.description;
|
|
5902
5900
|
if (body.priority !== undefined) wi.priority = body.priority;
|
|
5903
5901
|
if (body.estimated_complexity !== undefined) {
|
|
5904
|
-
wi.type = body.estimated_complexity === 'large' ?
|
|
5902
|
+
wi.type = body.estimated_complexity === 'large' ? WORK_TYPE.IMPLEMENT_LARGE : WORK_TYPE.IMPLEMENT;
|
|
5905
5903
|
}
|
|
5906
5904
|
workItemSynced = true;
|
|
5907
5905
|
}
|
package/engine/ado.js
CHANGED
|
@@ -1044,7 +1044,12 @@ async function forEachActivePr(config, token, callback) {
|
|
|
1044
1044
|
}
|
|
1045
1045
|
// Remove duplicates — prefer merged/abandoned over active
|
|
1046
1046
|
const bestById = new Map();
|
|
1047
|
-
const statusRank = {
|
|
1047
|
+
const statusRank = {
|
|
1048
|
+
[PR_STATUS.MERGED]: 3,
|
|
1049
|
+
[PR_STATUS.ABANDONED]: 2,
|
|
1050
|
+
[PR_STATUS.CLOSED]: 2,
|
|
1051
|
+
[PR_STATUS.ACTIVE]: 1,
|
|
1052
|
+
};
|
|
1048
1053
|
for (const p of currentPrs) {
|
|
1049
1054
|
const existing = bestById.get(p.id);
|
|
1050
1055
|
if (!existing || (statusRank[p.status] || 0) > (statusRank[existing.status] || 0)) {
|
|
@@ -2046,7 +2051,7 @@ async function reconcilePrs(config) {
|
|
|
2046
2051
|
agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
|
|
2047
2052
|
branch,
|
|
2048
2053
|
reviewStatus: REVIEW_STATUS.PENDING,
|
|
2049
|
-
status:
|
|
2054
|
+
status: PR_STATUS.ACTIVE,
|
|
2050
2055
|
created: adoPr.creationDate || ts(),
|
|
2051
2056
|
url: prUrl,
|
|
2052
2057
|
prdItems: [],
|
|
@@ -2072,7 +2077,7 @@ async function reconcilePrs(config) {
|
|
|
2072
2077
|
agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
|
|
2073
2078
|
branch,
|
|
2074
2079
|
reviewStatus: REVIEW_STATUS.PENDING,
|
|
2075
|
-
status:
|
|
2080
|
+
status: PR_STATUS.ACTIVE,
|
|
2076
2081
|
created: adoPr.creationDate || ts(),
|
|
2077
2082
|
url: prUrl,
|
|
2078
2083
|
prdItems: [confirmedItemId],
|
package/engine/github.js
CHANGED
|
@@ -252,6 +252,33 @@ async function _resolveViewerLogin(slug) {
|
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// OQ-Y (P-oqy-viewer-fail-closed) — Fail-closed counter for null
|
|
256
|
+
// `_resolveViewerLogin` results. When the viewer login can't be resolved,
|
|
257
|
+
// `_isMinionsAuthoredComment` cannot tell minions posts apart from human
|
|
258
|
+
// posts (the `viewerDidAuthor` gate stays false for everything), so the
|
|
259
|
+
// comment classifier reclassifies every minions comment as human and
|
|
260
|
+
// queues a spurious fix dispatch on every poll cycle. The downstream
|
|
261
|
+
// `check-self-authored-review-comment` skill no-ops the dispatch, so this
|
|
262
|
+
// is a slot/token tax rather than state corruption — but the frequency
|
|
263
|
+
// data is worth tracking so future audits can prioritize a deeper fix
|
|
264
|
+
// (e.g. a credentialed re-probe of the gh CLI). Global counter under
|
|
265
|
+
// `_engine.viewerLoginResolutionFailures`; surfaced through the existing
|
|
266
|
+
// engine metrics aggregation (`getMetrics()` in `engine/queries.js`).
|
|
267
|
+
function _incrementViewerLoginResolutionFailures() {
|
|
268
|
+
try {
|
|
269
|
+
const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
|
|
270
|
+
mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
271
|
+
metrics = metrics || {};
|
|
272
|
+
if (!metrics._engine) metrics._engine = {};
|
|
273
|
+
metrics._engine.viewerLoginResolutionFailures =
|
|
274
|
+
(metrics._engine.viewerLoginResolutionFailures || 0) + 1;
|
|
275
|
+
return metrics;
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
log('warn', `telemetry: viewerLoginResolutionFailures metric write failed: ${err?.message || err}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
255
282
|
// Test hook: lets tests prime or clear a cached viewer login per account
|
|
256
283
|
// without shelling out. Pass `(account, null)` to force a re-resolve.
|
|
257
284
|
// W-mp76pw7a001da7c1: signature changed from `(login)` → `(account, login)`
|
|
@@ -536,7 +563,12 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
536
563
|
}
|
|
537
564
|
// Remove duplicates — prefer merged/abandoned over active
|
|
538
565
|
const bestById = new Map();
|
|
539
|
-
const statusRank = {
|
|
566
|
+
const statusRank = {
|
|
567
|
+
[PR_STATUS.MERGED]: 3,
|
|
568
|
+
[PR_STATUS.ABANDONED]: 2,
|
|
569
|
+
[PR_STATUS.CLOSED]: 2,
|
|
570
|
+
[PR_STATUS.ACTIVE]: 1,
|
|
571
|
+
};
|
|
540
572
|
for (const p of currentPrs) {
|
|
541
573
|
const existing = bestById.get(p.id);
|
|
542
574
|
if (!existing || (statusRank[p.status] || 0) > (statusRank[existing.status] || 0)) {
|
|
@@ -1111,6 +1143,21 @@ async function pollPrHumanComments(config) {
|
|
|
1111
1143
|
// account pay only the first shell-out.
|
|
1112
1144
|
const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
|
|
1113
1145
|
const viewerLogin = await _resolveViewerLogin(slug);
|
|
1146
|
+
// OQ-Y (P-oqy-viewer-fail-closed) — Fail-closed when the viewer login
|
|
1147
|
+
// can't be resolved. `_isMinionsAuthoredComment` gates on
|
|
1148
|
+
// `c.viewerDidAuthor === true`, and `_backfillViewerDidAuthor` is a
|
|
1149
|
+
// no-op when viewerLogin is null. Running the classifier without a
|
|
1150
|
+
// login mass-misclassifies every minions comment as human and queues
|
|
1151
|
+
// a redundant fix dispatch on every poll cycle (the downstream
|
|
1152
|
+
// `check-self-authored-review-comment` skill no-ops them, so this is
|
|
1153
|
+
// a slot/token tax — see audit OQ-Y, HIGH not CRITICAL). Skip the
|
|
1154
|
+
// entire round instead. One log + one counter increment per affected
|
|
1155
|
+
// PR per cycle (no per-comment fanout).
|
|
1156
|
+
if (!viewerLogin) {
|
|
1157
|
+
log('warn', `GitHub PR comment poll: skipping comment classification for ${pr.id} (slug=${slug || '<no-slug>'}) — viewer login unresolved`);
|
|
1158
|
+
_incrementViewerLoginResolutionFailures();
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1114
1161
|
// Get issue comments (general PR comments)
|
|
1115
1162
|
const comments = await ghApi(`/issues/${prNum}/comments`, slug);
|
|
1116
1163
|
if (!comments || !Array.isArray(comments)) return false;
|
|
@@ -1392,7 +1439,7 @@ async function reconcilePrs(config) {
|
|
|
1392
1439
|
agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
|
|
1393
1440
|
branch,
|
|
1394
1441
|
reviewStatus: REVIEW_STATUS.PENDING,
|
|
1395
|
-
status:
|
|
1442
|
+
status: PR_STATUS.ACTIVE,
|
|
1396
1443
|
created: ghPr.created_at || ts(),
|
|
1397
1444
|
url: prUrl,
|
|
1398
1445
|
prdItems: [],
|
|
@@ -1418,7 +1465,7 @@ async function reconcilePrs(config) {
|
|
|
1418
1465
|
agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
|
|
1419
1466
|
branch,
|
|
1420
1467
|
reviewStatus: REVIEW_STATUS.PENDING,
|
|
1421
|
-
status:
|
|
1468
|
+
status: PR_STATUS.ACTIVE,
|
|
1422
1469
|
created: ghPr.created_at || ts(),
|
|
1423
1470
|
url: prUrl,
|
|
1424
1471
|
prdItems: [confirmedItemId],
|
|
@@ -1854,6 +1901,7 @@ module.exports = {
|
|
|
1854
1901
|
_resolveViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
|
|
1855
1902
|
_setCachedViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
|
|
1856
1903
|
_backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
|
|
1904
|
+
_incrementViewerLoginResolutionFailures, // exported for testing (P-oqy-viewer-fail-closed)
|
|
1857
1905
|
_setExecAsyncForTest, // W-mp5trwh60008386d: test seam to mock `gh api` shell-outs
|
|
1858
1906
|
_setShellSafeGhForTest, // P-f2-gh-shell (F2): test seam to mock argv-form gh api calls
|
|
1859
1907
|
_setGhApiForTest, // P-f1ghstale: test seam to mock ghApi call sites for stale-signal branch coverage
|
package/engine/lifecycle.js
CHANGED
|
@@ -199,7 +199,7 @@ function checkPlanCompletion(meta, config) {
|
|
|
199
199
|
if (workItems.some(w => w.sourcePlan === planFile && w.itemType === 'pr')) return workItems;
|
|
200
200
|
workItems.push({
|
|
201
201
|
id, title: `Create PR for plan: ${plan.plan_summary || planFile}`,
|
|
202
|
-
type:
|
|
202
|
+
type: WORK_TYPE.IMPLEMENT, priority: 'high',
|
|
203
203
|
description: `All plan items from \`${planFile}\` are complete on branch \`${featureBranch}\`.\n\n**Branch:** \`${featureBranch}\`\n**Target:** \`${mainBranch}\`\n\n## Completed Items\n${itemSummary}`,
|
|
204
204
|
status: WI_STATUS.PENDING, created: ts(), createdBy: 'engine:plan-completion',
|
|
205
205
|
sourcePlan: planFile, itemType: 'pr',
|
|
@@ -246,7 +246,7 @@ function checkPlanCompletion(meta, config) {
|
|
|
246
246
|
// Per-project existing verify lookup. Legacy single-WI plans may have
|
|
247
247
|
// existingVerify.project unset; match those against the primary project name.
|
|
248
248
|
const existingVerify = allWorkItems.find(w =>
|
|
249
|
-
w.sourcePlan === planFile && w.itemType ===
|
|
249
|
+
w.sourcePlan === planFile && w.itemType === WORK_TYPE.VERIFY &&
|
|
250
250
|
(w.project === projName || (!w.project && projName === primaryProject.name)));
|
|
251
251
|
|
|
252
252
|
if (isActiveVerify(existingVerify)) {
|
|
@@ -389,7 +389,7 @@ function checkPlanCompletion(meta, config) {
|
|
|
389
389
|
let created = false;
|
|
390
390
|
mutateWorkItems(vWiPath, workItems => {
|
|
391
391
|
// Re-check under lock to prevent races
|
|
392
|
-
if (workItems.some(w => w.sourcePlan === planFile && w.itemType ===
|
|
392
|
+
if (workItems.some(w => w.sourcePlan === planFile && w.itemType === WORK_TYPE.VERIFY && (w.project === projName || (!w.project && projName === primaryProject.name)))) {
|
|
393
393
|
return workItems;
|
|
394
394
|
}
|
|
395
395
|
workItems.push({
|
|
@@ -397,7 +397,7 @@ function checkPlanCompletion(meta, config) {
|
|
|
397
397
|
title: touchedProjects.length > 1
|
|
398
398
|
? `Verify plan (${projName}): ${(plan.plan_summary || planFile).slice(0, 70)}`
|
|
399
399
|
: `Verify plan: ${(plan.plan_summary || planFile).slice(0, 80)}`,
|
|
400
|
-
type:
|
|
400
|
+
type: WORK_TYPE.VERIFY,
|
|
401
401
|
priority: 'high',
|
|
402
402
|
description,
|
|
403
403
|
status: WI_STATUS.PENDING,
|
|
@@ -3137,7 +3137,7 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeN
|
|
|
3137
3137
|
data = data || [];
|
|
3138
3138
|
if (data.some(i => i.title === `Add skill: ${name}` && i.status !== WI_STATUS.FAILED)) return data;
|
|
3139
3139
|
skillId = `SK${String(data.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
|
|
3140
|
-
data.push({ id: skillId, type:
|
|
3140
|
+
data.push({ id: skillId, type: WORK_TYPE.IMPLEMENT, title: `Add skill: ${name}`,
|
|
3141
3141
|
description: `Create project-level skill \`${skillDirName}/SKILL.md\` in ${project}.\n\nWrite this file to \`${projectSkillPath}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
|
|
3142
3142
|
priority: 'low', status: WI_STATUS.QUEUED, created: ts(), createdBy: `engine:skill-extraction:${agentName}` });
|
|
3143
3143
|
return data;
|
|
@@ -4367,7 +4367,7 @@ function handleDecompositionResult(stdout, meta, config, runtimeName) {
|
|
|
4367
4367
|
const childItem = {
|
|
4368
4368
|
id: sub.id,
|
|
4369
4369
|
title: sub.name || sub.title || `Sub-task of ${parentId}`,
|
|
4370
|
-
type: (sub.estimated_complexity === 'large') ?
|
|
4370
|
+
type: (sub.estimated_complexity === 'large') ? WORK_TYPE.IMPLEMENT_LARGE : WORK_TYPE.IMPLEMENT,
|
|
4371
4371
|
priority: sub.priority || p.priority || 'medium',
|
|
4372
4372
|
description: sub.description || '',
|
|
4373
4373
|
status: WI_STATUS.PENDING,
|
package/engine/playbook.js
CHANGED
|
@@ -755,7 +755,7 @@ function renderPlaybook(type, vars) {
|
|
|
755
755
|
// type === 'review' keeps PR-82 header copy + meta.review.* outcome
|
|
756
756
|
// guidance; everything else uses the generic header that points at
|
|
757
757
|
// meta.skill.* in the completion report.
|
|
758
|
-
projectSkillsBlock = (type ===
|
|
758
|
+
projectSkillsBlock = (type === WORK_TYPE.REVIEW)
|
|
759
759
|
? discover.renderReviewSkillsBlock(filtered)
|
|
760
760
|
: discover.renderProjectSkillsBlock(filtered);
|
|
761
761
|
}
|
|
@@ -1195,7 +1195,7 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
|
|
|
1195
1195
|
...extraVars,
|
|
1196
1196
|
task_id: dispatchId,
|
|
1197
1197
|
};
|
|
1198
|
-
const playbookName = type ===
|
|
1198
|
+
const playbookName = type === WORK_TYPE.TEST ? 'build-and-test' : (type === WORK_TYPE.REVIEW ? 'review' : 'fix');
|
|
1199
1199
|
const prompt = renderPlaybook(playbookName, vars);
|
|
1200
1200
|
if (!prompt) return null;
|
|
1201
1201
|
return {
|
package/engine/queries.js
CHANGED
|
@@ -13,7 +13,7 @@ const steering = require('./steering');
|
|
|
13
13
|
|
|
14
14
|
const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileLocked,
|
|
15
15
|
projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
|
|
16
|
-
WI_STATUS, DONE_STATUSES, PRD_ITEM_STATUS, PR_STATUS, ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
|
|
16
|
+
WI_STATUS, DONE_STATUSES, WORK_TYPE, PRD_ITEM_STATUS, PR_STATUS, ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
|
|
17
17
|
|
|
18
18
|
// ─── Defensive PR enrichment cache (W-mq5uzmc6001d708f) ──────────────────────
|
|
19
19
|
//
|
|
@@ -438,7 +438,12 @@ function getMetrics() {
|
|
|
438
438
|
if (!agent || agent.startsWith('temp-')) continue;
|
|
439
439
|
prCountByAgent[agent] = (prCountByAgent[agent] || 0) + 1;
|
|
440
440
|
if (pr.reviewStatus === shared.REVIEW_STATUS.APPROVED || pr.status === shared.PR_STATUS.MERGED) prApprovedByAgent[agent] = (prApprovedByAgent[agent] || 0) + 1;
|
|
441
|
-
|
|
441
|
+
// BUG-H13 / P-h13-rejected: this used to compare to the literal 'rejected',
|
|
442
|
+
// which never appears in pull-requests.json — the canonical enum value is
|
|
443
|
+
// REVIEW_STATUS.CHANGES_REQUESTED ('changes-requested'). The miscompare
|
|
444
|
+
// permanently zeroed prRejectedByAgent, and the snapshot then clobbered
|
|
445
|
+
// shared.trackReviewMetric()'s cumulative counter at the apply step below.
|
|
446
|
+
if (pr.reviewStatus === shared.REVIEW_STATUS.CHANGES_REQUESTED) prRejectedByAgent[agent] = (prRejectedByAgent[agent] || 0) + 1;
|
|
442
447
|
}
|
|
443
448
|
|
|
444
449
|
// Enrich agent runtime from completed dispatch entries
|
|
@@ -466,7 +471,14 @@ function getMetrics() {
|
|
|
466
471
|
if (prCountByAgent[lower] !== undefined) {
|
|
467
472
|
m.prsCreated = prCountByAgent[lower];
|
|
468
473
|
m.prsApproved = prApprovedByAgent[lower] || 0;
|
|
469
|
-
|
|
474
|
+
// BUG-H13: shared.trackReviewMetric() increments prsRejected on every
|
|
475
|
+
// approved→changes-requested transition (cumulative, never decremented),
|
|
476
|
+
// while the snapshot bucket above is a point-in-time count of PRs still
|
|
477
|
+
// sitting at changes-requested. The two measure different things — a PR
|
|
478
|
+
// that was rejected once and then re-approved is gone from the snapshot
|
|
479
|
+
// but still counted in the transition tracker. Take the max so the
|
|
480
|
+
// snapshot enrichment cannot shadow a strictly larger history value.
|
|
481
|
+
m.prsRejected = Math.max(m.prsRejected || 0, prRejectedByAgent[lower] || 0);
|
|
470
482
|
}
|
|
471
483
|
if (runtimeByAgent[agentId]) {
|
|
472
484
|
// Use dispatch history as source of truth — it has full history
|
|
@@ -1338,7 +1350,7 @@ function getWorkItems(config) {
|
|
|
1338
1350
|
const dispatch = getDispatch();
|
|
1339
1351
|
const activeByWiId = new Map((dispatch.active || []).map(d => [d.meta?.item?.id, d.agent]));
|
|
1340
1352
|
for (const item of allItems) {
|
|
1341
|
-
if (item.status ===
|
|
1353
|
+
if (item.status === WI_STATUS.DISPATCHED && !item.dispatched_to && !item.agent) {
|
|
1342
1354
|
const activeAgent = activeByWiId.get(item.id);
|
|
1343
1355
|
if (activeAgent) item.dispatched_to = activeAgent;
|
|
1344
1356
|
}
|
|
@@ -1666,7 +1678,7 @@ function getPrdInfo(config) {
|
|
|
1666
1678
|
// resolves to, not raw itemIds.length: a PRD item + sibling review-followup
|
|
1667
1679
|
// sub-WIs all resolve to one PRD item and must still render. (W-mpem52qn)
|
|
1668
1680
|
const distinctPrdCount = countDistinctPrdItems(itemIds).size;
|
|
1669
|
-
if (distinctPrdCount > 1 || pr?.itemType ===
|
|
1681
|
+
if (distinctPrdCount > 1 || pr?.itemType === WORK_TYPE.VERIFY || pr?.title?.startsWith('[E2E]')) continue;
|
|
1670
1682
|
const url = buildPrUrlFromId(prId, pr, projects);
|
|
1671
1683
|
for (const itemId of (itemIds || [])) {
|
|
1672
1684
|
if (!prdToPr[itemId]) prdToPr[itemId] = [];
|
package/engine/scheduler.js
CHANGED
|
@@ -30,23 +30,167 @@ const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateS
|
|
|
30
30
|
|
|
31
31
|
const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Compute the ISO 8601 week of the year for `date` as `YYYY-Www` (e.g.
|
|
35
|
+
* 2026-W23). Uses the standard ISO algorithm: Thursday of the target week
|
|
36
|
+
* determines its calendar year; week 1 contains the first Thursday of the
|
|
37
|
+
* year. UTC-based so the value lines up with dateStamp().
|
|
38
|
+
*/
|
|
39
|
+
function isoWeek(date) {
|
|
40
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
41
|
+
// Day of week: Sunday=0..Saturday=6 → ISO Monday=1..Sunday=7.
|
|
42
|
+
const dayNum = d.getUTCDay() || 7;
|
|
43
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum); // shift to Thursday of this week
|
|
44
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
45
|
+
const weekNum = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
|
46
|
+
return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the standard schedule-time template-var map. Vars are values that are
|
|
51
|
+
* KNOWN at schedule firing time (date, week, time, etc.) — not values that
|
|
52
|
+
* only become available later in the dispatch pipeline (project_name,
|
|
53
|
+
* branch_name, agent_id, etc.). Those still resolve in renderPlaybook.
|
|
54
|
+
*
|
|
55
|
+
* Caller-supplied `extraVars` override the time-based defaults so test
|
|
56
|
+
* harnesses can pin a specific date and so future callers can pass extras
|
|
57
|
+
* like `schedule_id`.
|
|
58
|
+
*/
|
|
59
|
+
function buildScheduleTemplateVars(extraVars) {
|
|
60
|
+
const now = new Date();
|
|
61
|
+
const iso = now.toISOString();
|
|
62
|
+
const vars = {
|
|
63
|
+
date: dateStamp(), // 2026-06-11
|
|
64
|
+
year: iso.slice(0, 4), // 2026
|
|
65
|
+
month: iso.slice(5, 7), // 06
|
|
66
|
+
day: iso.slice(8, 10), // 11
|
|
67
|
+
time: iso.slice(11, 16), // HH:MM (UTC)
|
|
68
|
+
datetime: iso, // 2026-06-11T01:23:45.678Z
|
|
69
|
+
week: isoWeek(now), // 2026-W24
|
|
70
|
+
};
|
|
71
|
+
if (extraVars && typeof extraVars === 'object') {
|
|
72
|
+
for (const [k, v] of Object.entries(extraVars)) {
|
|
73
|
+
if (v === undefined || v === null) continue;
|
|
74
|
+
vars[k] = String(v);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return vars;
|
|
78
|
+
}
|
|
79
|
+
|
|
33
80
|
/**
|
|
34
81
|
* Substitute schedule-time template variables in a string.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
82
|
+
*
|
|
83
|
+
* Supported vars (BUG-H8 + BUG-H9 expansion of the original `{{date}}`-only
|
|
84
|
+
* helper):
|
|
85
|
+
* {{date}} — today's date as YYYY-MM-DD (UTC, via dateStamp())
|
|
86
|
+
* {{year}} — UTC year as YYYY
|
|
87
|
+
* {{month}} — UTC month as MM (zero-padded)
|
|
88
|
+
* {{day}} — UTC day of month as DD (zero-padded)
|
|
89
|
+
* {{time}} — UTC time as HH:MM
|
|
90
|
+
* {{datetime}} — full ISO timestamp (UTC, ms precision)
|
|
91
|
+
* {{week}} — ISO 8601 week as YYYY-Www (UTC)
|
|
92
|
+
*
|
|
93
|
+
* Plus any caller-supplied `extraVars` (string-coerced). Callers should only
|
|
94
|
+
* pass vars known at schedule time — pipeline-time vars like {{project_name}}
|
|
95
|
+
* stay in engine/playbook.js where the dispatch context is available.
|
|
37
96
|
*
|
|
38
97
|
* Downstream playbook rendering (engine/playbook.js) is a single-pass replace,
|
|
39
|
-
* so any {{
|
|
98
|
+
* so any {{var}} embedded in a schedule's title/description would survive
|
|
40
99
|
* substitution of {{task_description}} and surface as an "unresolved template
|
|
41
|
-
* variables:
|
|
42
|
-
* Resolve these fields at schedule time so the work item carries
|
|
43
|
-
*
|
|
100
|
+
* variables: var" warning plus a literal "{{var}}" in agent filenames.
|
|
101
|
+
* Resolve these fields at schedule time so the work item carries concrete
|
|
102
|
+
* strings from the moment it's created.
|
|
103
|
+
*
|
|
104
|
+
* Unknown placeholders are left intact (renderPlaybook still warns on them).
|
|
44
105
|
*
|
|
45
106
|
* Safe on undefined/null/empty/non-string inputs — returns the input unchanged.
|
|
107
|
+
*
|
|
108
|
+
* @param {*} str
|
|
109
|
+
* @param {object} [extraVars] optional caller-supplied vars (e.g. { schedule_id }).
|
|
110
|
+
* Can also be a pre-built var map from
|
|
111
|
+
* buildScheduleTemplateVars() to share one
|
|
112
|
+
* "now" snapshot across a recursive walk.
|
|
46
113
|
*/
|
|
47
|
-
function resolveScheduleTemplateVars(str) {
|
|
114
|
+
function resolveScheduleTemplateVars(str, extraVars) {
|
|
48
115
|
if (typeof str !== 'string' || str.length === 0) return str;
|
|
49
|
-
|
|
116
|
+
if (str.indexOf('{{') === -1) return str;
|
|
117
|
+
const vars = (extraVars && extraVars.__scheduleVars) ? extraVars : buildScheduleTemplateVars(extraVars);
|
|
118
|
+
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
119
|
+
return Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Recursively walk `obj` and substitute schedule-time template variables in
|
|
125
|
+
* every string-valued field in place. Returns `obj`.
|
|
126
|
+
*
|
|
127
|
+
* Walks own enumerable properties of plain objects and the items of plain
|
|
128
|
+
* arrays. Strings are replaced via resolveScheduleTemplateVars; null,
|
|
129
|
+
* primitives, Dates, RegExps, Buffers, Maps, Sets, functions, and any
|
|
130
|
+
* value with a custom prototype are left untouched.
|
|
131
|
+
*
|
|
132
|
+
* Cycle-safe: tracks visited objects so a self-referencing work-item field
|
|
133
|
+
* won't infinite-loop.
|
|
134
|
+
*
|
|
135
|
+
* Why a recursive walker exists (BUG-H8 + BUG-H9): scheduler's original fix
|
|
136
|
+
* only substituted `title` / `description` / `harness_rubric`. Other vars
|
|
137
|
+
* embedded in nested fields (e.g. `_harness.rubric`, `metadata.notes`,
|
|
138
|
+
* `references[].url`) survive untouched and surface as unresolved-template
|
|
139
|
+
* warnings or literal `{{var}}` strings in agent filenames. Apply this to
|
|
140
|
+
* each work item the scheduler hands off to dispatch.
|
|
141
|
+
*
|
|
142
|
+
* @param {*} obj work item or nested object/array to walk in place
|
|
143
|
+
* @param {object} [extraVars] optional caller-supplied vars passed through to
|
|
144
|
+
* resolveScheduleTemplateVars. A single var snapshot
|
|
145
|
+
* is taken at the top of the walk so every field
|
|
146
|
+
* sees the same "now".
|
|
147
|
+
*/
|
|
148
|
+
function applyScheduleTemplateVarsRecursive(obj, extraVars) {
|
|
149
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
150
|
+
// Take one snapshot of the var map so every field in the same walk sees
|
|
151
|
+
// an identical "now". Flag-mark it so resolveScheduleTemplateVars reuses
|
|
152
|
+
// it instead of rebuilding per call.
|
|
153
|
+
const vars = buildScheduleTemplateVars(extraVars);
|
|
154
|
+
Object.defineProperty(vars, '__scheduleVars', { value: true, enumerable: false });
|
|
155
|
+
const seen = new WeakSet();
|
|
156
|
+
|
|
157
|
+
function walk(node) {
|
|
158
|
+
if (node === null || typeof node !== 'object') return;
|
|
159
|
+
if (seen.has(node)) return;
|
|
160
|
+
seen.add(node);
|
|
161
|
+
|
|
162
|
+
if (Array.isArray(node)) {
|
|
163
|
+
for (let i = 0; i < node.length; i++) {
|
|
164
|
+
const v = node[i];
|
|
165
|
+
if (typeof v === 'string') {
|
|
166
|
+
node[i] = resolveScheduleTemplateVars(v, vars);
|
|
167
|
+
} else if (v !== null && typeof v === 'object') {
|
|
168
|
+
// Only recurse into plain objects/arrays — leave Date, Buffer, etc. alone.
|
|
169
|
+
const proto = Object.getPrototypeOf(v);
|
|
170
|
+
if (proto === Object.prototype || proto === Array.prototype || proto === null) {
|
|
171
|
+
walk(v);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Plain object: walk own enumerable string-keyed props.
|
|
179
|
+
for (const key of Object.keys(node)) {
|
|
180
|
+
const v = node[key];
|
|
181
|
+
if (typeof v === 'string') {
|
|
182
|
+
node[key] = resolveScheduleTemplateVars(v, vars);
|
|
183
|
+
} else if (v !== null && typeof v === 'object') {
|
|
184
|
+
const proto = Object.getPrototypeOf(v);
|
|
185
|
+
if (proto === Object.prototype || proto === Array.prototype || proto === null) {
|
|
186
|
+
walk(v);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
walk(obj);
|
|
193
|
+
return obj;
|
|
50
194
|
}
|
|
51
195
|
|
|
52
196
|
// Parse a single cron field into a matcher function.
|
|
@@ -171,12 +315,12 @@ function createScheduledWorkItem(sched) {
|
|
|
171
315
|
throw new Error('schedule id and title are required');
|
|
172
316
|
}
|
|
173
317
|
const workItemId = `sched-${sched.id}-${Date.now()}`;
|
|
174
|
-
|
|
318
|
+
const workItem = {
|
|
175
319
|
id: workItemId,
|
|
176
|
-
title:
|
|
320
|
+
title: sched.title,
|
|
177
321
|
type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
|
|
178
322
|
priority: sched.priority || 'medium',
|
|
179
|
-
description:
|
|
323
|
+
description: sched.description || sched.title,
|
|
180
324
|
status: WI_STATUS.PENDING,
|
|
181
325
|
created: ts(),
|
|
182
326
|
createdBy: 'scheduler',
|
|
@@ -185,6 +329,12 @@ function createScheduledWorkItem(sched) {
|
|
|
185
329
|
project: sched.project || null,
|
|
186
330
|
_scheduleId: sched.id,
|
|
187
331
|
};
|
|
332
|
+
// Walk every string-valued field on the work item so vars embedded in
|
|
333
|
+
// nested fields (e.g. _harness.rubric added by callers, references[].url,
|
|
334
|
+
// ad-hoc metadata) all resolve before dispatch — not just title/description.
|
|
335
|
+
// Single-pass renderPlaybook can't reach into nested fields via
|
|
336
|
+
// {{task_description}} expansion (BUG-H8 + BUG-H9).
|
|
337
|
+
return applyScheduleTemplateVarsRecursive(workItem, { schedule_id: sched.id });
|
|
188
338
|
}
|
|
189
339
|
|
|
190
340
|
function writeScheduleRunEntry(runs, scheduleId, workItemId, extra) {
|
|
@@ -235,17 +385,26 @@ function discoverScheduledWork(config) {
|
|
|
235
385
|
continue;
|
|
236
386
|
}
|
|
237
387
|
try {
|
|
238
|
-
// Resolve schedule-time template variables on the
|
|
239
|
-
// BEFORE handing
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
388
|
+
// Resolve schedule-time template variables on the schedule fields
|
|
389
|
+
// BEFORE handing them to the harness builder so subtask prompts
|
|
390
|
+
// inherit the same substitutions. The recursive walker catches any
|
|
391
|
+
// string field — not just title / description / harness_rubric —
|
|
392
|
+
// so e.g. caller-added metadata also resolves (BUG-H8 + BUG-H9).
|
|
393
|
+
const resolvedSched = applyScheduleTemplateVarsRecursive(
|
|
394
|
+
{ ...sched },
|
|
395
|
+
{ schedule_id: sched.id },
|
|
396
|
+
);
|
|
397
|
+
if (typeof resolvedSched.description !== 'string' || resolvedSched.description.length === 0) {
|
|
398
|
+
resolvedSched.description = resolvedSched.title;
|
|
399
|
+
}
|
|
247
400
|
const mission = harness.createTriAgentMission(resolvedSched);
|
|
248
|
-
|
|
401
|
+
// Walk each generated harness work item too — _harness.rubric and
|
|
402
|
+
// other nested fields can carry surviving placeholders that the
|
|
403
|
+
// resolved-schedule step above doesn't reach.
|
|
404
|
+
for (const it of mission.items) {
|
|
405
|
+
applyScheduleTemplateVarsRecursive(it, { schedule_id: sched.id });
|
|
406
|
+
work.push(it);
|
|
407
|
+
}
|
|
249
408
|
// Record the mission's planner id as lastWorkItemId for compatibility
|
|
250
409
|
// with the existing schedule-runs shape, plus lastMissionId so the
|
|
251
410
|
// dashboard and consolidation tooling can join across the trio.
|
|
@@ -288,5 +447,7 @@ module.exports = {
|
|
|
288
447
|
recordScheduleRun,
|
|
289
448
|
writeScheduleRunEntry,
|
|
290
449
|
resolveScheduleTemplateVars,
|
|
450
|
+
applyScheduleTemplateVarsRecursive,
|
|
451
|
+
buildScheduleTemplateVars,
|
|
291
452
|
SCHEDULE_RUNS_PATH,
|
|
292
453
|
};
|
package/engine/shared.js
CHANGED
|
@@ -3629,13 +3629,13 @@ function mutateMetrics(mutator) {
|
|
|
3629
3629
|
|
|
3630
3630
|
/** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
|
|
3631
3631
|
function trackReviewMetric(pr, newReviewStatus, config) {
|
|
3632
|
-
if (newReviewStatus !==
|
|
3632
|
+
if (newReviewStatus !== REVIEW_STATUS.APPROVED && newReviewStatus !== REVIEW_STATUS.CHANGES_REQUESTED) return;
|
|
3633
3633
|
const authorId = (pr.agent || '').toLowerCase();
|
|
3634
3634
|
if (!authorId || !config?.agents?.[authorId]) return;
|
|
3635
3635
|
try {
|
|
3636
3636
|
mutateMetrics((metrics) => {
|
|
3637
3637
|
if (!metrics[authorId]) metrics[authorId] = { ...DEFAULT_AGENT_METRICS };
|
|
3638
|
-
if (newReviewStatus ===
|
|
3638
|
+
if (newReviewStatus === REVIEW_STATUS.APPROVED) metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
|
|
3639
3639
|
else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
|
|
3640
3640
|
return metrics;
|
|
3641
3641
|
});
|
|
@@ -3659,7 +3659,7 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
|
|
|
3659
3659
|
let item = null;
|
|
3660
3660
|
mutateJsonFileLocked(centralWiPath, items => {
|
|
3661
3661
|
if (!Array.isArray(items)) items = [];
|
|
3662
|
-
const existing = items.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === planFile && (w.status ===
|
|
3662
|
+
const existing = items.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === planFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED));
|
|
3663
3663
|
if (existing) {
|
|
3664
3664
|
id = existing.id;
|
|
3665
3665
|
item = existing;
|
|
@@ -3671,7 +3671,7 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
|
|
|
3671
3671
|
type: WORK_TYPE.PLAN_TO_PRD,
|
|
3672
3672
|
priority: 'high',
|
|
3673
3673
|
description,
|
|
3674
|
-
status:
|
|
3674
|
+
status: WI_STATUS.PENDING,
|
|
3675
3675
|
created: new Date().toISOString(),
|
|
3676
3676
|
createdBy,
|
|
3677
3677
|
project,
|
package/engine/watch-actions.js
CHANGED
|
@@ -26,6 +26,7 @@ const path = require('path');
|
|
|
26
26
|
const fs = require('fs');
|
|
27
27
|
const http = require('http');
|
|
28
28
|
const https = require('https');
|
|
29
|
+
const crypto = require('crypto');
|
|
29
30
|
const { URL } = require('url');
|
|
30
31
|
|
|
31
32
|
const shared = require('./shared');
|
|
@@ -33,6 +34,44 @@ const {
|
|
|
33
34
|
WATCH_ACTION_TYPE, WI_STATUS, WORK_TYPE, DONE_STATUSES, PLAN_STATUS,
|
|
34
35
|
log, ts, uid, mutateWorkItems, mutateJsonFileLocked, projectWorkItemsPath,
|
|
35
36
|
} = shared;
|
|
37
|
+
|
|
38
|
+
// BUG-H10 (P-h10-watch-flood): statuses that mean the dispatched WI is still
|
|
39
|
+
// "live" — pending or in-flight — and should block a second dispatch for the
|
|
40
|
+
// same watch+target+condition+action identity. Terminal statuses (done,
|
|
41
|
+
// failed, cancelled, decomposed) DO NOT block; if the previous attempt
|
|
42
|
+
// terminated, a retrigger is allowed to fire fresh work.
|
|
43
|
+
const WATCH_DEDUP_ACTIVE_STATUSES = new Set([
|
|
44
|
+
WI_STATUS.PENDING,
|
|
45
|
+
WI_STATUS.DISPATCHED,
|
|
46
|
+
WI_STATUS.QUEUED,
|
|
47
|
+
WI_STATUS.PAUSED,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// BUG-H10 — derive a deterministic dedup key for a dispatched-work-item action
|
|
51
|
+
// invocation. Inputs are the stable identity of the trigger (watchId + target
|
|
52
|
+
// + condition) plus the resolved WI shape (title/type/project/agent). The key
|
|
53
|
+
// must NOT include Date.now()/uid()/triggerCount so the same logical work
|
|
54
|
+
// produces the same key across ticks and engine restarts.
|
|
55
|
+
//
|
|
56
|
+
// Stored on the dispatched WI under `meta._watchDedupKey`; engine/watch-actions.js
|
|
57
|
+
// scans existing pending+active WIs and skips the push if a match is found.
|
|
58
|
+
function computeWatchDedupKey(watch, dispatchedItemShape) {
|
|
59
|
+
const stable = {
|
|
60
|
+
target: watch && watch.target != null ? String(watch.target) : '',
|
|
61
|
+
targetType: watch && watch.targetType != null ? String(watch.targetType) : '',
|
|
62
|
+
condition: watch && watch.condition != null ? String(watch.condition) : '',
|
|
63
|
+
title: dispatchedItemShape.title || '',
|
|
64
|
+
type: dispatchedItemShape.type || '',
|
|
65
|
+
project: dispatchedItemShape.project || '',
|
|
66
|
+
agent: dispatchedItemShape.agent || '',
|
|
67
|
+
};
|
|
68
|
+
const hash = crypto.createHash('sha256')
|
|
69
|
+
.update(JSON.stringify(stable))
|
|
70
|
+
.digest('hex')
|
|
71
|
+
.slice(0, 16);
|
|
72
|
+
const watchId = (watch && watch.id) ? String(watch.id) : 'unknown';
|
|
73
|
+
return `${watchId}-${hash}`;
|
|
74
|
+
}
|
|
36
75
|
// P-w7c5d8b3 — Phase 3.2: optional guard expressions on action steps.
|
|
37
76
|
// safe-expr.evaluate() never throws and returns Boolean(...) on success or
|
|
38
77
|
// `false` on parse/eval errors (with a `[safe-expr]` warn). That contract
|
|
@@ -384,11 +423,42 @@ registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
|
|
|
384
423
|
if (project) item.project = project;
|
|
385
424
|
if (p.agent) item.agent = String(p.agent);
|
|
386
425
|
|
|
426
|
+
// BUG-H10 — derive a stable dedup key (no Date.now/uid) and stamp it on
|
|
427
|
+
// meta._watchDedupKey so the in-mutator scan below can compare. A
|
|
428
|
+
// persistent watch condition (e.g. stuck-in-stage holding true for an
|
|
429
|
+
// hour at 1-tick-per-minute) would otherwise mint ~60 duplicate WIs.
|
|
430
|
+
const dedupKey = computeWatchDedupKey(watch, {
|
|
431
|
+
title,
|
|
432
|
+
type: item.type,
|
|
433
|
+
project,
|
|
434
|
+
agent: item.agent,
|
|
435
|
+
});
|
|
436
|
+
item.meta = Object.assign({}, item.meta, { _watchDedupKey: dedupKey });
|
|
437
|
+
|
|
387
438
|
let appended = false;
|
|
439
|
+
let dedupedAgainstId = null;
|
|
388
440
|
mutateWorkItems(wiPath, (items) => {
|
|
441
|
+
const existing = items.find(it =>
|
|
442
|
+
it
|
|
443
|
+
&& it.meta
|
|
444
|
+
&& it.meta._watchDedupKey === dedupKey
|
|
445
|
+
&& WATCH_DEDUP_ACTIVE_STATUSES.has(it.status));
|
|
446
|
+
if (existing) {
|
|
447
|
+
dedupedAgainstId = existing.id;
|
|
448
|
+
return items;
|
|
449
|
+
}
|
|
389
450
|
items.push(item);
|
|
390
451
|
appended = true;
|
|
391
452
|
});
|
|
453
|
+
if (dedupedAgainstId) {
|
|
454
|
+
log('info', `Watch ${watch.id} dispatch-work-item deduped against existing WI ${dedupedAgainstId} (key=${dedupKey})`);
|
|
455
|
+
return {
|
|
456
|
+
ok: true,
|
|
457
|
+
summary: `deduped against existing work item ${dedupedAgainstId}`,
|
|
458
|
+
dispatchedItemId: dedupedAgainstId,
|
|
459
|
+
deduped: true,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
392
462
|
if (!appended) return { ok: false, summary: 'dispatch-work-item: write failed' };
|
|
393
463
|
log('info', `Watch ${watch.id} dispatched work item ${id} (${title})`);
|
|
394
464
|
return { ok: true, summary: `dispatched work item ${id}`, dispatchedItemId: id };
|
|
@@ -1117,5 +1187,7 @@ module.exports = {
|
|
|
1117
1187
|
buildTriggerContext,
|
|
1118
1188
|
substituteTemplate,
|
|
1119
1189
|
validateAction,
|
|
1190
|
+
computeWatchDedupKey, // exported for testing (BUG-H10)
|
|
1191
|
+
WATCH_DEDUP_ACTIVE_STATUSES, // exported for testing (BUG-H10)
|
|
1120
1192
|
_ACTION_TYPES: ACTION_TYPES, // exported for testing
|
|
1121
1193
|
};
|
package/engine/watches.js
CHANGED
|
@@ -722,7 +722,7 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
|
|
|
722
722
|
evaluate: (condition, pr, prevState, target) => {
|
|
723
723
|
switch (condition) {
|
|
724
724
|
case WATCH_CONDITION.MERGED:
|
|
725
|
-
return { triggered: pr.status ===
|
|
725
|
+
return { triggered: pr.status === shared.PR_STATUS.MERGED, message: pr.status === shared.PR_STATUS.MERGED ? `PR ${target} was merged` : '' };
|
|
726
726
|
case WATCH_CONDITION.BUILD_FAIL:
|
|
727
727
|
return { triggered: pr.buildStatus === shared.BUILD_STATUS.FAILING, message: pr.buildStatus === shared.BUILD_STATUS.FAILING ? `PR ${target} build is failing` : '' };
|
|
728
728
|
case WATCH_CONDITION.BUILD_PASS:
|
|
@@ -910,7 +910,7 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
|
|
|
910
910
|
});
|
|
911
911
|
|
|
912
912
|
// Meeting — concluded (terminal status) / status-change / any
|
|
913
|
-
const MEETING_TERMINAL = new Set([
|
|
913
|
+
const MEETING_TERMINAL = new Set([shared.MEETING_STATUS.COMPLETED, shared.MEETING_STATUS.ARCHIVED]);
|
|
914
914
|
registerTargetType(WATCH_TARGET_TYPE.MEETING, {
|
|
915
915
|
label: 'Meeting',
|
|
916
916
|
description: 'Watch a meeting for conclusion or status changes',
|
|
@@ -1123,7 +1123,7 @@ function _completedStageCount(run) {
|
|
|
1123
1123
|
let n = 0;
|
|
1124
1124
|
for (const st of Object.values(run.stages)) {
|
|
1125
1125
|
const s = String((st && st.status) || '').toLowerCase();
|
|
1126
|
-
if (s ===
|
|
1126
|
+
if (s === shared.PIPELINE_STATUS.COMPLETED || s === shared.PIPELINE_STATUS.FAILED || s === shared.PIPELINE_STATUS.STOPPED) n += 1;
|
|
1127
1127
|
}
|
|
1128
1128
|
return n;
|
|
1129
1129
|
}
|
|
@@ -1131,7 +1131,7 @@ function _completedStageCount(run) {
|
|
|
1131
1131
|
// in run.stages iteration order. run.stages is built from pipeline.stages
|
|
1132
1132
|
// in declaration order (engine/pipeline.js startRun ~line 84) so the first
|
|
1133
1133
|
// non-terminal entry is the currently-executing or next-pending stage.
|
|
1134
|
-
const _PIPELINE_TERMINAL = new Set([
|
|
1134
|
+
const _PIPELINE_TERMINAL = new Set([shared.PIPELINE_STATUS.COMPLETED, shared.PIPELINE_STATUS.FAILED, shared.PIPELINE_STATUS.STOPPED]);
|
|
1135
1135
|
function _currentPipelineStageId(run) {
|
|
1136
1136
|
if (!run || !run.stages || typeof run.stages !== 'object') return null;
|
|
1137
1137
|
for (const [stageId, st] of Object.entries(run.stages)) {
|
|
@@ -1237,7 +1237,7 @@ function _findDispatchEntry(target, state) {
|
|
|
1237
1237
|
}
|
|
1238
1238
|
return null;
|
|
1239
1239
|
}
|
|
1240
|
-
const DISPATCH_TERMINAL = new Set([
|
|
1240
|
+
const DISPATCH_TERMINAL = new Set([shared.WI_STATUS.DONE, shared.PIPELINE_STATUS.COMPLETED, shared.WI_STATUS.FAILED, shared.WI_STATUS.CANCELLED]);
|
|
1241
1241
|
registerTargetType(WATCH_TARGET_TYPE.DISPATCH, {
|
|
1242
1242
|
label: 'Dispatch',
|
|
1243
1243
|
description: 'Watch a dispatch entry for terminal status or status changes',
|
package/engine.js
CHANGED
|
@@ -31,7 +31,7 @@ const path = require('path');
|
|
|
31
31
|
const crypto = require('crypto');
|
|
32
32
|
const shared = require('./engine/shared');
|
|
33
33
|
const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
|
|
34
|
-
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, DISPATCH_RESULT, AGENT_STATUS,
|
|
34
|
+
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
|
|
35
35
|
FAILURE_CLASS } = shared;
|
|
36
36
|
const { resolveRuntime } = require('./engine/runtimes');
|
|
37
37
|
const { assertStaleHeadOk } = require('./engine/spawn-agent');
|
|
@@ -1734,10 +1734,16 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1734
1734
|
// (dispatch.js, meeting.js, cli.js, cleanup.js, timeout.js) can find the
|
|
1735
1735
|
// dir via shared.dispatchPidCandidates / findDispatchPidFile. Best-effort —
|
|
1736
1736
|
// backward-compat fallbacks still scan dispatch-<safeId>-* dirs by id.
|
|
1737
|
+
//
|
|
1738
|
+
// W-mq9b7lor (H1): must route through mutateDispatch (SQL writer) — a direct
|
|
1739
|
+
// mutateJsonFileLocked on engine/dispatch.json is dropped on the next
|
|
1740
|
+
// mutateDispatch call because engine/dispatch.js#mutateDispatch regenerates
|
|
1741
|
+
// dispatch.json from SQL after every successful mutation (see
|
|
1742
|
+
// engine/dispatch.js:98-101). Without the SQL write, the tmpDir pointer is
|
|
1743
|
+
// lost and orphan-reap/kill/cleanup paths can't find the prompt directory.
|
|
1737
1744
|
try {
|
|
1738
1745
|
dispatchItem.tmpDir = dispatchTmpDir;
|
|
1739
|
-
|
|
1740
|
-
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
1746
|
+
mutateDispatch((dispatch) => {
|
|
1741
1747
|
for (const queue of ['pending', 'active', 'completed']) {
|
|
1742
1748
|
const arr = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
|
|
1743
1749
|
if (!arr) continue;
|
|
@@ -1745,7 +1751,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1745
1751
|
if (found) found.tmpDir = dispatchTmpDir;
|
|
1746
1752
|
}
|
|
1747
1753
|
return dispatch;
|
|
1748
|
-
}
|
|
1754
|
+
});
|
|
1749
1755
|
} catch (e) { log('warn', `spawnAgent: failed to persist tmpDir for ${id}: ${e.message}`); }
|
|
1750
1756
|
const _cleanupPromptFiles = () => { shared.removeDispatchTmpDir(dispatchTmpDir); };
|
|
1751
1757
|
// Convert a WORKTREE_NESTED_IN_PROJECT throw into a fail-fast non-retryable
|
|
@@ -4787,15 +4793,39 @@ function materializePlansAsWorkItems(config) {
|
|
|
4787
4793
|
if (desiredFileName && desiredFileName.toLowerCase() !== String(fileName).toLowerCase()) {
|
|
4788
4794
|
const fromPath = path.join(PRD_DIR, fileName);
|
|
4789
4795
|
const desiredPath = shared.sanitizePath(desiredFileName, PRD_DIR);
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4796
|
+
// W-mq8qdai6 — refuse to rename into a name owned by an archived PRD.
|
|
4797
|
+
// `shared.uniquePath` only scans the live `prd/` directory; without this
|
|
4798
|
+
// guard, an archived PRD with the canonical "<project>-<date>.json" name
|
|
4799
|
+
// would silently collide with the rename target. The dashboard joins
|
|
4800
|
+
// work items to PRDs by basename (sourcePlan === <prd filename>), so a
|
|
4801
|
+
// live↔archive name collision bleeds the archived PRD's done items,
|
|
4802
|
+
// verify task, and PRs into the live PRD's view (the bug-fix-plan vs
|
|
4803
|
+
// minions-opg-2026-06-10 incident).
|
|
4804
|
+
const archiveBasenames = new Set(
|
|
4805
|
+
safeReadDir(path.join(PRD_DIR, 'archive'))
|
|
4806
|
+
.filter(f => f.endsWith('.json'))
|
|
4807
|
+
.map(f => f.toLowerCase())
|
|
4808
|
+
);
|
|
4809
|
+
if (archiveBasenames.has(desiredFileName.toLowerCase())) {
|
|
4810
|
+
log('warn', `Plan project enforcement: skipping rename of ${fileName} to ${desiredFileName} — would collide with archived PRD`);
|
|
4811
|
+
} else {
|
|
4812
|
+
const toPath = shared.uniquePath(desiredPath);
|
|
4813
|
+
const toBasename = path.basename(toPath);
|
|
4814
|
+
if (archiveBasenames.has(toBasename.toLowerCase())) {
|
|
4815
|
+
// uniquePath bumped past a live conflict (e.g. <name>-2.json) but
|
|
4816
|
+
// the bumped name itself is owned by an archived PRD — same risk.
|
|
4817
|
+
log('warn', `Plan project enforcement: skipping rename of ${fileName} to ${toBasename} — would collide with archived PRD`);
|
|
4818
|
+
} else {
|
|
4819
|
+
try {
|
|
4820
|
+
fs.renameSync(fromPath, toPath);
|
|
4821
|
+
nextFileName = toBasename;
|
|
4822
|
+
const migrated = migratePrdFilenameReferences(fileName, nextFileName);
|
|
4823
|
+
if (migrated > 0) log('info', `Plan project enforcement: migrated ${migrated} PRD reference(s) from ${fileName} to ${nextFileName}`);
|
|
4824
|
+
changed = true;
|
|
4825
|
+
} catch (e) {
|
|
4826
|
+
log('warn', `Plan project enforcement: could not rename ${fileName} to ${toBasename}: ${e.message}`);
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4799
4829
|
}
|
|
4800
4830
|
}
|
|
4801
4831
|
if (changed) log('info', `Plan project enforcement: preserved declared project "${declaredProject}" for ${nextFileName}`);
|
|
@@ -5492,11 +5522,11 @@ async function discoverFromPrs(config, project) {
|
|
|
5492
5522
|
const prNumber = shared.getPrNumber(pr);
|
|
5493
5523
|
// Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
|
|
5494
5524
|
// minionsReview tracks metadata (reviewer, note) but not the authoritative status
|
|
5495
|
-
const reviewStatus = pr.reviewStatus ||
|
|
5525
|
+
const reviewStatus = pr.reviewStatus || REVIEW_STATUS.PENDING;
|
|
5496
5526
|
|
|
5497
5527
|
// Skip fix dispatch if a fix was recently submitted and awaiting re-review.
|
|
5498
5528
|
// The poller holds reviewStatus at 'waiting' until the reviewer acts on the new code.
|
|
5499
|
-
const awaitingReReview = reviewStatus ===
|
|
5529
|
+
const awaitingReReview = reviewStatus === REVIEW_STATUS.WAITING && !!pr.minionsReview?.fixedAt;
|
|
5500
5530
|
|
|
5501
5531
|
// F8 (P-f8firstdispatch): hoisted above the first-review block so a
|
|
5502
5532
|
// successful initial-review dispatch suppresses downstream fix dispatches
|
|
@@ -5509,7 +5539,7 @@ async function discoverFromPrs(config, project) {
|
|
|
5509
5539
|
const reviewEnabled = evalLoopEnabled && pollEnabled && autoReviewPrs;
|
|
5510
5540
|
const reReviewEnabled = evalLoopEnabled && pollEnabled && autoReReviewPrs;
|
|
5511
5541
|
const alreadyReviewed = pr.lastReviewedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.lastReviewedAt);
|
|
5512
|
-
const needsReview = reviewEnabled && reviewStatus ===
|
|
5542
|
+
const needsReview = reviewEnabled && reviewStatus === REVIEW_STATUS.PENDING && !alreadyReviewed;
|
|
5513
5543
|
if (needsReview) {
|
|
5514
5544
|
const key = getPrReviewCooldownKey('review', project, pr, prDisplayId);
|
|
5515
5545
|
if (clearLegacyPrReviewCooldown('review', project, pr, prDisplayId, key)) {
|
|
@@ -5521,16 +5551,16 @@ async function discoverFromPrs(config, project) {
|
|
|
5521
5551
|
try {
|
|
5522
5552
|
const checkFn = project.repoHost === 'github' ? ghCheckLiveReview : adoCheckLiveReview;
|
|
5523
5553
|
const liveStatus = await checkFn(pr, project);
|
|
5524
|
-
if (liveStatus && liveStatus !==
|
|
5554
|
+
if (liveStatus && liveStatus !== REVIEW_STATUS.PENDING) {
|
|
5525
5555
|
log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was pending) — skipping review`);
|
|
5526
5556
|
// Never downgrade from approved
|
|
5527
|
-
if (pr.reviewStatus !==
|
|
5557
|
+
if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
|
|
5528
5558
|
// Persist so next tick doesn't re-check
|
|
5529
5559
|
try {
|
|
5530
5560
|
mutateJsonFileLocked(projectPrPath(project), data => {
|
|
5531
5561
|
if (!Array.isArray(data)) return data;
|
|
5532
5562
|
const target = shared.findPrRecord(data, pr, project);
|
|
5533
|
-
if (target && target.reviewStatus !==
|
|
5563
|
+
if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
|
|
5534
5564
|
return data;
|
|
5535
5565
|
});
|
|
5536
5566
|
} catch (e) { log('warn', `persist live vote-check for ${pr.id}: ${e.message}`); }
|
|
@@ -5711,7 +5741,7 @@ async function discoverFromPrs(config, project) {
|
|
|
5711
5741
|
// or when no minions review has completed yet (e.g. human-feedback-only fix path).
|
|
5712
5742
|
const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
|
|
5713
5743
|
(!pr.lastReviewedAt || pr.minionsReview.fixedAt > pr.lastReviewedAt));
|
|
5714
|
-
const needsReReview = reReviewEnabled && reviewStatus ===
|
|
5744
|
+
const needsReReview = reReviewEnabled && reviewStatus === REVIEW_STATUS.WAITING &&
|
|
5715
5745
|
fixedAfterReview && !fixDispatched;
|
|
5716
5746
|
if (needsReReview) {
|
|
5717
5747
|
const key = getPrReviewCooldownKey('rereview', project, pr, prDisplayId);
|
|
@@ -5727,15 +5757,15 @@ async function discoverFromPrs(config, project) {
|
|
|
5727
5757
|
try {
|
|
5728
5758
|
const checkFn = project.repoHost === 'github' ? ghCheckLiveReview : adoCheckLiveReview;
|
|
5729
5759
|
const liveStatus = await checkFn(pr, project);
|
|
5730
|
-
const liveStatusBlocksReReview = liveStatus && liveStatus !==
|
|
5760
|
+
const liveStatusBlocksReReview = liveStatus && liveStatus !== REVIEW_STATUS.WAITING && liveStatus !== REVIEW_STATUS.PENDING;
|
|
5731
5761
|
if (liveStatusBlocksReReview) {
|
|
5732
5762
|
log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was waiting) — skipping re-review`);
|
|
5733
|
-
if (pr.reviewStatus !==
|
|
5763
|
+
if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
|
|
5734
5764
|
try {
|
|
5735
5765
|
mutateJsonFileLocked(projectPrPath(project), data => {
|
|
5736
5766
|
if (!Array.isArray(data)) return data;
|
|
5737
5767
|
const target = shared.findPrRecord(data, pr, project);
|
|
5738
|
-
if (target && target.reviewStatus !==
|
|
5768
|
+
if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
|
|
5739
5769
|
return data;
|
|
5740
5770
|
});
|
|
5741
5771
|
} catch (e) { log('warn', `persist live re-review vote-check for ${pr.id}: ${e.message}`); }
|
|
@@ -5776,7 +5806,7 @@ async function discoverFromPrs(config, project) {
|
|
|
5776
5806
|
|
|
5777
5807
|
// PRs with changes requested → route back to author for fix.
|
|
5778
5808
|
// Gate on evalLoopEnabled and provider polling — the review→fix cycle depends on fresh vote state.
|
|
5779
|
-
if (evalLoopEnabled && pollEnabled && autoFixReviewFeedback && reviewStatus ===
|
|
5809
|
+
if (evalLoopEnabled && pollEnabled && autoFixReviewFeedback && reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED && !awaitingReReview && !fixDispatched
|
|
5780
5810
|
&& !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.REVIEW_FEEDBACK)) {
|
|
5781
5811
|
const reviewCauseKey = getPrAutomationCauseKey('review-feedback', pr);
|
|
5782
5812
|
const key = getPrAutomationDispatchKey(`fix-${project?.name || 'default'}-${prDisplayId}`, reviewCauseKey);
|
|
@@ -6473,6 +6503,16 @@ function discoverFromWorkItems(config, project) {
|
|
|
6473
6503
|
|
|
6474
6504
|
const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
|
|
6475
6505
|
const items = safeJsonArr(projectWorkItemsPath(project));
|
|
6506
|
+
// W-mq9b7lor (H3): snapshot each item's serializable state BEFORE the
|
|
6507
|
+
// discover loop runs so we can compute per-item field deltas at the end
|
|
6508
|
+
// and apply them inside the mutateWorkItems lock — the prior
|
|
6509
|
+
// `mutateWorkItems(..., () => items)` shape ignored the lock-supplied
|
|
6510
|
+
// current state and overwrote any concurrent writes that landed while
|
|
6511
|
+
// discoverWork was iterating (e.g. dashboard PATCHes, completion writes,
|
|
6512
|
+
// sibling-project work-item store mutations sharing the same scope).
|
|
6513
|
+
const _wiPreSnapshots = new Map(
|
|
6514
|
+
items.map(it => [it.id, JSON.parse(JSON.stringify(it))])
|
|
6515
|
+
);
|
|
6476
6516
|
const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
|
|
6477
6517
|
const newWork = [];
|
|
6478
6518
|
// PRD sync for dispatched status deferred to spawnAgent success (#480)
|
|
@@ -6824,8 +6864,47 @@ function discoverFromWorkItems(config, project) {
|
|
|
6824
6864
|
}
|
|
6825
6865
|
|
|
6826
6866
|
// Write back updated statuses (pendingReason clears, checkpoint counts, decompose flags, etc.)
|
|
6867
|
+
// W-mq9b7lor (H3): compute per-item field deltas from the pre-loop snapshot
|
|
6868
|
+
// and apply them INSIDE the mutator against the lock-supplied `current`
|
|
6869
|
+
// array, so concurrent writers (dashboard PATCHes, sibling discoverWork
|
|
6870
|
+
// passes, completion writes routed through the same work-items store
|
|
6871
|
+
// scope) that landed during the discover loop survive. The prior `() =>
|
|
6872
|
+
// items` shape ignored `current` and committed the pre-lock snapshot,
|
|
6873
|
+
// silently overwriting those concurrent changes.
|
|
6827
6874
|
if (needsWrite) {
|
|
6828
|
-
|
|
6875
|
+
const patches = new Map();
|
|
6876
|
+
for (const item of items) {
|
|
6877
|
+
const before = _wiPreSnapshots.get(item.id);
|
|
6878
|
+
if (!before) continue; // safety: discover doesn't add new items
|
|
6879
|
+
const setFields = {};
|
|
6880
|
+
const deleteKeys = [];
|
|
6881
|
+
const allKeys = new Set([...Object.keys(before), ...Object.keys(item)]);
|
|
6882
|
+
for (const k of allKeys) {
|
|
6883
|
+
const hasBefore = Object.prototype.hasOwnProperty.call(before, k);
|
|
6884
|
+
const hasAfter = Object.prototype.hasOwnProperty.call(item, k);
|
|
6885
|
+
if (hasAfter && !hasBefore) {
|
|
6886
|
+
setFields[k] = item[k];
|
|
6887
|
+
} else if (!hasAfter && hasBefore) {
|
|
6888
|
+
deleteKeys.push(k);
|
|
6889
|
+
} else if (JSON.stringify(before[k]) !== JSON.stringify(item[k])) {
|
|
6890
|
+
setFields[k] = item[k];
|
|
6891
|
+
}
|
|
6892
|
+
}
|
|
6893
|
+
if (Object.keys(setFields).length > 0 || deleteKeys.length > 0) {
|
|
6894
|
+
patches.set(item.id, { setFields, deleteKeys });
|
|
6895
|
+
}
|
|
6896
|
+
}
|
|
6897
|
+
if (patches.size > 0) {
|
|
6898
|
+
mutateWorkItems(projectWorkItemsPath(project), (current) => {
|
|
6899
|
+
for (const it of current) {
|
|
6900
|
+
const patch = patches.get(it.id);
|
|
6901
|
+
if (!patch) continue;
|
|
6902
|
+
Object.assign(it, patch.setFields);
|
|
6903
|
+
for (const k of patch.deleteKeys) delete it[k];
|
|
6904
|
+
}
|
|
6905
|
+
return current;
|
|
6906
|
+
});
|
|
6907
|
+
}
|
|
6829
6908
|
}
|
|
6830
6909
|
|
|
6831
6910
|
const skipTotal = skipped.gated + skipped.noAgent;
|
|
@@ -7054,11 +7133,11 @@ function materializeSpecsAsWorkItems(config, project) {
|
|
|
7054
7133
|
|
|
7055
7134
|
existingItems.push({
|
|
7056
7135
|
id: newId,
|
|
7057
|
-
type:
|
|
7136
|
+
type: WORK_TYPE.IMPLEMENT,
|
|
7058
7137
|
title: `Implement: ${info.title}`,
|
|
7059
7138
|
description: `Implementation work from merged spec.\n\n**Spec:** \`${doc.file}\`\n**Source PR:** ${pr.id} — ${pr.title || ''}\n**PR URL:** ${pr.url || 'N/A'}\n\n## Summary\n\n${info.summary}\n\nRead the full spec at \`${doc.file}\` before starting.`,
|
|
7060
7139
|
priority: info.priority,
|
|
7061
|
-
status:
|
|
7140
|
+
status: WI_STATUS.QUEUED,
|
|
7062
7141
|
created: ts(),
|
|
7063
7142
|
createdBy: 'engine:spec-discovery',
|
|
7064
7143
|
sourceSpec: doc.file,
|
|
@@ -8279,30 +8358,31 @@ async function tickInner() {
|
|
|
8279
8358
|
|
|
8280
8359
|
try { pruneStalePrDispatches(config); } catch (e) { log('warn', 'prune stale PR dispatches: ' + e.message); }
|
|
8281
8360
|
|
|
8282
|
-
//
|
|
8361
|
+
// Process pending dispatches before discovery. discoverWork() runs
|
|
8283
8362
|
// pre-dispatch LLM validation for newly found work; a slow validator must not
|
|
8284
8363
|
// starve runnable entries that are already durable in dispatch.pending.
|
|
8285
|
-
|
|
8286
|
-
|
|
8364
|
+
//
|
|
8365
|
+
// W-mq9b7lor (H2): sort INSIDE the dispatch mutator so any concurrent
|
|
8366
|
+
// completion / enqueue / queue-cleanup path that lands between the SQL read
|
|
8367
|
+
// and our write isn't clobbered by writing a stale pre-lock snapshot back.
|
|
8368
|
+
// Previously this site read dispatch, sorted pending in memory, then handed
|
|
8369
|
+
// the pre-lock snapshot to the mutator callback — overwriting whatever the
|
|
8370
|
+
// lock-held read had freshly loaded from SQL.
|
|
8287
8371
|
const maxConcurrent = resolveMaxConcurrent(config);
|
|
8288
|
-
|
|
8289
|
-
const slotsAvailable = Math.max(0, maxConcurrent - activeCount);
|
|
8290
|
-
|
|
8291
|
-
// Priority dispatch: implement > fix/ask > review > test/verify > plan > other
|
|
8292
8372
|
const typePriority = { 'implement:large': 0, implement: 0, fix: 1, ask: 1, review: 2, test: 3, verify: 3, plan: 4, 'plan-to-prd': 4 };
|
|
8293
8373
|
const itemPriority = { high: 0, medium: 1, low: 2 };
|
|
8294
|
-
dispatch.pending.sort((a, b) => {
|
|
8295
|
-
const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;
|
|
8296
|
-
if (ta !== tb) return ta - tb;
|
|
8297
|
-
const pa = itemPriority[a.meta?.item?.priority] ?? 1, pb = itemPriority[b.meta?.item?.priority] ?? 1;
|
|
8298
|
-
return pa - pb;
|
|
8299
|
-
});
|
|
8300
8374
|
if (_isTickStale(myGeneration)) return;
|
|
8301
|
-
mutateDispatch((dp) => {
|
|
8302
|
-
dp.pending
|
|
8303
|
-
|
|
8375
|
+
const dispatch = mutateDispatch((dp) => {
|
|
8376
|
+
dp.pending.sort((a, b) => {
|
|
8377
|
+
const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;
|
|
8378
|
+
if (ta !== tb) return ta - tb;
|
|
8379
|
+
const pa = itemPriority[a.meta?.item?.priority] ?? 1, pb = itemPriority[b.meta?.item?.priority] ?? 1;
|
|
8380
|
+
return pa - pb;
|
|
8381
|
+
});
|
|
8304
8382
|
return dp;
|
|
8305
8383
|
});
|
|
8384
|
+
const activeCount = (dispatch.active || []).length;
|
|
8385
|
+
const slotsAvailable = Math.max(0, maxConcurrent - activeCount);
|
|
8306
8386
|
|
|
8307
8387
|
// Build set of agents currently active (one task per agent at a time).
|
|
8308
8388
|
const busyAgents = new Set((dispatch.active || []).map(d => d.agent));
|
|
@@ -8658,7 +8738,35 @@ async function tickInner() {
|
|
|
8658
8738
|
}
|
|
8659
8739
|
if (skipReasonChanged) {
|
|
8660
8740
|
if (_isTickStale(myGeneration)) return;
|
|
8661
|
-
|
|
8741
|
+
// W-mq9b7lor (H2): apply per-item skipReason patches INSIDE the mutator
|
|
8742
|
+
// so any concurrent completion / enqueue / queue-cleanup path landing
|
|
8743
|
+
// between the postDispatch snapshot read above and our write isn't
|
|
8744
|
+
// clobbered. Previously the mutator callback assigned dp.pending from the
|
|
8745
|
+
// pre-lock postDispatch snapshot, overwriting any freshly loaded queue
|
|
8746
|
+
// entries. Sentinel-undefined for _pendingReason / _agentBusySince
|
|
8747
|
+
// mirrors the in-loop `delete` semantics — JSON serialization drops
|
|
8748
|
+
// undefined-valued keys, so SQL → JSON mirror stays consistent.
|
|
8749
|
+
const patches = new Map();
|
|
8750
|
+
for (const item of (postDispatch.pending || [])) {
|
|
8751
|
+
patches.set(item.id, {
|
|
8752
|
+
skipReason: item.skipReason,
|
|
8753
|
+
_pendingReason: item._pendingReason,
|
|
8754
|
+
_agentBusySince: item._agentBusySince,
|
|
8755
|
+
});
|
|
8756
|
+
}
|
|
8757
|
+
if (_isTickStale(myGeneration)) return;
|
|
8758
|
+
mutateDispatch((dp) => {
|
|
8759
|
+
for (const item of (dp.pending || [])) {
|
|
8760
|
+
const patch = patches.get(item.id);
|
|
8761
|
+
if (!patch) continue;
|
|
8762
|
+
item.skipReason = patch.skipReason;
|
|
8763
|
+
if (patch._pendingReason === undefined) delete item._pendingReason;
|
|
8764
|
+
else item._pendingReason = patch._pendingReason;
|
|
8765
|
+
if (patch._agentBusySince === undefined) delete item._agentBusySince;
|
|
8766
|
+
else item._agentBusySince = patch._agentBusySince;
|
|
8767
|
+
}
|
|
8768
|
+
return dp;
|
|
8769
|
+
});
|
|
8662
8770
|
}
|
|
8663
8771
|
|
|
8664
8772
|
// 4. Discover new work from sources. Newly discovered dispatches are eligible
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2172",
|
|
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"
|