@yemi33/minions 0.1.2177 → 0.1.2179
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/bin/minions.js +24 -11
- package/dashboard/js/command-parser.js +1 -1
- package/dashboard/js/memory-panel.js +262 -0
- package/dashboard/js/qa.js +2 -2
- package/dashboard/js/refresh.js +9 -1
- package/dashboard/js/render-dispatch.js +92 -0
- package/dashboard/js/render-other.js +1 -1
- package/dashboard/js/render-plans.js +82 -13
- package/dashboard/js/render-prs.js +2 -1
- package/dashboard/js/render-schedules.js +1 -1
- package/dashboard/js/render-watches.js +1 -1
- package/dashboard/js/settings.js +100 -11
- package/dashboard/layout.html +6 -0
- package/dashboard/pages/engine-memory-panel.html +49 -0
- package/dashboard/pages/engine.html +1 -0
- package/dashboard/slim/js/link-pr.js +5 -5
- package/dashboard/slim/js/modals-tiles.js +44 -3
- package/dashboard/slim/js/projects.js +8 -6
- package/dashboard/slim/styles.css +20 -0
- package/dashboard/styles.css +39 -0
- package/dashboard-build.js +17 -2
- package/dashboard.js +469 -21
- package/docs/README.md +8 -1
- package/docs/auto-discovery.md +40 -0
- package/docs/branch-derivation.md +13 -1
- package/docs/cross-repo-plans.md +292 -0
- package/docs/deprecated.json +4 -4
- package/docs/pr-auto-fix-dispatch.md +64 -0
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/watches.md +1 -0
- package/engine/ado.js +1 -10
- package/engine/diagnostics-memory.js +190 -0
- package/engine/dispatch.js +53 -0
- package/engine/lifecycle.js +155 -191
- package/engine/meeting.js +30 -0
- package/engine/playbook.js +15 -0
- package/engine/queries.js +165 -5
- package/engine/runtimes/copilot.js +19 -0
- package/engine/shared.js +303 -3
- package/engine/watchdog.js +6 -0
- package/engine.js +576 -113
- package/package.json +2 -2
- package/playbooks/plan-to-prd.md +25 -2
- package/playbooks/plan.md +4 -2
package/engine/lifecycle.js
CHANGED
|
@@ -915,9 +915,30 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
|
|
|
915
915
|
(projects.length === 1 ? projects[0] : null);
|
|
916
916
|
const useCentral = !defaultProject;
|
|
917
917
|
|
|
918
|
-
// Match each PR to its correct project
|
|
918
|
+
// Match each PR to its correct project. W-mqba5ulq000nd255 — primary
|
|
919
|
+
// strategy is canonical scope: parse the evidence URL into a host scope
|
|
920
|
+
// (e.g. `github:opg-microsoft/minions`) and find the configured project
|
|
921
|
+
// whose `getProjectPrScope` matches. This routes the record into the
|
|
922
|
+
// correctly-scoped project file even when the dispatching agent ran
|
|
923
|
+
// against a different project (the cross-project PR case that previously
|
|
924
|
+
// produced stale `_invalidProjectScope` stubs in the wrong file). Falls
|
|
925
|
+
// back to the legacy substring match (handles legacy non-canonical
|
|
926
|
+
// configs), then to a repoName-by-_git fallback, then to the dispatching
|
|
927
|
+
// project (which will get the `_invalidProjectScope` stamp via
|
|
928
|
+
// normalizePrRecord — preserving today's tracking behavior for orphan /
|
|
929
|
+
// unknown-owner URLs).
|
|
919
930
|
function resolveProjectForPr(prId) {
|
|
920
931
|
const evidenceUrl = prEvidence.get(prId) || '';
|
|
932
|
+
// Scope match wins. Use the most-specific URL available: prefer the
|
|
933
|
+
// direct evidence URL captured alongside the PR id; fall back to the
|
|
934
|
+
// generic stdout scan if that's empty.
|
|
935
|
+
const urlScope = shared.getPrScopeInfo(null, evidenceUrl)?.scope || '';
|
|
936
|
+
if (urlScope) {
|
|
937
|
+
for (const p of projects) {
|
|
938
|
+
const projScope = shared.getProjectPrScope(p);
|
|
939
|
+
if (projScope && projScope === urlScope) return p;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
921
942
|
const evidenceText = `${outputText}\n${evidenceUrl}`;
|
|
922
943
|
for (const p of projects) {
|
|
923
944
|
if (!p.prUrlBase) continue;
|
|
@@ -1472,7 +1493,14 @@ async function findOpenPrForBranch(meta, config) {
|
|
|
1472
1493
|
if (!meta?.branch) return null;
|
|
1473
1494
|
const projectObj = resolvePrFallbackProject(meta, config);
|
|
1474
1495
|
if (!projectObj) return null;
|
|
1475
|
-
|
|
1496
|
+
// BUG-H14 (P-h14-repohost): no silent 'ado' default — if the resolved
|
|
1497
|
+
// project doesn't declare repoHost, bail rather than mis-routing a GitHub
|
|
1498
|
+
// project's branch lookup through the ADO PR-search path.
|
|
1499
|
+
const host = projectObj.repoHost || null;
|
|
1500
|
+
if (!host) {
|
|
1501
|
+
log('debug', `Skipping branch PR lookup: project ${projectObj.name} has no repoHost configured`);
|
|
1502
|
+
return null;
|
|
1503
|
+
}
|
|
1476
1504
|
if (host === 'github') {
|
|
1477
1505
|
const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
|
|
1478
1506
|
if (!ghSlug) return null;
|
|
@@ -1981,13 +2009,26 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
1981
2009
|
// The poller will pick up the real status on the next cycle (~3 min).
|
|
1982
2010
|
let postReviewStatus = null; // null = don't change
|
|
1983
2011
|
let liveStatus = null;
|
|
2012
|
+
// BUG-H14 (P-h14-repohost): resolve repoHost from the PR's canonical id
|
|
2013
|
+
// FIRST. The previous code derived hostForChecks from the fallback project
|
|
2014
|
+
// (`reviewProject || getProjects(config)[0]`) and silently defaulted to
|
|
2015
|
+
// 'ado' on miss — so a github:* PR found in the central pull-requests.json
|
|
2016
|
+
// (reviewProject=null) with an ADO project as projects[0] was mis-routed to
|
|
2017
|
+
// ado.checkLiveReviewStatus. The fallback project is still consulted when
|
|
2018
|
+
// the id is unparseable, but there is no silent 'ado' default — an
|
|
2019
|
+
// unresolved host is observable (no live check fires).
|
|
1984
2020
|
const projectObjForChecks = reviewProject || shared.getProjects(config)[0];
|
|
1985
|
-
const
|
|
1986
|
-
const
|
|
1987
|
-
?
|
|
1988
|
-
:
|
|
2021
|
+
const parsedCanonicalReviewId = shared.parseCanonicalPrId(reviewPr.id);
|
|
2022
|
+
const hostFromCanonical = parsedCanonicalReviewId
|
|
2023
|
+
? parsedCanonicalReviewId.scope.split(':')[0]
|
|
2024
|
+
: null;
|
|
2025
|
+
const hostForChecks = hostFromCanonical || projectObjForChecks?.repoHost || null;
|
|
2026
|
+
let checkFn = null;
|
|
2027
|
+
if (hostForChecks === 'github') checkFn = require('./github').checkLiveReviewStatus;
|
|
2028
|
+
else if (hostForChecks === 'ado') checkFn = require('./ado').checkLiveReviewStatus;
|
|
2029
|
+
else log('warn', `Cannot resolve repoHost for review of ${reviewPr.id} (unparseable canonical id and no project config); skipping live review check.`);
|
|
1989
2030
|
try {
|
|
1990
|
-
if (projectObjForChecks) {
|
|
2031
|
+
if (projectObjForChecks && checkFn) {
|
|
1991
2032
|
liveStatus = await checkFn(reviewPr, projectObjForChecks);
|
|
1992
2033
|
}
|
|
1993
2034
|
} catch (e) { log('warn', `Post-review status check for ${reviewPr.id}: ${e.message}`); }
|
|
@@ -2025,7 +2066,9 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
2025
2066
|
try {
|
|
2026
2067
|
const reconcileFn = hostForChecks === 'github'
|
|
2027
2068
|
? require('./github').dismissPriorViewerChangesRequestedReviews
|
|
2028
|
-
:
|
|
2069
|
+
: hostForChecks === 'ado'
|
|
2070
|
+
? require('./ado').resetReviewerNegativeVote
|
|
2071
|
+
: null;
|
|
2029
2072
|
if (typeof reconcileFn === 'function') {
|
|
2030
2073
|
const result = await reconcileFn(reviewPr, projectObjForChecks);
|
|
2031
2074
|
const cleared = !!(result && (result.changed || result.dismissed > 0));
|
|
@@ -3395,85 +3438,6 @@ function parseAgentOutput(stdout, runtimeName) {
|
|
|
3395
3438
|
return { resultSummary: text, taskUsage: usage, sessionId, model };
|
|
3396
3439
|
}
|
|
3397
3440
|
|
|
3398
|
-
/**
|
|
3399
|
-
* Parse structured completion block from agent output.
|
|
3400
|
-
* Agents produce a ```completion fenced block with key: value pairs.
|
|
3401
|
-
* Returns parsed object or null if not found / malformed.
|
|
3402
|
-
* If multiple blocks exist, the last one wins (agent may retry).
|
|
3403
|
-
*
|
|
3404
|
-
* DEPRECATED — slated for removal once telemetry shows zero hits over a 14-day
|
|
3405
|
-
* window. Telemetry: see _engine.completionFallbacks in metrics.json (the JSON
|
|
3406
|
-
* sidecar at MINIONS_COMPLETION_REPORT is the documented contract; this fenced
|
|
3407
|
-
* fallback exists only to support runtimes/agents that have not yet adopted it).
|
|
3408
|
-
* Removal is tracked by the P-c8f5e1b3 follow-up plan.
|
|
3409
|
-
*/
|
|
3410
|
-
function parseStructuredCompletion(stdout, runtimeName) {
|
|
3411
|
-
if (!stdout || typeof stdout !== 'string') return null;
|
|
3412
|
-
|
|
3413
|
-
// Extract text from stream-json output if needed
|
|
3414
|
-
const text = extractCompletionText(stdout, runtimeName);
|
|
3415
|
-
|
|
3416
|
-
// Find all ```completion blocks, take the last one
|
|
3417
|
-
const blockPattern = /```completion\s*\n([\s\S]*?)```/g;
|
|
3418
|
-
let lastMatch = null;
|
|
3419
|
-
let m;
|
|
3420
|
-
while ((m = blockPattern.exec(text)) !== null) {
|
|
3421
|
-
lastMatch = m[1];
|
|
3422
|
-
}
|
|
3423
|
-
if (!lastMatch) {
|
|
3424
|
-
const taskCompleteSummary = extractTaskCompleteSummary(stdout);
|
|
3425
|
-
return taskCompleteSummary ? parseCompletionKeyValues(taskCompleteSummary) : null;
|
|
3426
|
-
}
|
|
3427
|
-
|
|
3428
|
-
return parseCompletionKeyValues(lastMatch);
|
|
3429
|
-
}
|
|
3430
|
-
|
|
3431
|
-
function extractCompletionText(stdout, runtimeName) {
|
|
3432
|
-
let text = stdout;
|
|
3433
|
-
if (typeof stdout === 'string' && stdout.includes('"type":')) {
|
|
3434
|
-
try {
|
|
3435
|
-
const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
|
|
3436
|
-
if (parsed.text) text = parsed.text;
|
|
3437
|
-
} catch {}
|
|
3438
|
-
}
|
|
3439
|
-
return text;
|
|
3440
|
-
}
|
|
3441
|
-
|
|
3442
|
-
function hasCompletionFence(stdout, runtimeName) {
|
|
3443
|
-
const text = extractCompletionText(stdout, runtimeName);
|
|
3444
|
-
return /```completion\s*\n[\s\S]*?```/.test(text);
|
|
3445
|
-
}
|
|
3446
|
-
|
|
3447
|
-
function extractTaskCompleteSummary(stdout) {
|
|
3448
|
-
if (!stdout || typeof stdout !== 'string') return '';
|
|
3449
|
-
let summary = '';
|
|
3450
|
-
for (const rawLine of stdout.split('\n')) {
|
|
3451
|
-
const line = rawLine.trim();
|
|
3452
|
-
if (!line || !line.startsWith('{')) continue;
|
|
3453
|
-
let obj;
|
|
3454
|
-
try { obj = JSON.parse(line); } catch { continue; }
|
|
3455
|
-
if (!obj || typeof obj !== 'object') continue;
|
|
3456
|
-
if (obj.type === 'session.task_complete') {
|
|
3457
|
-
const value = obj.data?.summary;
|
|
3458
|
-
if (typeof value === 'string' && value.trim()) summary = value;
|
|
3459
|
-
continue;
|
|
3460
|
-
}
|
|
3461
|
-
if (obj.type === 'tool.execution_start' && obj.data?.toolName === 'task_complete') {
|
|
3462
|
-
const value = obj.data?.arguments?.summary;
|
|
3463
|
-
if (typeof value === 'string' && value.trim()) summary = value;
|
|
3464
|
-
continue;
|
|
3465
|
-
}
|
|
3466
|
-
if (obj.type === 'assistant.message' && Array.isArray(obj.data?.toolRequests)) {
|
|
3467
|
-
for (const tr of obj.data.toolRequests) {
|
|
3468
|
-
if (tr?.name !== 'task_complete') continue;
|
|
3469
|
-
const value = tr.arguments?.summary || tr.intentionSummary;
|
|
3470
|
-
if (typeof value === 'string' && value.trim()) summary = value;
|
|
3471
|
-
}
|
|
3472
|
-
}
|
|
3473
|
-
}
|
|
3474
|
-
return summary;
|
|
3475
|
-
}
|
|
3476
|
-
|
|
3477
3441
|
function hasActionableFailureClass(value) {
|
|
3478
3442
|
const normalized = String(value || '').trim().toLowerCase();
|
|
3479
3443
|
if (!normalized) return false;
|
|
@@ -3577,57 +3541,6 @@ function handleInjectionFlag(dispatchItem, agentId, structuredCompletion, config
|
|
|
3577
3541
|
return { description, sources, at };
|
|
3578
3542
|
}
|
|
3579
3543
|
|
|
3580
|
-
function parseCompletionKeyValues(text) {
|
|
3581
|
-
if (!text || typeof text !== 'string') return null;
|
|
3582
|
-
const result = {};
|
|
3583
|
-
const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
|
|
3584
|
-
const lines = text.trim().split('\n');
|
|
3585
|
-
for (const line of lines) {
|
|
3586
|
-
const normalizedLine = line.trim().replace(/^[-*]\s+/, '');
|
|
3587
|
-
const colonIdx = normalizedLine.indexOf(':');
|
|
3588
|
-
if (colonIdx < 1) continue;
|
|
3589
|
-
const key = normalizedLine.slice(0, colonIdx).trim().toLowerCase();
|
|
3590
|
-
if (allowedFields.size > 0 && !allowedFields.has(key)) continue;
|
|
3591
|
-
const value = normalizedLine.slice(colonIdx + 1).trim();
|
|
3592
|
-
if (key && value) result[key] = value;
|
|
3593
|
-
}
|
|
3594
|
-
|
|
3595
|
-
// Must have at least status, or an actionable failure_class that implies failure.
|
|
3596
|
-
if (!result.status && hasActionableFailureClass(result.failure_class)) result.status = 'failed';
|
|
3597
|
-
if (!result.status) return null;
|
|
3598
|
-
return result;
|
|
3599
|
-
}
|
|
3600
|
-
|
|
3601
|
-
/**
|
|
3602
|
-
* DEPRECATED — slated for removal once telemetry shows zero hits over a 14-day
|
|
3603
|
-
* window. Telemetry: see _engine.completionFallbacks in metrics.json (the JSON
|
|
3604
|
-
* sidecar at MINIONS_COMPLETION_REPORT is the documented contract; this prose
|
|
3605
|
-
* fallback only fires when both the sidecar AND the fenced ```completion block
|
|
3606
|
-
* are missing). Removal is tracked by the P-c8f5e1b3 follow-up plan.
|
|
3607
|
-
*/
|
|
3608
|
-
function parseCompletionFieldSummary(text) {
|
|
3609
|
-
if (!text || typeof text !== 'string') return null;
|
|
3610
|
-
|
|
3611
|
-
const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
|
|
3612
|
-
const result = {};
|
|
3613
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
3614
|
-
const line = rawLine.trim().replace(/^[-*]\s+/, '');
|
|
3615
|
-
const colonIdx = line.indexOf(':');
|
|
3616
|
-
if (colonIdx < 1) continue;
|
|
3617
|
-
const key = line.slice(0, colonIdx).trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
3618
|
-
if (!allowedFields.has(key)) continue;
|
|
3619
|
-
const value = line.slice(colonIdx + 1).trim().replace(/^["'`]+|["'`]+$/g, '');
|
|
3620
|
-
if (value) result[key] = value;
|
|
3621
|
-
}
|
|
3622
|
-
|
|
3623
|
-
if (!result.status) return null;
|
|
3624
|
-
const fieldCount = Object.keys(result).length;
|
|
3625
|
-
const status = normalizeCompletionStatus(result.status);
|
|
3626
|
-
const explicitlyFailed = status.startsWith('fail') || status === 'error';
|
|
3627
|
-
if (fieldCount < 2 && !explicitlyFailed) return null;
|
|
3628
|
-
return result;
|
|
3629
|
-
}
|
|
3630
|
-
|
|
3631
3544
|
function parseCompletionReportFile(dispatchItem, opts = {}) {
|
|
3632
3545
|
const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
|
|
3633
3546
|
if (!reportPath || !fs.existsSync(reportPath)) {
|
|
@@ -3820,29 +3733,6 @@ function promoteCompletionArtifacts(meta, agentId, dispatchId, structuredComplet
|
|
|
3820
3733
|
return { artifacts, notes, agentId, dispatchId };
|
|
3821
3734
|
}
|
|
3822
3735
|
|
|
3823
|
-
function persistCompletionReport(dispatchItem, completion, source = 'fallback') {
|
|
3824
|
-
if (!dispatchItem?.id || !completion || typeof completion !== 'object') return completion;
|
|
3825
|
-
const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem.id);
|
|
3826
|
-
if (!reportPath) return completion;
|
|
3827
|
-
const report = {
|
|
3828
|
-
...completion,
|
|
3829
|
-
status: completion.status || completion.outcome || 'unknown',
|
|
3830
|
-
_source: source,
|
|
3831
|
-
_path: reportPath,
|
|
3832
|
-
dispatchId: dispatchItem.id,
|
|
3833
|
-
agent: dispatchItem.agent || null,
|
|
3834
|
-
type: dispatchItem.type || null,
|
|
3835
|
-
completedAt: ts(),
|
|
3836
|
-
};
|
|
3837
|
-
try {
|
|
3838
|
-
safeWrite(reportPath, report);
|
|
3839
|
-
log('info', `Persisted ${source} completion report for ${dispatchItem.id}: ${reportPath}`);
|
|
3840
|
-
} catch (err) {
|
|
3841
|
-
log('warn', `Persist fallback completion report for ${dispatchItem.id}: ${err.message}`);
|
|
3842
|
-
}
|
|
3843
|
-
return report;
|
|
3844
|
-
}
|
|
3845
|
-
|
|
3846
3736
|
function normalizeCompletionStatus(status) {
|
|
3847
3737
|
return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
3848
3738
|
}
|
|
@@ -4487,6 +4377,12 @@ function handleHarnessIterationResult(stdout, structuredCompletion, meta, config
|
|
|
4487
4377
|
* - Returns silently when the PR is no longer active, or when its current
|
|
4488
4378
|
* reviewStatus is not in {changes-requested, waiting}. An already-approved
|
|
4489
4379
|
* PR doesn't need a re-review.
|
|
4380
|
+
* - P-e8b1c4d2: Returns silently when engine.autoReReviewPrs === false. This
|
|
4381
|
+
* mirrors the discovery-driven gate in engine.js:discoverFromPrs so the
|
|
4382
|
+
* toggle is a single source of truth across BOTH re-review paths
|
|
4383
|
+
* (open-loop / closure-loop). Without this gate the dashboard switch only
|
|
4384
|
+
* muted the discovery path, leaving the closure-loop free to keep
|
|
4385
|
+
* dispatching re-reviews — defeating the operator's intent.
|
|
4490
4386
|
* - Idempotent: getPrDispatchDedupeKey + addToDispatch dedup already skips a
|
|
4491
4387
|
* second review dispatch for the same (PR, type). No second WI ever lands.
|
|
4492
4388
|
* - Soft agent preference: agents whose charter advertises code-review or
|
|
@@ -4504,6 +4400,14 @@ function dispatchReReviewForFix(fixDispatchItem, meta, config) {
|
|
|
4504
4400
|
if (!addressesReviewWi) return null;
|
|
4505
4401
|
const pr = meta?.pr;
|
|
4506
4402
|
if (!pr?.id) return null;
|
|
4403
|
+
// P-e8b1c4d2 — honor the autoReReviewPrs kill-switch in the closure-loop too.
|
|
4404
|
+
// The open-loop discovery path in engine.js gates on the same flag; without
|
|
4405
|
+
// this guard the closure-loop silently bypassed the operator's toggle.
|
|
4406
|
+
const autoReReviewPrs = config?.engine?.autoReReviewPrs ?? shared.ENGINE_DEFAULTS.autoReReviewPrs;
|
|
4407
|
+
if (autoReReviewPrs === false) {
|
|
4408
|
+
log('info', `Re-review skipped for ${pr.id}: engine.autoReReviewPrs is off (closure-loop kill-switch)`);
|
|
4409
|
+
return null;
|
|
4410
|
+
}
|
|
4507
4411
|
// Re-read live PR state so we don't queue a re-review against an already
|
|
4508
4412
|
// merged/abandoned PR. The post-completion fix-update may have just flipped
|
|
4509
4413
|
// reviewStatus to 'waiting'; consult that fresh state via the central read.
|
|
@@ -4672,33 +4576,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
4672
4576
|
reportCompletion = null;
|
|
4673
4577
|
}
|
|
4674
4578
|
|
|
4675
|
-
const
|
|
4676
|
-
const summaryCompletion = (nonceMismatch || reportCompletion || fencedCompletion) ? null : parseCompletionFieldSummary(resultSummary);
|
|
4677
|
-
const fallbackCompletion = fencedCompletion || summaryCompletion;
|
|
4678
|
-
const fallbackSource = fencedCompletion && hasCompletionFence(stdout, runtimeName) ? 'fenced-completion' : 'summary-completion';
|
|
4679
|
-
// P-c8f5e1b3 — telemetry: completion-fallback. Emit a grep-able log + bump a
|
|
4680
|
-
// counter in metrics.json whenever the fenced or summary fallback fires (i.e.
|
|
4681
|
-
// the documented sidecar contract was missing). Used to confirm zero adoption
|
|
4682
|
-
// gaps before removing parseStructuredCompletion / parseCompletionFieldSummary
|
|
4683
|
-
// in a follow-up. Sidecar (happy) path is silent — no log, no increment.
|
|
4684
|
-
if (!reportCompletion && fallbackCompletion) {
|
|
4685
|
-
const counterKey = fallbackSource === 'fenced-completion' ? 'fenced' : 'summary';
|
|
4686
|
-
const wiId = dispatchItem.meta?.item?.id || null;
|
|
4687
|
-
log('info', `telemetry: completion-fallback agent=${agentId || 'unknown'} wi=${wiId || 'N/A'} runtime=${runtimeName} source=${fallbackSource}`);
|
|
4688
|
-
try {
|
|
4689
|
-
const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
|
|
4690
|
-
mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
4691
|
-
metrics = metrics || {};
|
|
4692
|
-
if (!metrics._engine) metrics._engine = {};
|
|
4693
|
-
if (!metrics._engine.completionFallbacks) metrics._engine.completionFallbacks = { fenced: 0, summary: 0 };
|
|
4694
|
-
metrics._engine.completionFallbacks[counterKey] = (metrics._engine.completionFallbacks[counterKey] || 0) + 1;
|
|
4695
|
-
return metrics;
|
|
4696
|
-
});
|
|
4697
|
-
} catch (err) {
|
|
4698
|
-
log('warn', `telemetry: completion-fallback metrics write failed: ${err.message}`);
|
|
4699
|
-
}
|
|
4700
|
-
}
|
|
4701
|
-
const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, fallbackSource);
|
|
4579
|
+
const structuredCompletion = reportCompletion;
|
|
4702
4580
|
if (structuredCompletion) {
|
|
4703
4581
|
if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
|
|
4704
4582
|
log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
|
|
@@ -5617,6 +5495,94 @@ function diagnoseEmptyOutput(failureClass, code, elapsedMs) {
|
|
|
5617
5495
|
return `[empty-output: process exited in ${elapsedMs}ms \u2014 possible causes: machine sleep, network unavailability, auth failure]`;
|
|
5618
5496
|
}
|
|
5619
5497
|
|
|
5498
|
+
// W-mqba5ulq000nd255 — Reconciliation sweep: delete PR records flagged with
|
|
5499
|
+
// `_invalidProjectScope: { reason: "pr_scope_mismatch" }` IFF a sibling
|
|
5500
|
+
// record for the same canonical pr.id exists in another project whose scope
|
|
5501
|
+
// matches the PR URL (i.e. the correctly-scoped project owns the canonical
|
|
5502
|
+
// record). Sibling-less mismatches are preserved as tracking. Runs once per
|
|
5503
|
+
// tick from engine.js after the ADO/GitHub reconcile polls finish.
|
|
5504
|
+
//
|
|
5505
|
+
// Returns { pruned, scanned } so the engine tick can log a summary line.
|
|
5506
|
+
function pruneScopeMismatchDuplicatePrs(config) {
|
|
5507
|
+
config = config || getConfig();
|
|
5508
|
+
const projects = shared.getProjects(config) || [];
|
|
5509
|
+
if (projects.length === 0) return { pruned: 0, scanned: 0 };
|
|
5510
|
+
|
|
5511
|
+
// Map project name -> canonical scope so we can find which project IS the
|
|
5512
|
+
// correctly-scoped owner for a given mismatch record.
|
|
5513
|
+
const scopeByProject = new Map();
|
|
5514
|
+
const projectByScope = new Map();
|
|
5515
|
+
for (const p of projects) {
|
|
5516
|
+
if (!p || !p.name) continue;
|
|
5517
|
+
const sc = shared.getProjectPrScope(p);
|
|
5518
|
+
if (!sc) continue;
|
|
5519
|
+
scopeByProject.set(p.name, sc);
|
|
5520
|
+
projectByScope.set(sc, p);
|
|
5521
|
+
}
|
|
5522
|
+
|
|
5523
|
+
// Index all PRs by canonical id across all projects (post-_scope decoration).
|
|
5524
|
+
const store = require('./pull-requests-store');
|
|
5525
|
+
const allPrs = store.readAllPullRequests() || [];
|
|
5526
|
+
const byId = new Map();
|
|
5527
|
+
for (const pr of allPrs) {
|
|
5528
|
+
if (!pr || !pr.id) continue;
|
|
5529
|
+
if (!byId.has(pr.id)) byId.set(pr.id, []);
|
|
5530
|
+
byId.get(pr.id).push(pr);
|
|
5531
|
+
}
|
|
5532
|
+
|
|
5533
|
+
// Build per-project delete sets keyed by id, so we batch one mutation per
|
|
5534
|
+
// affected project file.
|
|
5535
|
+
const deletesByProject = new Map(); // projectName -> Set(prId)
|
|
5536
|
+
let scanned = 0;
|
|
5537
|
+
let pruned = 0;
|
|
5538
|
+
|
|
5539
|
+
for (const records of byId.values()) {
|
|
5540
|
+
if (records.length < 2) continue;
|
|
5541
|
+
for (const rec of records) {
|
|
5542
|
+
scanned++;
|
|
5543
|
+
if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
|
|
5544
|
+
const correctScope = rec._invalidProjectScope.prScope;
|
|
5545
|
+
if (!correctScope) continue;
|
|
5546
|
+
const correctProject = projectByScope.get(correctScope);
|
|
5547
|
+
if (!correctProject) continue; // no configured project owns the URL — keep as tracking
|
|
5548
|
+
// Sibling check: is there a record under the correct scope for the same id?
|
|
5549
|
+
const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
|
|
5550
|
+
if (!sibling) continue;
|
|
5551
|
+
// Safe to prune.
|
|
5552
|
+
const owningScope = rec._scope;
|
|
5553
|
+
if (!owningScope || owningScope === 'central') continue;
|
|
5554
|
+
if (!deletesByProject.has(owningScope)) deletesByProject.set(owningScope, new Set());
|
|
5555
|
+
deletesByProject.get(owningScope).add(rec.id);
|
|
5556
|
+
}
|
|
5557
|
+
}
|
|
5558
|
+
|
|
5559
|
+
if (deletesByProject.size === 0) return { pruned: 0, scanned };
|
|
5560
|
+
|
|
5561
|
+
for (const [projectName, idsToDelete] of deletesByProject) {
|
|
5562
|
+
const project = projects.find(p => p.name === projectName);
|
|
5563
|
+
if (!project) continue;
|
|
5564
|
+
const prPath = projectPrPath(project);
|
|
5565
|
+
try {
|
|
5566
|
+
shared.mutatePullRequests(prPath, (prs) => {
|
|
5567
|
+
const before = prs.length;
|
|
5568
|
+
const next = prs.filter(p => !idsToDelete.has(p?.id));
|
|
5569
|
+
const deleted = before - next.length;
|
|
5570
|
+
if (deleted > 0) {
|
|
5571
|
+
pruned += deleted;
|
|
5572
|
+
for (const id of idsToDelete) {
|
|
5573
|
+
log('info', `[pull-requests] pruned scope-mismatch duplicate ${id} from project=${projectName} (sibling exists in correctly-scoped project)`);
|
|
5574
|
+
}
|
|
5575
|
+
}
|
|
5576
|
+
return next;
|
|
5577
|
+
});
|
|
5578
|
+
} catch (err) {
|
|
5579
|
+
log('warn', `pruneScopeMismatchDuplicatePrs: failed to mutate ${projectName}: ${err?.message || err}`);
|
|
5580
|
+
}
|
|
5581
|
+
}
|
|
5582
|
+
|
|
5583
|
+
return { pruned, scanned };
|
|
5584
|
+
}
|
|
5585
|
+
|
|
5620
5586
|
module.exports = {
|
|
5621
5587
|
checkPlanCompletion,
|
|
5622
5588
|
archivePlan,
|
|
@@ -5625,6 +5591,7 @@ module.exports = {
|
|
|
5625
5591
|
syncPrdItemStatus,
|
|
5626
5592
|
reconcilePrdStatuses,
|
|
5627
5593
|
syncPrsFromOutput,
|
|
5594
|
+
pruneScopeMismatchDuplicatePrs,
|
|
5628
5595
|
updatePrAfterReview,
|
|
5629
5596
|
updatePrAfterFix,
|
|
5630
5597
|
updatePrAfterFixError,
|
|
@@ -5638,8 +5605,6 @@ module.exports = {
|
|
|
5638
5605
|
parseAgentOutput,
|
|
5639
5606
|
parseReviewVerdict,
|
|
5640
5607
|
isReviewBailout,
|
|
5641
|
-
parseStructuredCompletion,
|
|
5642
|
-
parseCompletionFieldSummary,
|
|
5643
5608
|
parseCompletionNoop,
|
|
5644
5609
|
detectNonTerminalResultSummary,
|
|
5645
5610
|
deferNonTerminalCompletion,
|
|
@@ -5651,7 +5616,6 @@ module.exports = {
|
|
|
5651
5616
|
completionArtifactToNoteEntry,
|
|
5652
5617
|
mergeArtifactNotes,
|
|
5653
5618
|
promoteCompletionArtifacts,
|
|
5654
|
-
persistCompletionReport,
|
|
5655
5619
|
runPostCompletionHooks,
|
|
5656
5620
|
syncPrdFromPrs,
|
|
5657
5621
|
resolveWorkItemPath,
|
package/engine/meeting.js
CHANGED
|
@@ -916,6 +916,36 @@ function checkMeetingTimeouts(config) {
|
|
|
916
916
|
const elapsed = Date.now() - roundStartedMs;
|
|
917
917
|
if (elapsed < timeout) continue;
|
|
918
918
|
|
|
919
|
+
// BUG-H11: when a hard timeout will fire, kill in-flight meeting
|
|
920
|
+
// dispatches BEFORE entering the meeting lock so they stop consuming
|
|
921
|
+
// worker slots / LLM tokens immediately rather than draining over the
|
|
922
|
+
// next tens of minutes. _killMeetingDispatches takes the dispatch.json
|
|
923
|
+
// lock and shells out to kill processes — per CLAUDE.md and the
|
|
924
|
+
// advanceMeetingRound/endMeeting/deleteMeeting pattern at L834, that
|
|
925
|
+
// MUST run outside the meeting lock. We pre-evaluate eligibility from
|
|
926
|
+
// the snapshot using the same predicate the mutate callback below
|
|
927
|
+
// re-checks under lock; if the timeout has already advanced/cleared,
|
|
928
|
+
// the kill is a no-op on dispatch.json (no matching meetingId entries).
|
|
929
|
+
const snapshotRoundName = snapshot.status === 'investigating'
|
|
930
|
+
? 'investigate'
|
|
931
|
+
: snapshot.status === 'debating'
|
|
932
|
+
? 'debate'
|
|
933
|
+
: snapshot.status === 'concluding'
|
|
934
|
+
? 'conclude'
|
|
935
|
+
: null;
|
|
936
|
+
const snapshotHardTimeoutWillFire = elapsed >= hardTimeout && (
|
|
937
|
+
// Round-timeout branch: non-conclude round + not all participants done
|
|
938
|
+
(
|
|
939
|
+
(snapshotRoundName === 'investigate' || snapshotRoundName === 'debate')
|
|
940
|
+
&& !allParticipantsFinishedRound(snapshot, snapshotRoundName, snapshot.round)
|
|
941
|
+
)
|
|
942
|
+
// Conclusion-timeout branch: concluding status
|
|
943
|
+
|| snapshot.status === 'concluding'
|
|
944
|
+
);
|
|
945
|
+
if (snapshotHardTimeoutWillFire) {
|
|
946
|
+
_killMeetingDispatches(snapshot.id);
|
|
947
|
+
}
|
|
948
|
+
|
|
919
949
|
// Re-evaluate the timeout transition under the file lock to avoid lost
|
|
920
950
|
// updates if an agent finalised mid-tick. Helpers (advanceMeetingIfRoundComplete
|
|
921
951
|
// etc.) operate on the locked-and-rehydrated meeting object.
|
package/engine/playbook.js
CHANGED
|
@@ -298,6 +298,11 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
|
|
|
298
298
|
'existing_prd_json', // only set when re-running plan-to-prd over an existing PRD
|
|
299
299
|
'branch_strategy_hint', // only set for shared-branch plans
|
|
300
300
|
'review_note', // only set on fix/review tasks tied to a comment
|
|
301
|
+
// P-4d6e2af3 — comma-separated list of target projects for cross-repo
|
|
302
|
+
// plans. Only set in engine.js's WORK_TYPE.PLAN branch when the WI carries
|
|
303
|
+
// item._targetProjects (≥2 entries from dashboard.js#buildPlanWorkItem).
|
|
304
|
+
// Single-project plans legitimately resolve to ''.
|
|
305
|
+
'target_projects',
|
|
301
306
|
// PR-context vars on non-PR tasks (implement/explore/etc.)
|
|
302
307
|
'pr_id', 'pr_number', 'pr_title', 'pr_branch', 'pr_author', 'pr_url',
|
|
303
308
|
'reviewer',
|
|
@@ -868,6 +873,16 @@ function renderPlaybook(type, vars) {
|
|
|
868
873
|
return (val && String(val).trim()) ? block : '';
|
|
869
874
|
});
|
|
870
875
|
|
|
876
|
+
// P-c1f87a92 — negative conditional blocks: {{^key}}...{{/key}} — include
|
|
877
|
+
// block only if key is FALSY / absent / whitespace-only. Mirror the
|
|
878
|
+
// positive-block trim() check above so single-value semantics line up
|
|
879
|
+
// (vars.target_projects = '' or undefined renders the {{^}} branch, the
|
|
880
|
+
// same way it omits the {{#}} branch).
|
|
881
|
+
content = content.replace(/\{\{\^(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, block) => {
|
|
882
|
+
const val = allVars[key];
|
|
883
|
+
return (val && String(val).trim()) ? '' : block;
|
|
884
|
+
});
|
|
885
|
+
|
|
871
886
|
// Substitute variables
|
|
872
887
|
for (const [key, val] of Object.entries(allVars)) {
|
|
873
888
|
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
|