@yemi33/minions 0.1.2127 → 0.1.2129
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/command-parser.js +47 -4
- package/dashboard/js/settings.js +2 -0
- package/dashboard.js +47 -94
- package/engine/ado-git-auth.js +10 -2
- package/engine/ado-status.js +2 -2
- package/engine/cleanup.js +2 -2
- package/engine/dispatch.js +1 -1
- package/engine/meeting.js +5 -5
- package/engine/queries.js +106 -11
- package/engine/shared.js +62 -8
- package/engine.js +117 -13
- package/package.json +1 -1
- package/prompts/cc-system.md +13 -4
- package/prompts/doc-chat-system.md +12 -3
|
@@ -33,8 +33,14 @@ function showToast(id, msg, ok, durationMs) {
|
|
|
33
33
|
setTimeout(() => { el.classList.remove('success', 'error'); }, durationMs || (msg.includes('<a ') ? 15000 : 4000));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function detectWorkItemType(text) {
|
|
36
|
+
function detectWorkItemType(text, hasPrRef) {
|
|
37
37
|
const t = text.toLowerCase();
|
|
38
|
+
// When the caller doesn't pass an explicit PR-ref signal, derive it
|
|
39
|
+
// from the same text we're classifying. cmdParseInput passes the
|
|
40
|
+
// signal from RAW input (before `#projects` stripping) so canonical
|
|
41
|
+
// `#NNN` shorthand isn't lost; direct callers and tests get the
|
|
42
|
+
// ergonomic default of "scan the input you have."
|
|
43
|
+
const prRefPresent = (hasPrRef === undefined) ? _hasPrRef(text) : !!hasPrRef;
|
|
38
44
|
const patterns = [
|
|
39
45
|
{ type: 'ask', words: ['explain', 'why does', 'why is', 'what does', 'how do i', 'how do you', 'what\'s the', 'tell me', 'can you explain', 'walk me through'] },
|
|
40
46
|
{ type: 'explore', words: ['explore', 'investigate', 'understand', 'analyze', 'audit', 'document', 'architecture', 'how does', 'what is', 'look into', 'research', 'survey', 'map out', 'codebase', 'make a note of', 'find out'] },
|
|
@@ -45,14 +51,51 @@ function detectWorkItemType(text) {
|
|
|
45
51
|
{ type: 'test', words: ['test', 'write tests', 'add tests', 'unit test', 'e2e test', 'coverage', 'testing', 'build', 'run locally', 'localhost', 'start the', 'spin up', 'verify', 'check if it works'] },
|
|
46
52
|
];
|
|
47
53
|
for (const { type, words } of patterns) {
|
|
48
|
-
if (words.some(w => t.includes(w)))
|
|
54
|
+
if (words.some(w => t.includes(w))) {
|
|
55
|
+
// W-mq17dun7000f1150 — `type:fix` has a specific engine semantic
|
|
56
|
+
// ("responds to comments/builds on a tracked PR"). If the user's
|
|
57
|
+
// text matches the fix-verb list but doesn't reference any PR, the
|
|
58
|
+
// intent is a fresh repo-level bugfix that will open a NEW PR —
|
|
59
|
+
// i.e. `implement`, not `fix`. Without this gate, repo-level
|
|
60
|
+
// bugfix WIs hit the engine.js#discoverWork `pr_not_found` gate
|
|
61
|
+
// when shared.extractWorkItemPrRef finds a stale PR pointer in
|
|
62
|
+
// the description text and the WI is stuck indefinitely.
|
|
63
|
+
if (type === 'fix' && !prRefPresent) return 'implement';
|
|
64
|
+
return type;
|
|
65
|
+
}
|
|
49
66
|
}
|
|
50
67
|
return 'implement';
|
|
51
68
|
}
|
|
52
69
|
|
|
70
|
+
// _hasPrRef(rawText) -> bool. Recognises canonical PR pointers in free text:
|
|
71
|
+
// - `PR #N` / `PR-N` / `PR N`
|
|
72
|
+
// - `pull request N`
|
|
73
|
+
// - GitHub pull URLs: `github.com/<owner>/<repo>/pull/<N>`
|
|
74
|
+
// - Azure DevOps pull URLs: `.../pullrequest/<N>`
|
|
75
|
+
// - Canonical ids: `github:owner/repo#N`, `ado:org/proj/repo#N`
|
|
76
|
+
// Does NOT match GitHub `issues/N` or bare `#N` — those are not PR pointers
|
|
77
|
+
// (issues have their own URL shape and bare `#N` collides with hashtag /
|
|
78
|
+
// project syntax in cmdParseInput).
|
|
79
|
+
function _hasPrRef(rawText) {
|
|
80
|
+
if (!rawText || typeof rawText !== 'string') return false;
|
|
81
|
+
if (/\bpr[\s#-]+\d+\b/i.test(rawText)) return true;
|
|
82
|
+
if (/\bpull\s+request[\s#-]*\d+\b/i.test(rawText)) return true;
|
|
83
|
+
if (/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/i.test(rawText)) return true;
|
|
84
|
+
if (/\/pullrequest\/\d+/i.test(rawText)) return true;
|
|
85
|
+
if (/\bgithub:[\w.-]+\/[\w.-]+#\d+/i.test(rawText)) return true;
|
|
86
|
+
if (/\bado:[\w.-]+\/[\w.-]+\/[\w.-]+#\d+/i.test(rawText)) return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
53
90
|
// Parse the unified input into structured intent
|
|
54
91
|
function cmdParseInput(raw) {
|
|
55
92
|
let text = raw.trim();
|
|
93
|
+
// W-mq17dun7000f1150 — capture the PR-ref signal on the RAW input before
|
|
94
|
+
// we strip `#projects` (which would eat `#NNN` PR shorthand). The signal
|
|
95
|
+
// is passed to detectWorkItemType so a naked "fix" verb without a PR
|
|
96
|
+
// pointer gets demoted to `implement` (the engine semantic for `fix`
|
|
97
|
+
// requires a tracked PR).
|
|
98
|
+
const hasPrRef = _hasPrRef(raw);
|
|
56
99
|
const result = {
|
|
57
100
|
intent: 'work-item', // 'work-item' | 'note' | 'plan'
|
|
58
101
|
agents: [], // assigned agent IDs
|
|
@@ -138,9 +181,9 @@ function cmdParseInput(raw) {
|
|
|
138
181
|
result.description = lines.slice(1).join('\n').trim();
|
|
139
182
|
|
|
140
183
|
// Auto-detect work type
|
|
141
|
-
result.type = detectWorkItemType(result.title + ' ' + result.description);
|
|
184
|
+
result.type = detectWorkItemType(result.title + ' ' + result.description, hasPrRef);
|
|
142
185
|
|
|
143
186
|
return result;
|
|
144
187
|
}
|
|
145
188
|
|
|
146
|
-
window.MinionsCmdParser = { cmdUpdateAgentList, cmdUpdateProjectList, showToast, detectWorkItemType, cmdParseInput };
|
|
189
|
+
window.MinionsCmdParser = { cmdUpdateAgentList, cmdUpdateProjectList, showToast, detectWorkItemType, cmdParseInput, _hasPrRef };
|
package/dashboard/js/settings.js
CHANGED
|
@@ -297,6 +297,7 @@ async function openSettings() {
|
|
|
297
297
|
settingsField('Worktree Create Timeout', 'set-worktreeCreateTimeout', e.worktreeCreateTimeout || 300000, 'ms', 'Timeout for git worktree add (increase for large repos/Windows)') +
|
|
298
298
|
settingsField('Worktree Create Retries', 'set-worktreeCreateRetries', e.worktreeCreateRetries || 1, '', 'Retry count for transient worktree add failures (0-3)') +
|
|
299
299
|
settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative or absolute path for git worktrees; on Windows prefer a short path like C:\\wt') +
|
|
300
|
+
settingsField('Assert-Clean Status Probe Timeout', 'set-assertCleanStatusTimeoutMs', e.assertCleanStatusTimeoutMs || 10000, 'ms', 'Timeout for the `git status --porcelain` preflight probe in assertCleanSharedWorktree. Raise this (e.g. 60000) on GVFS/Plastic-backed monorepos where sparse hydration runs longer than the 10s default. On timeout the engine quarantines the bad worktree and emits a RETRYABLE failure so the next dispatch starts fresh. Clamped 1000–120000ms.') +
|
|
300
301
|
'</div>';
|
|
301
302
|
|
|
302
303
|
const paneCopilot =
|
|
@@ -846,6 +847,7 @@ async function saveSettings() {
|
|
|
846
847
|
worktreeCreateTimeout: document.getElementById('set-worktreeCreateTimeout').value,
|
|
847
848
|
worktreeCreateRetries: document.getElementById('set-worktreeCreateRetries').value,
|
|
848
849
|
worktreeRoot: document.getElementById('set-worktreeRoot').value,
|
|
850
|
+
assertCleanStatusTimeoutMs: document.getElementById('set-assertCleanStatusTimeoutMs').value,
|
|
849
851
|
idleAlertMinutes: document.getElementById('set-idleAlertMinutes').value,
|
|
850
852
|
shutdownTimeout: document.getElementById('set-shutdownTimeout').value,
|
|
851
853
|
restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
|
package/dashboard.js
CHANGED
|
@@ -3335,6 +3335,14 @@ function getWorkItemPrRef(input) {
|
|
|
3335
3335
|
// description text — not just the canonical structured fields — and routes
|
|
3336
3336
|
// the dispatch to the existing PR branch instead of a fresh `work/<wi-id>`
|
|
3337
3337
|
// parallel branch (issue #2999 / W-mpx6i5kh000ac040).
|
|
3338
|
+
//
|
|
3339
|
+
// ASYMMETRY (W-mq18ec6h000p7b87): the engine's pr_not_found *gate* uses
|
|
3340
|
+
// the strict `shared.extractStructuredWorkItemPrRef` — gate uses
|
|
3341
|
+
// structured-only; stamp uses loose. Rationale: operators creating fix
|
|
3342
|
+
// WIs via API often paste the PR URL in description prose and expect
|
|
3343
|
+
// `targetPr` to get auto-stamped here (best-effort, reversible). The gate
|
|
3344
|
+
// blocks dispatch and must require explicit operator intent (a structured
|
|
3345
|
+
// field) before doing so.
|
|
3338
3346
|
return shared.extractWorkItemPrRef(input);
|
|
3339
3347
|
}
|
|
3340
3348
|
|
|
@@ -5896,47 +5904,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
5896
5904
|
const requestedAgent = body.agent || body.agentId;
|
|
5897
5905
|
const requestedTask = body.task || body.cancelTask;
|
|
5898
5906
|
if (!requestedAgent && !requestedTask) return jsonReply(res, 400, { error: 'agent or task required' });
|
|
5907
|
+
|
|
5908
|
+
// Pre-read for response snapshot. Lockless — cleanDispatchEntries owns the actual mutation.
|
|
5899
5909
|
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
5900
5910
|
const dispatch = safeJsonObj(dispatchPath);
|
|
5901
|
-
const active = dispatch.active
|
|
5902
|
-
const
|
|
5903
|
-
|
|
5904
|
-
for (const d of active) {
|
|
5911
|
+
const active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
5912
|
+
const matchFn = (d) => {
|
|
5905
5913
|
const matchAgent = requestedAgent && d.agent === requestedAgent;
|
|
5906
5914
|
const matchTask = requestedTask && (d.task || '').toLowerCase().includes(String(requestedTask).toLowerCase());
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
|
|
5918
|
-
} else {
|
|
5919
|
-
process.kill(safePid, 'SIGTERM');
|
|
5920
|
-
}
|
|
5921
|
-
} catch { /* process may be dead or invalid PID */ }
|
|
5922
|
-
}
|
|
5923
|
-
status.status = 'idle';
|
|
5924
|
-
delete status.currentTask;
|
|
5925
|
-
delete status.dispatched;
|
|
5926
|
-
safeWrite(statusPath, status);
|
|
5927
|
-
} catch (e) { console.error('agent cancel:', e.message); }
|
|
5928
|
-
|
|
5929
|
-
cancelled.push({ agent: d.agent, task: d.task });
|
|
5930
|
-
}
|
|
5931
|
-
|
|
5932
|
-
// Remove cancelled from active dispatch
|
|
5915
|
+
return Boolean(matchAgent || matchTask);
|
|
5916
|
+
};
|
|
5917
|
+
const cancelled = active.filter(matchFn).map(d => ({ agent: d.agent, task: d.task }));
|
|
5918
|
+
|
|
5919
|
+
// Route PID resolution + kill + dispatch removal through the canonical primitive.
|
|
5920
|
+
// cleanDispatchEntries resolves the PID from engine/tmp/dispatch-<id>-*/pid-<id>.pid
|
|
5921
|
+
// (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes
|
|
5922
|
+
// the entry via mutateDispatch (SQL + JSON mirror in one atomic write).
|
|
5923
|
+
// The defunct per-agent status sidecar reads/writes that used to live here are gone
|
|
5924
|
+
// (engine/queries.js documents that file no longer exists).
|
|
5933
5925
|
if (cancelled.length > 0) {
|
|
5934
|
-
|
|
5935
|
-
mutateJsonFileLocked(dispatchPath, (dp) => {
|
|
5936
|
-
dp.active = Array.isArray(dp.active) ? dp.active : [];
|
|
5937
|
-
dp.active = dp.active.filter(d => !cancelledIds.has(d.agent));
|
|
5938
|
-
return dp;
|
|
5939
|
-
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
5926
|
+
cleanDispatchEntries(matchFn);
|
|
5940
5927
|
}
|
|
5941
5928
|
|
|
5942
5929
|
return jsonReply(res, 200, { ok: true, cancelled });
|
|
@@ -6367,7 +6354,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6367
6354
|
// safeJsonArr is sync but reads a single small file — leave as is.
|
|
6368
6355
|
const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
|
|
6369
6356
|
const completedPrdFiles = new Set(
|
|
6370
|
-
centralWi.filter(w => w.type ===
|
|
6357
|
+
centralWi.filter(w => w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile)
|
|
6371
6358
|
.map(w => w.planFile)
|
|
6372
6359
|
);
|
|
6373
6360
|
const plans = [];
|
|
@@ -6618,7 +6605,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6618
6605
|
|
|
6619
6606
|
// Propagate pause to materialized work items across all projects:
|
|
6620
6607
|
// kill any active agent process and reset non-completed items to paused.
|
|
6621
|
-
// Pattern:
|
|
6608
|
+
// Pattern: lockless reads → cleanDispatchEntries (atomic kill + remove) → mutateWorkItems.
|
|
6622
6609
|
const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
6623
6610
|
for (const proj of PROJECTS) {
|
|
6624
6611
|
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
@@ -6638,50 +6625,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
6638
6625
|
} catch { /* file may not exist */ }
|
|
6639
6626
|
}
|
|
6640
6627
|
|
|
6641
|
-
// Step 2:
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
const
|
|
6649
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
6653
|
-
|
|
6654
|
-
|
|
6655
|
-
}
|
|
6656
|
-
}
|
|
6657
|
-
|
|
6658
|
-
// Step 3: Kill agent processes OUTSIDE any lock (expensive, may take seconds).
|
|
6659
|
-
const killedAgents = new Set();
|
|
6660
|
-
for (const target of killTargets) {
|
|
6661
|
-
if (target.pid) {
|
|
6662
|
-
try {
|
|
6663
|
-
const safePid = shared.validatePid(target.pid);
|
|
6664
|
-
if (process.platform === 'win32') {
|
|
6665
|
-
require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
|
|
6666
|
-
} else {
|
|
6667
|
-
process.kill(safePid, 'SIGTERM');
|
|
6668
|
-
}
|
|
6669
|
-
} catch { /* process may be dead or invalid PID */ }
|
|
6670
|
-
}
|
|
6671
|
-
// Reset agent status file (no lock needed — agent-specific file).
|
|
6672
|
-
try {
|
|
6673
|
-
const agentStatus = safeJsonObj(target.statusPath);
|
|
6674
|
-
agentStatus.status = 'idle';
|
|
6675
|
-
delete agentStatus.currentTask;
|
|
6676
|
-
delete agentStatus.dispatched;
|
|
6677
|
-
safeWrite(target.statusPath, agentStatus);
|
|
6678
|
-
} catch (e) { console.error('agent reset:', e.message); }
|
|
6679
|
-
killedAgents.add(target.agent);
|
|
6628
|
+
// Step 2: Route PID resolution + kill + dispatch removal through the canonical primitive.
|
|
6629
|
+
// cleanDispatchEntries resolves PIDs from engine/tmp/dispatch-<id>-*/pid-<id>.pid
|
|
6630
|
+
// (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes the
|
|
6631
|
+
// entries via mutateDispatch (SQL + JSON mirror in one atomic write).
|
|
6632
|
+
// The defunct per-agent status sidecar reads/writes that used to live here are gone
|
|
6633
|
+
// (engine/queries.js documents that file no longer exists).
|
|
6634
|
+
if (dispatchedItemIds.size > 0) {
|
|
6635
|
+
const matchFn = (d) => {
|
|
6636
|
+
const itemId = d.meta?.item?.id;
|
|
6637
|
+
if (itemId && dispatchedItemIds.has(itemId)) return true;
|
|
6638
|
+
if (d.meta?.dispatchKey && [...dispatchedItemIds].some(id => d.meta.dispatchKey.includes(id))) return true;
|
|
6639
|
+
return false;
|
|
6640
|
+
};
|
|
6641
|
+
cleanDispatchEntries(matchFn);
|
|
6680
6642
|
}
|
|
6681
6643
|
|
|
6682
|
-
// Step
|
|
6644
|
+
// Step 3: Mutate work-items.json per path — pause items (each lock held briefly, no nesting).
|
|
6683
6645
|
let reset = 0;
|
|
6684
|
-
const resetItemIds = new Set();
|
|
6685
6646
|
for (const wiPath of wiPaths) {
|
|
6686
6647
|
try {
|
|
6687
6648
|
mutateWorkItems(wiPath, items => {
|
|
@@ -6696,24 +6657,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
6696
6657
|
delete w.dispatched_to;
|
|
6697
6658
|
delete w.failReason;
|
|
6698
6659
|
delete w.failedAt;
|
|
6699
|
-
if (w.id) resetItemIds.add(w.id);
|
|
6700
6660
|
}
|
|
6701
6661
|
});
|
|
6702
6662
|
} catch (e) { console.error('reset work items:', e.message); }
|
|
6703
6663
|
}
|
|
6704
6664
|
|
|
6705
|
-
// Step 5: Re-acquire dispatch lock to clean up active entries (brief lock, no nesting).
|
|
6706
|
-
mutateJsonFileLocked(dispatchPath, (dispatchData) => {
|
|
6707
|
-
dispatchData.active = Array.isArray(dispatchData.active) ? dispatchData.active : [];
|
|
6708
|
-
dispatchData.active = dispatchData.active.filter(d => {
|
|
6709
|
-
const itemId = d.meta?.item?.id;
|
|
6710
|
-
if (itemId && resetItemIds.has(itemId)) return false;
|
|
6711
|
-
if (killedAgents.has(d.agent)) return false;
|
|
6712
|
-
return true;
|
|
6713
|
-
});
|
|
6714
|
-
return dispatchData;
|
|
6715
|
-
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
6716
|
-
|
|
6717
6665
|
invalidateStatusCache();
|
|
6718
6666
|
invalidatePlansCache();
|
|
6719
6667
|
return jsonReply(res, 200, { ok: true, status: 'paused', resetWorkItems: reset });
|
|
@@ -6887,7 +6835,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6887
6835
|
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
6888
6836
|
mutateWorkItems(centralPath, items => {
|
|
6889
6837
|
for (const w of items) {
|
|
6890
|
-
if (w.type ===
|
|
6838
|
+
if (w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
|
|
6891
6839
|
w.status = WI_STATUS.CANCELLED;
|
|
6892
6840
|
w._cancelledBy = 'prd-deleted';
|
|
6893
6841
|
}
|
|
@@ -7034,7 +6982,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7034
6982
|
mutateWorkItems(wiPath, items => {
|
|
7035
6983
|
items.push({
|
|
7036
6984
|
id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
|
|
7037
|
-
type:
|
|
6985
|
+
type: WORK_TYPE.PLAN_TO_PRD, priority: 'high',
|
|
7038
6986
|
description: 'Revision requested on plan file: ' + (body.file.endsWith('.json') ? 'prd/' : 'plans/') + body.file + '\n\nFeedback:\n' + body.feedback + '\n\nRevise the plan to address this feedback. Read the existing plan, apply the feedback, and overwrite the file with the updated version. Set status back to "awaiting-approval".',
|
|
7039
6987
|
status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard:revision',
|
|
7040
6988
|
project: plan.project || '',
|
|
@@ -9237,6 +9185,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9237
9185
|
tickInterval: [10000], maxConcurrent: [1, 50], inboxConsolidateThreshold: [1],
|
|
9238
9186
|
agentTimeout: [60000], maxTurns: [5, 500], heartbeatTimeout: [60000],
|
|
9239
9187
|
worktreeCreateTimeout: [60000], worktreeCreateRetries: [0, 3],
|
|
9188
|
+
// W-mq1habhf: status-probe deadline inside assertCleanSharedWorktree.
|
|
9189
|
+
// 1s floor (anything shorter races with git startup on cold caches);
|
|
9190
|
+
// 2min ceiling (covers even a GVFS-backed sparse hydration without
|
|
9191
|
+
// letting a config typo wedge dispatch for an hour).
|
|
9192
|
+
assertCleanStatusTimeoutMs: [1000, 120000],
|
|
9240
9193
|
idleAlertMinutes: [1], shutdownTimeout: [30000], restartGracePeriod: [60000],
|
|
9241
9194
|
meetingRoundTimeout: [60000],
|
|
9242
9195
|
// W-mq066js7000fff1f-c (Gap B/C): steering safety-net knobs.
|
package/engine/ado-git-auth.js
CHANGED
|
@@ -180,8 +180,15 @@ function getAdoGitExtraArgs(project, opts = {}) {
|
|
|
180
180
|
return _composeAdoArgs(_buildHeaderArgs(token, _resolveScopeUrls(project)));
|
|
181
181
|
} catch (e) {
|
|
182
182
|
_backoffUntil = now + ACQUIRE_BACKOFF_MS;
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
// #3045 — log the FULL exec error (redacted) instead of just the first
|
|
184
|
+
// line. child_process.exec rejections start with "Command failed: <cmd>"
|
|
185
|
+
// and put the real stderr ("TF400813 unknown user", "HTTP 401", etc.)
|
|
186
|
+
// on the SECOND line; .split('\n')[0] threw that away and made every
|
|
187
|
+
// ADO auth failure look like an indistinguishable wall of "Command
|
|
188
|
+
// failed: ...". _redactBearer strips any bearer header echoed back by
|
|
189
|
+
// the failing git command so the token never reaches engine logs.
|
|
190
|
+
const fullMsg = _redactBearer(String(e && e.message || e));
|
|
191
|
+
log('warn', `ado-git-auth: token acquire failed (${fullMsg}); backing off ${ACQUIRE_BACKOFF_MS / 1000}s`);
|
|
185
192
|
return [...CREDENTIAL_DISABLE_ARGS];
|
|
186
193
|
}
|
|
187
194
|
}
|
|
@@ -272,6 +279,7 @@ module.exports = {
|
|
|
272
279
|
isAdoAuthFailure,
|
|
273
280
|
runAdoGit,
|
|
274
281
|
isAdoProject,
|
|
282
|
+
redactBearer: _redactBearer,
|
|
275
283
|
_setTokenForTest,
|
|
276
284
|
_clearTokenCache,
|
|
277
285
|
_isBackedOff,
|
package/engine/ado-status.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
const path = require('path');
|
|
25
25
|
const shared = require('./shared');
|
|
26
|
-
const { safeJson, safeJsonArr, projectPrPath, getProjects } = shared;
|
|
26
|
+
const { safeJson, safeJsonArr, projectPrPath, getProjects, MINIONS_DIR } = shared;
|
|
27
27
|
|
|
28
28
|
// ── Arg parsing ──────────────────────────────────────────────────────────────
|
|
29
29
|
|
|
@@ -87,7 +87,7 @@ function findInCache(projects) {
|
|
|
87
87
|
|
|
88
88
|
async function main() {
|
|
89
89
|
// Use safeJson directly — pulling in queries.js would load all cached state unnecessarily
|
|
90
|
-
const config = safeJson(path.join(
|
|
90
|
+
const config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
|
|
91
91
|
const allProjects = getProjects(config).filter(p => p.repoHost === 'ado' || !p.repoHost);
|
|
92
92
|
const projects = projectName
|
|
93
93
|
? allProjects.filter(p => p.name === projectName)
|
package/engine/cleanup.js
CHANGED
|
@@ -664,7 +664,7 @@ async function runCleanup(config, verbose = false) {
|
|
|
664
664
|
const prs = safeJson(projectPrPath(project)) || [];
|
|
665
665
|
const mergedBranches = new Set();
|
|
666
666
|
for (const pr of prs) {
|
|
667
|
-
if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED
|
|
667
|
+
if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
|
|
668
668
|
if (pr.branch) mergedBranches.add(pr.branch);
|
|
669
669
|
}
|
|
670
670
|
}
|
|
@@ -877,7 +877,7 @@ async function runCleanup(config, verbose = false) {
|
|
|
877
877
|
const freshMergedBranches = new Set();
|
|
878
878
|
const freshMergedPrByBranch = new Map();
|
|
879
879
|
for (const pr of freshPrs) {
|
|
880
|
-
if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED
|
|
880
|
+
if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
|
|
881
881
|
if (pr.branch) freshMergedBranches.add(pr.branch);
|
|
882
882
|
}
|
|
883
883
|
if (pr.status === shared.PR_STATUS.MERGED && pr.branch) {
|
package/engine/dispatch.js
CHANGED
|
@@ -986,7 +986,7 @@ function cleanDispatchEntries(matchFn) {
|
|
|
986
986
|
const safePid = shared.validatePid(pid);
|
|
987
987
|
if (process.platform === 'win32') {
|
|
988
988
|
const { execFileSync } = require('child_process');
|
|
989
|
-
execFileSync('taskkill', ['/PID', String(safePid), '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
|
|
989
|
+
execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
|
|
990
990
|
} else {
|
|
991
991
|
process.kill(safePid, 'SIGTERM');
|
|
992
992
|
}
|
package/engine/meeting.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const shared = require('./shared');
|
|
9
|
-
const { safeJson, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT } = shared;
|
|
9
|
+
const { safeJson, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT, MEETING_STATUS } = shared;
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const { getDispatch, getConfig } = queries;
|
|
12
12
|
const { renderPlaybook } = require('./playbook');
|
|
@@ -829,7 +829,7 @@ function advanceMeetingRound(meetingId) {
|
|
|
829
829
|
// already terminal. The authoritative status check still runs INSIDE the
|
|
830
830
|
// lock below.
|
|
831
831
|
const existing = getMeeting(meetingId);
|
|
832
|
-
if (!existing || existing.status ===
|
|
832
|
+
if (!existing || existing.status === MEETING_STATUS.COMPLETED || existing.status === MEETING_STATUS.ARCHIVED) return null;
|
|
833
833
|
|
|
834
834
|
// CRITICAL: kill BEFORE acquiring the meeting lock. _killMeetingDispatches
|
|
835
835
|
// takes the dispatch.json lock and shells out to kill processes — never
|
|
@@ -838,7 +838,7 @@ function advanceMeetingRound(meetingId) {
|
|
|
838
838
|
_killMeetingDispatches(meetingId);
|
|
839
839
|
|
|
840
840
|
return mutateMeeting(meetingId, (meeting) => {
|
|
841
|
-
if (!meeting || meeting.status ===
|
|
841
|
+
if (!meeting || meeting.status === MEETING_STATUS.COMPLETED || meeting.status === MEETING_STATUS.ARCHIVED) return null;
|
|
842
842
|
if (meeting.status === 'investigating') { meeting.status = 'debating'; meeting.round = 2; }
|
|
843
843
|
else if (meeting.status === 'debating') { meeting.status = 'concluding'; meeting.round = 3; }
|
|
844
844
|
else if (meeting.status === 'concluding') { meeting.status = 'completed'; meeting.completedAt = ts(); }
|
|
@@ -857,7 +857,7 @@ function endMeeting(meetingId) {
|
|
|
857
857
|
|
|
858
858
|
return mutateMeeting(meetingId, (meeting) => {
|
|
859
859
|
if (!meeting) return null;
|
|
860
|
-
meeting.status =
|
|
860
|
+
meeting.status = MEETING_STATUS.COMPLETED;
|
|
861
861
|
meeting.completedAt = ts();
|
|
862
862
|
return meeting;
|
|
863
863
|
});
|
|
@@ -866,7 +866,7 @@ function endMeeting(meetingId) {
|
|
|
866
866
|
function archiveMeeting(id) {
|
|
867
867
|
return mutateMeeting(id, (meeting) => {
|
|
868
868
|
if (!meeting) return null;
|
|
869
|
-
meeting.status =
|
|
869
|
+
meeting.status = MEETING_STATUS.ARCHIVED;
|
|
870
870
|
meeting.archivedAt = ts();
|
|
871
871
|
return meeting;
|
|
872
872
|
});
|
package/engine/queries.js
CHANGED
|
@@ -1717,9 +1717,10 @@ function resetPrdInfoCache() {
|
|
|
1717
1717
|
}
|
|
1718
1718
|
|
|
1719
1719
|
// ── Project git status (current branch + dirty/detached) ──────────────────
|
|
1720
|
-
// Cached per resolved localPath with a
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1720
|
+
// Cached per resolved localPath with a 15-second TTL (see
|
|
1721
|
+
// `PROJECT_GIT_STATUS_TTL` below). `getProjectGitStatus` is synchronous
|
|
1722
|
+
// (returns the cached value or a pending placeholder) and NEVER blocks the
|
|
1723
|
+
// event loop — the actual git probes run asynchronously via
|
|
1723
1724
|
// child_process.execFile and write back into the cache when they settle. This
|
|
1724
1725
|
// is critical for /api/status responsiveness: under the previous synchronous
|
|
1725
1726
|
// implementation, four projects × three git invocations was ~12 sync shell-outs
|
|
@@ -1730,10 +1731,15 @@ function resetPrdInfoCache() {
|
|
|
1730
1731
|
// stderr to suppress the `fatal: not a git repository` noise on non-git project
|
|
1731
1732
|
// paths — same requirement enforced for install/boot paths in
|
|
1732
1733
|
// test/unit/runtime-fleet-helpers.test.js:465.
|
|
1733
|
-
// Single map keyed by normalized localPath. Each entry holds
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1736
|
-
//
|
|
1734
|
+
// Single map keyed by normalized localPath. Each entry holds
|
|
1735
|
+
// `{ts, value, promise, promiseStartedAt, refMtimes}`:
|
|
1736
|
+
// ts: last-settled timestamp (Date.now()), or 0 if never settled
|
|
1737
|
+
// value: last-settled status object (or PROJECT_GIT_STATUS_PENDING placeholder)
|
|
1738
|
+
// promise: in-flight refresh Promise, or null when idle
|
|
1739
|
+
// promiseStartedAt: when the in-flight Promise started (Date.now()), 0 when idle
|
|
1740
|
+
// — used by the stale-pending guard to detect wedged probes
|
|
1741
|
+
// (W-mq19314k00101fc7)
|
|
1742
|
+
// refMtimes: snapshot of HEAD/FETCH_HEAD/refs/remotes mtimes at probe start
|
|
1737
1743
|
// Folding state + in-flight into one map keeps reads/writes atomic and removes
|
|
1738
1744
|
// the second-Map sync hazard.
|
|
1739
1745
|
const _projectGitStatusCache = new Map();
|
|
@@ -1752,21 +1758,76 @@ const PROJECT_GIT_STATUS_PENDING = Object.freeze({ gitBranch: null, gitDetached:
|
|
|
1752
1758
|
const PROJECT_GIT_STATUS_MISSING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing', remoteDefaultBranch: null, ahead: null, behind: null });
|
|
1753
1759
|
const PROJECT_GIT_STATUS_NON_GIT = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git', remoteDefaultBranch: null, ahead: null, behind: null });
|
|
1754
1760
|
|
|
1761
|
+
// Probe budget constants (W-mq19314k00101fc7). `PROBE_TIMEOUT_MS` is the
|
|
1762
|
+
// per-invocation execFile timeout; `PROBE_WATCHDOG_MS` is an OUTER
|
|
1763
|
+
// belt-and-suspenders rejection that always fires regardless of whether
|
|
1764
|
+
// execFile honored its `timeout` option. Production observation
|
|
1765
|
+
// (2026-06-05): on Windows, execFile's timeout can silently no-op when
|
|
1766
|
+
// Defender/AV stalls the child's I/O, leaving the in-flight Promise
|
|
1767
|
+
// pending indefinitely; every subsequent caller then got the dead Promise
|
|
1768
|
+
// back from the single-flight guard in `_scheduleProjectGitStatusRefresh`
|
|
1769
|
+
// and the cache wedged for minutes. The watchdog converts that hope into
|
|
1770
|
+
// a hard invariant: _gitExec always settles within PROBE_WATCHDOG_MS.
|
|
1771
|
+
const PROBE_TIMEOUT_MS = 10000;
|
|
1772
|
+
const PROBE_WATCHDOG_MS = 15000;
|
|
1773
|
+
let _probeTimeoutOverride = null;
|
|
1774
|
+
let _probeWatchdogOverride = null;
|
|
1775
|
+
// Test seam: shrink the watchdog budget so wedge regression tests can
|
|
1776
|
+
// run in <1s. Returns a restore function that resets to the production
|
|
1777
|
+
// default.
|
|
1778
|
+
function _setProbeWatchdogForTest({ timeoutMs, watchdogMs } = {}) {
|
|
1779
|
+
const prevTimeout = _probeTimeoutOverride;
|
|
1780
|
+
const prevWatchdog = _probeWatchdogOverride;
|
|
1781
|
+
if (typeof timeoutMs === 'number') _probeTimeoutOverride = timeoutMs;
|
|
1782
|
+
if (typeof watchdogMs === 'number') _probeWatchdogOverride = watchdogMs;
|
|
1783
|
+
return () => { _probeTimeoutOverride = prevTimeout; _probeWatchdogOverride = prevWatchdog; };
|
|
1784
|
+
}
|
|
1785
|
+
function _getProbeTimeoutsForTest() {
|
|
1786
|
+
return {
|
|
1787
|
+
timeoutMs: _probeTimeoutOverride != null ? _probeTimeoutOverride : PROBE_TIMEOUT_MS,
|
|
1788
|
+
watchdogMs: _probeWatchdogOverride != null ? _probeWatchdogOverride : PROBE_WATCHDOG_MS,
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1755
1792
|
// Async git invocation. Promise-returning so getProjectGitStatus can fire
|
|
1756
1793
|
// background refreshes without blocking the event loop. Pipes stderr to keep
|
|
1757
1794
|
// `fatal: not a git repository` from leaking to the dashboard console.
|
|
1795
|
+
//
|
|
1796
|
+
// W-mq19314k00101fc7 — wraps execFile in an outer watchdog Promise.race so
|
|
1797
|
+
// the returned Promise ALWAYS settles within PROBE_WATCHDOG_MS, even if
|
|
1798
|
+
// execFile's own `timeout` option silently no-ops (the Windows production
|
|
1799
|
+
// failure mode). On watchdog fire, we SIGKILL the child explicitly so an
|
|
1800
|
+
// orphaned git.exe doesn't linger.
|
|
1758
1801
|
function _gitExec(localPath, args) {
|
|
1759
1802
|
const { execFile } = require('child_process');
|
|
1803
|
+
const timeoutMs = _probeTimeoutOverride != null ? _probeTimeoutOverride : PROBE_TIMEOUT_MS;
|
|
1804
|
+
const watchdogMs = _probeWatchdogOverride != null ? _probeWatchdogOverride : PROBE_WATCHDOG_MS;
|
|
1760
1805
|
return new Promise((resolve, reject) => {
|
|
1761
|
-
|
|
1806
|
+
let settled = false;
|
|
1807
|
+
let watchdogTimer = null;
|
|
1808
|
+
const child = execFile('git', ['-C', localPath, ...args], {
|
|
1762
1809
|
encoding: 'utf8',
|
|
1763
|
-
timeout:
|
|
1810
|
+
timeout: timeoutMs,
|
|
1764
1811
|
windowsHide: true,
|
|
1765
1812
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1766
1813
|
}, (err, stdout) => {
|
|
1814
|
+
if (settled) return;
|
|
1815
|
+
settled = true;
|
|
1816
|
+
if (watchdogTimer) clearTimeout(watchdogTimer);
|
|
1767
1817
|
if (err) reject(err);
|
|
1768
1818
|
else resolve(stdout);
|
|
1769
1819
|
});
|
|
1820
|
+
watchdogTimer = setTimeout(() => {
|
|
1821
|
+
if (settled) return;
|
|
1822
|
+
settled = true;
|
|
1823
|
+
// Belt-and-suspenders: explicitly kill the stuck child so it can't
|
|
1824
|
+
// linger as an orphaned git.exe. SIGKILL on Unix, forced termination
|
|
1825
|
+
// on Windows (Node maps both to TerminateProcess via libuv).
|
|
1826
|
+
try { if (child && typeof child.kill === 'function') child.kill('SIGKILL'); }
|
|
1827
|
+
catch { /* child may already be dead */ }
|
|
1828
|
+
reject(new Error(`git probe watchdog: \`git ${args.join(' ')}\` did not settle within ${watchdogMs}ms`));
|
|
1829
|
+
}, watchdogMs);
|
|
1830
|
+
if (watchdogTimer && typeof watchdogTimer.unref === 'function') watchdogTimer.unref();
|
|
1770
1831
|
});
|
|
1771
1832
|
}
|
|
1772
1833
|
|
|
@@ -1874,8 +1935,26 @@ function _projectGitStatusEqual(a, b) {
|
|
|
1874
1935
|
|
|
1875
1936
|
function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch) {
|
|
1876
1937
|
const existing = _projectGitStatusCache.get(key);
|
|
1877
|
-
|
|
1878
|
-
|
|
1938
|
+
// Stale-pending safety net (W-mq19314k00101fc7 — option b). The _gitExec
|
|
1939
|
+
// watchdog (option a) is the primary guarantee that an in-flight probe
|
|
1940
|
+
// ALWAYS settles within PROBE_WATCHDOG_MS. This second-tier check is a
|
|
1941
|
+
// belt-and-suspenders for any future async path that might wedge BEFORE
|
|
1942
|
+
// _gitExec is reached (e.g. fs.existsSync hanging on a disconnected
|
|
1943
|
+
// network drive, or a refactor introducing a pre-gitExec await). If
|
|
1944
|
+
// `entry.promise` is older than 2× the watchdog budget, treat it as
|
|
1945
|
+
// dead and schedule a fresh probe rather than serving the stuck Promise
|
|
1946
|
+
// forever.
|
|
1947
|
+
if (existing && existing.promise) {
|
|
1948
|
+
const watchdogMs = _probeWatchdogOverride != null ? _probeWatchdogOverride : PROBE_WATCHDOG_MS;
|
|
1949
|
+
const startedAt = existing.promiseStartedAt || 0;
|
|
1950
|
+
const age = startedAt ? (Date.now() - startedAt) : 0;
|
|
1951
|
+
if (!startedAt || age < (2 * watchdogMs)) return existing.promise;
|
|
1952
|
+
// Past 2× budget — wedged. Fall through and schedule a fresh probe;
|
|
1953
|
+
// the wedged Promise is intentionally orphaned (its later resolution,
|
|
1954
|
+
// if any, can no longer race the new probe because we overwrite
|
|
1955
|
+
// entry.promise + entry.promiseStartedAt below).
|
|
1956
|
+
}
|
|
1957
|
+
const entry = existing || { ts: 0, value: PROJECT_GIT_STATUS_PENDING, promise: null, promiseStartedAt: 0, refMtimes: null };
|
|
1879
1958
|
const prevValue = entry.value;
|
|
1880
1959
|
// Snapshot ref mtimes BEFORE the probe so the next call compares against
|
|
1881
1960
|
// an exact baseline rather than a Date.now() timestamp. On Windows
|
|
@@ -1884,11 +1963,13 @@ function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch)
|
|
|
1884
1963
|
// even when nothing actually changed.
|
|
1885
1964
|
const probeStartTs = Date.now();
|
|
1886
1965
|
const probeStartRefMtimes = _snapshotProjectGitRefMtimes(localPath, configuredMainBranch);
|
|
1966
|
+
entry.promiseStartedAt = probeStartTs;
|
|
1887
1967
|
entry.promise = _probeProjectGitStatus(localPath, configuredMainBranch).then(value => {
|
|
1888
1968
|
entry.ts = probeStartTs;
|
|
1889
1969
|
entry.refMtimes = probeStartRefMtimes;
|
|
1890
1970
|
entry.value = value;
|
|
1891
1971
|
entry.promise = null;
|
|
1972
|
+
entry.promiseStartedAt = 0;
|
|
1892
1973
|
if (_onProjectGitStatusChanged && !_projectGitStatusEqual(prevValue, value)) {
|
|
1893
1974
|
try { _onProjectGitStatusChanged(key, value, prevValue); }
|
|
1894
1975
|
catch { /* hook must never break the probe */ }
|
|
@@ -1896,6 +1977,7 @@ function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch)
|
|
|
1896
1977
|
return value;
|
|
1897
1978
|
}, () => {
|
|
1898
1979
|
entry.promise = null;
|
|
1980
|
+
entry.promiseStartedAt = 0;
|
|
1899
1981
|
return null;
|
|
1900
1982
|
});
|
|
1901
1983
|
_projectGitStatusCache.set(key, entry);
|
|
@@ -2109,6 +2191,16 @@ function resetProjectGitStatusCache() {
|
|
|
2109
2191
|
_projectGitStatusCache.clear();
|
|
2110
2192
|
}
|
|
2111
2193
|
|
|
2194
|
+
// Test seam (W-mq19314k00101fc7): expose the internal cache Map so the
|
|
2195
|
+
// wedge regression test can inject a synthetic "stuck Promise + stale
|
|
2196
|
+
// promiseStartedAt" entry that mirrors the production failure mode without
|
|
2197
|
+
// having to wait out a real watchdog. Production callers must never read
|
|
2198
|
+
// or mutate this Map directly — always go through getProjectGitStatus /
|
|
2199
|
+
// warmProjectGitStatus.
|
|
2200
|
+
function _getProjectGitStatusCacheForTest() {
|
|
2201
|
+
return _projectGitStatusCache;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2112
2204
|
/**
|
|
2113
2205
|
* Files whose mtime must trigger a dashboard `_fastState` rebuild
|
|
2114
2206
|
* (W-mpftp7na000td0f4). Single source of truth for the dashboard's
|
|
@@ -2378,6 +2470,9 @@ module.exports = {
|
|
|
2378
2470
|
warmProjectGitStatus,
|
|
2379
2471
|
_awaitPendingProjectGitStatusProbes,
|
|
2380
2472
|
_setOnProjectGitStatusChanged,
|
|
2473
|
+
_setProbeWatchdogForTest,
|
|
2474
|
+
_getProbeTimeoutsForTest,
|
|
2475
|
+
_getProjectGitStatusCacheForTest,
|
|
2381
2476
|
// W-mpftp7na000td0f4 — engine→dashboard cache-invalidation registry
|
|
2382
2477
|
getStatusFastStateMtimePaths,
|
|
2383
2478
|
getStatusSlowStateMtimePaths,
|
package/engine/shared.js
CHANGED
|
@@ -2218,6 +2218,16 @@ const ENGINE_DEFAULTS = {
|
|
|
2218
2218
|
worktreeCreateRetries: 1, // retry once on transient timeout/lock races
|
|
2219
2219
|
worktreeRoot: '../worktrees',
|
|
2220
2220
|
worktreeCountCacheTtl: 30000, // 30s — TTL for cached _countWorktrees() result in dashboard
|
|
2221
|
+
// W-mq1habhf: timeout (ms) for the `git status --porcelain` probe inside
|
|
2222
|
+
// engine.js assertCleanSharedWorktree. The default 10s is fine for ordinary
|
|
2223
|
+
// repos but too tight for GVFS/Plastic-backed monorepos where the first
|
|
2224
|
+
// status against a freshly-recreated worktree blocks on sparse hydration.
|
|
2225
|
+
// When the probe times out the engine now quarantines the bad worktree
|
|
2226
|
+
// (status-failed branch) and reports a RETRYABLE failure so the next
|
|
2227
|
+
// dispatch starts on a fresh worktree. Operators on large repos should
|
|
2228
|
+
// raise this knob (e.g. 60000) before tuning anything else. Clamped to
|
|
2229
|
+
// [1000, 120000] in dashboard.js POST /api/settings.
|
|
2230
|
+
assertCleanStatusTimeoutMs: 10000,
|
|
2221
2231
|
workItemCreateDedupWindowMs: 15 * 60 * 1000, // 15min — collapse duplicate CC/API create races
|
|
2222
2232
|
idleAlertMinutes: 15,
|
|
2223
2233
|
fanOutTimeout: null, // falls back to agentTimeout
|
|
@@ -3428,7 +3438,7 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
|
|
|
3428
3438
|
let item = null;
|
|
3429
3439
|
mutateJsonFileLocked(centralWiPath, items => {
|
|
3430
3440
|
if (!Array.isArray(items)) items = [];
|
|
3431
|
-
const existing = items.find(w => w.type ===
|
|
3441
|
+
const existing = items.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === planFile && (w.status === 'pending' || w.status === 'dispatched'));
|
|
3432
3442
|
if (existing) {
|
|
3433
3443
|
id = existing.id;
|
|
3434
3444
|
item = existing;
|
|
@@ -3437,7 +3447,7 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
|
|
|
3437
3447
|
const newItem = {
|
|
3438
3448
|
id: 'W-' + uid(),
|
|
3439
3449
|
title,
|
|
3440
|
-
type:
|
|
3450
|
+
type: WORK_TYPE.PLAN_TO_PRD,
|
|
3441
3451
|
priority: 'high',
|
|
3442
3452
|
description,
|
|
3443
3453
|
status: 'pending',
|
|
@@ -5021,7 +5031,40 @@ function extractPrRefFromText(value) {
|
|
|
5021
5031
|
return numberMatch ? numberMatch[1] : null;
|
|
5022
5032
|
}
|
|
5023
5033
|
|
|
5024
|
-
|
|
5034
|
+
// Cap for the description-text fallback scan in `extractWorkItemPrRef`.
|
|
5035
|
+
// Operators pasting a PR pointer put it at the TOP of the description;
|
|
5036
|
+
// long technical descriptions (CI failure reports, blast-radius examples,
|
|
5037
|
+
// 'upstream commit' references) mention OTHER PRs as context deeper in the
|
|
5038
|
+
// body — those mentions must not become the work-item's PR target.
|
|
5039
|
+
// See W-mq17dukn000ef114 (narrowing of W-mpx6i5kh000ac040 / issue #2999).
|
|
5040
|
+
const DESCRIPTION_PR_SCAN_MAX = 500;
|
|
5041
|
+
|
|
5042
|
+
function _descriptionPrScanSlice(description) {
|
|
5043
|
+
const text = String(description || '');
|
|
5044
|
+
if (!text) return '';
|
|
5045
|
+
// First paragraph boundary OR first DESCRIPTION_PR_SCAN_MAX chars,
|
|
5046
|
+
// whichever is shorter. Paragraph split is blank-line (allowing trailing
|
|
5047
|
+
// whitespace on the blank line).
|
|
5048
|
+
const firstParagraph = text.split(/\n\s*\n/, 1)[0];
|
|
5049
|
+
return firstParagraph.slice(0, DESCRIPTION_PR_SCAN_MAX);
|
|
5050
|
+
}
|
|
5051
|
+
|
|
5052
|
+
// Structured-only PR-ref extraction (W-mq18ec6h000p7b87). Walks ONLY the
|
|
5053
|
+
// canonical structured sources — targetPr / pr / pr_id / prId / sourcePr /
|
|
5054
|
+
// pullRequest / prUrl / prNumber, references[*].url, meta.pr_followup
|
|
5055
|
+
// .parent_pr_url. Does NOT scan free-form description/title text. This is
|
|
5056
|
+
// the helper the engine's `pr_not_found` dispatch gate should call: it must
|
|
5057
|
+
// require explicit operator intent (a structured PR pointer) before blocking
|
|
5058
|
+
// dispatch, otherwise commentary like "see PR #3015 for context" in a
|
|
5059
|
+
// description trips the gate and a fresh refactor item with no real PR
|
|
5060
|
+
// target gets stuck in `_pendingReason: 'pr_not_found'` forever.
|
|
5061
|
+
//
|
|
5062
|
+
// Loose-form callers (branch derivation, prompt PR context, dashboard's
|
|
5063
|
+
// create-time stamp) should keep using `extractWorkItemPrRef` below — they
|
|
5064
|
+
// downgrade gracefully when no PR record matches the loose ref, and the
|
|
5065
|
+
// stamp path in particular preserves the operator UX of pasting a PR URL
|
|
5066
|
+
// into the description and getting `targetPr` auto-stamped.
|
|
5067
|
+
function extractStructuredWorkItemPrRef(item) {
|
|
5025
5068
|
if (!item || typeof item !== 'object') return null;
|
|
5026
5069
|
const structured = item.targetPr
|
|
5027
5070
|
|| item.pr
|
|
@@ -5032,8 +5075,6 @@ function extractWorkItemPrRef(item) {
|
|
|
5032
5075
|
|| item.prUrl
|
|
5033
5076
|
|| item.prNumber;
|
|
5034
5077
|
if (structured) return structured;
|
|
5035
|
-
// references[*].url — manual /api/work-items callers often supply the PR
|
|
5036
|
-
// pointer here when no structured prId/targetPr is known up front.
|
|
5037
5078
|
if (Array.isArray(item.references)) {
|
|
5038
5079
|
for (const ref of item.references) {
|
|
5039
5080
|
const url = ref && typeof ref === 'object' ? ref.url : null;
|
|
@@ -5041,14 +5082,26 @@ function extractWorkItemPrRef(item) {
|
|
|
5041
5082
|
if (fromUrl) return fromUrl;
|
|
5042
5083
|
}
|
|
5043
5084
|
}
|
|
5044
|
-
// meta.pr_followup.parent_pr_url — set by playbooks/templates/followup-dispatch.md.
|
|
5045
5085
|
const followupUrl = item?.meta?.pr_followup?.parent_pr_url;
|
|
5046
5086
|
if (followupUrl) {
|
|
5047
5087
|
const fromFollowup = extractPrRefFromText(followupUrl);
|
|
5048
5088
|
if (fromFollowup) return fromFollowup;
|
|
5049
5089
|
}
|
|
5050
|
-
|
|
5051
|
-
|
|
5090
|
+
return null;
|
|
5091
|
+
}
|
|
5092
|
+
|
|
5093
|
+
function extractWorkItemPrRef(item) {
|
|
5094
|
+
if (!item || typeof item !== 'object') return null;
|
|
5095
|
+
const fromStructured = extractStructuredWorkItemPrRef(item);
|
|
5096
|
+
if (fromStructured) return fromStructured;
|
|
5097
|
+
// Last resort: scan the FIRST PARAGRAPH of description (capped at
|
|
5098
|
+
// DESCRIPTION_PR_SCAN_MAX chars) + title for a PR URL / canonical id.
|
|
5099
|
+
// The narrow slice prevents long `type: fix` descriptions that quote other
|
|
5100
|
+
// PRs as context from false-positiving the fix WI onto the wrong PR
|
|
5101
|
+
// (W-mq17dukn000ef114). This branch is intentionally separate from
|
|
5102
|
+
// extractStructuredWorkItemPrRef so the dispatch gate can require
|
|
5103
|
+
// structured-only intent (W-mq18ec6h000p7b87).
|
|
5104
|
+
const fromDescription = extractPrRefFromText(_descriptionPrScanSlice(item.description));
|
|
5052
5105
|
if (fromDescription) return fromDescription;
|
|
5053
5106
|
return extractPrRefFromText(item.title) || null;
|
|
5054
5107
|
}
|
|
@@ -6446,6 +6499,7 @@ module.exports = {
|
|
|
6446
6499
|
parsePrUrl, // exported for testing
|
|
6447
6500
|
extractPrRefFromText,
|
|
6448
6501
|
extractWorkItemPrRef,
|
|
6502
|
+
extractStructuredWorkItemPrRef,
|
|
6449
6503
|
getProjectPrScope,
|
|
6450
6504
|
getPrNumber,
|
|
6451
6505
|
getPrDisplayId,
|
package/engine.js
CHANGED
|
@@ -744,18 +744,27 @@ async function _fetchWithTransientRetry(args, opts, label) {
|
|
|
744
744
|
return true;
|
|
745
745
|
} catch (e) {
|
|
746
746
|
if (!_isTransientGitNetworkError(e)) {
|
|
747
|
-
log
|
|
747
|
+
// #3045 — log the FULL exec error message (redacted), not just the first
|
|
748
|
+
// line. child_process.exec rejections format as "Command failed: <cmd>"
|
|
749
|
+
// on line 1 and the real stderr ("TF400813 unknown user", "HTTP 401",
|
|
750
|
+
// etc.) on line 2; pre-fix we kept only line 1 via a first-line split,
|
|
751
|
+
// throwing away the actionable stderr and making every swallowed fetch
|
|
752
|
+
// warning look identical, hiding ADO auth failures for the entire #3045
|
|
753
|
+
// incident. _redactBearer keeps any bearer header echoed in the failing
|
|
754
|
+
// git command out of engine logs.
|
|
755
|
+
log('warn', `git fetch ${label}: ${adoGitAuth.redactBearer(String(e.message || e))}`);
|
|
748
756
|
return false; // swallow non-transient — caller falls back to local ref
|
|
749
757
|
}
|
|
750
|
-
const
|
|
751
|
-
log('warn', `git fetch ${label}: transient (${
|
|
758
|
+
const transientMsg = adoGitAuth.redactBearer(String(e.message || e));
|
|
759
|
+
log('warn', `git fetch ${label}: transient (${transientMsg}) — retrying once after 1.5s`);
|
|
752
760
|
await new Promise(r => setTimeout(r, 1500));
|
|
753
761
|
try {
|
|
754
762
|
await _attempt();
|
|
755
763
|
log('info', `git fetch ${label}: succeeded on retry`);
|
|
756
764
|
return true;
|
|
757
765
|
} catch (e2) {
|
|
758
|
-
|
|
766
|
+
const retryMsg = adoGitAuth.redactBearer(String(e2.message || e2));
|
|
767
|
+
log('warn', `git fetch ${label}: retry also failed (${retryMsg}) — falling back to local ref`);
|
|
759
768
|
return false;
|
|
760
769
|
}
|
|
761
770
|
}
|
|
@@ -909,6 +918,14 @@ async function pruneStaleWorktreeForBranch(rootDir, branchName, gitOpts) {
|
|
|
909
918
|
// WORKTREE_DIVERGENT reason and retry semantics.
|
|
910
919
|
async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, dispatchId, gitOpts = {}, opts = {}) {
|
|
911
920
|
const quarantineOnUnsafe = !!opts.quarantineOnUnsafe;
|
|
921
|
+
// W-mq1habhf: status probe timeout is now configurable so large/GVFS repos
|
|
922
|
+
// can raise it past the 10s default. Clamped to [1000, 120000] to mirror
|
|
923
|
+
// the dashboard validator; mis-set values fall back to the default.
|
|
924
|
+
const statusTimeoutMs = (() => {
|
|
925
|
+
const raw = Number(opts.statusTimeoutMs);
|
|
926
|
+
if (!Number.isFinite(raw) || raw <= 0) return ENGINE_DEFAULTS.assertCleanStatusTimeoutMs;
|
|
927
|
+
return Math.max(1000, Math.min(120000, raw));
|
|
928
|
+
})();
|
|
912
929
|
const result = {
|
|
913
930
|
clean: false, healed: false, reason: null, dirtyFiles: [],
|
|
914
931
|
ahead: 0, behind: 0,
|
|
@@ -917,11 +934,56 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
917
934
|
// 1. Status probe (filesystem)
|
|
918
935
|
let statusOut = '';
|
|
919
936
|
try {
|
|
920
|
-
const r = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout:
|
|
937
|
+
const r = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
|
|
921
938
|
statusOut = (r || '').toString().trim();
|
|
922
939
|
} catch (e) {
|
|
940
|
+
// W-mq1habhf: previously this bailed out with the bad worktree intact,
|
|
941
|
+
// and every same-WI retry hit the identical timeout (the .git/worktrees/
|
|
942
|
+
// metadata + branch ref linger from the prior aborted dispatch, so
|
|
943
|
+
// reuse-by-branch keeps re-finding the same broken tree). When the
|
|
944
|
+
// caller opts into quarantineOnUnsafe and no other dispatch claims the
|
|
945
|
+
// branch, we now quarantine the worktree out of the way (rename →
|
|
946
|
+
// worktree prune → reset refs/heads/<branch>) so the next dispatch
|
|
947
|
+
// creates a fresh worktree. The caller emits a RETRYABLE failure
|
|
948
|
+
// (distinct from has-unpushed-commits/no-upstream which preserve
|
|
949
|
+
// potentially-unpushed work and stay non-retryable).
|
|
923
950
|
result.reason = 'status-failed';
|
|
924
951
|
result.error = e.message;
|
|
952
|
+
if (quarantineOnUnsafe) {
|
|
953
|
+
const sanitizedB = sanitizeBranch(branchName);
|
|
954
|
+
let otherActive = false;
|
|
955
|
+
try {
|
|
956
|
+
mutateDispatch((dp) => {
|
|
957
|
+
otherActive = (dp.active || []).some(d => {
|
|
958
|
+
if (d.id === dispatchId) return false;
|
|
959
|
+
const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
|
|
960
|
+
return dBranch === sanitizedB;
|
|
961
|
+
});
|
|
962
|
+
return dp;
|
|
963
|
+
});
|
|
964
|
+
} catch (errOwn) {
|
|
965
|
+
log('warn', `assertCleanSharedWorktree: dispatch read failed during status-failed (${errOwn.message}) — skipping quarantine`);
|
|
966
|
+
otherActive = true;
|
|
967
|
+
}
|
|
968
|
+
if (!otherActive) {
|
|
969
|
+
try {
|
|
970
|
+
const q = await _quarantineDirtyWorktree(
|
|
971
|
+
rootDir, worktreePath, branchName, gitOpts,
|
|
972
|
+
{
|
|
973
|
+
dispatchId,
|
|
974
|
+
reason: result.reason,
|
|
975
|
+
statusError: e.message,
|
|
976
|
+
},
|
|
977
|
+
);
|
|
978
|
+
result.quarantined = true;
|
|
979
|
+
result.quarantinedPath = q.quarantinedPath;
|
|
980
|
+
result.backupRef = q.backupRef;
|
|
981
|
+
} catch (qErr) {
|
|
982
|
+
result.quarantineError = qErr.message;
|
|
983
|
+
log('error', `assertCleanSharedWorktree: quarantine after status-failed failed for ${worktreePath}: ${qErr.message}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
925
987
|
return result;
|
|
926
988
|
}
|
|
927
989
|
if (statusOut) {
|
|
@@ -937,7 +999,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
937
999
|
try {
|
|
938
1000
|
const r = await execAsync(
|
|
939
1001
|
`git rev-list --left-right --count refs/remotes/origin/${branchName}...HEAD`,
|
|
940
|
-
{ ...gitOpts, cwd: worktreePath, timeout:
|
|
1002
|
+
{ ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs },
|
|
941
1003
|
);
|
|
942
1004
|
const parts = String(r || '').trim().split(/\s+/);
|
|
943
1005
|
const behind = parseInt(parts[0], 10);
|
|
@@ -1000,7 +1062,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
1000
1062
|
try {
|
|
1001
1063
|
const r = await execAsync(
|
|
1002
1064
|
`git rev-list ${opts.mainBranch}..HEAD --count`,
|
|
1003
|
-
{ ...gitOpts, cwd: worktreePath, timeout:
|
|
1065
|
+
{ ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs },
|
|
1004
1066
|
);
|
|
1005
1067
|
const parsed = parseInt(String(r || '').trim(), 10);
|
|
1006
1068
|
if (Number.isFinite(parsed)) commitsAheadOfMain = parsed;
|
|
@@ -1059,7 +1121,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
1059
1121
|
|
|
1060
1122
|
// 7. Re-verify
|
|
1061
1123
|
try {
|
|
1062
|
-
const r2 = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout:
|
|
1124
|
+
const r2 = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
|
|
1063
1125
|
const after = (r2 || '').toString().trim();
|
|
1064
1126
|
if (after) {
|
|
1065
1127
|
result.reason = 'dirty-after-clean';
|
|
@@ -1853,7 +1915,10 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1853
1915
|
_phaseT.cleanCheckStart = Date.now();
|
|
1854
1916
|
const cleanResult = await assertCleanSharedWorktree(
|
|
1855
1917
|
rootDir, worktreePath, branchName, id, _gitOpts,
|
|
1856
|
-
{
|
|
1918
|
+
{
|
|
1919
|
+
mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch),
|
|
1920
|
+
statusTimeoutMs: engineConfig.assertCleanStatusTimeoutMs ?? ENGINE_DEFAULTS.assertCleanStatusTimeoutMs,
|
|
1921
|
+
},
|
|
1857
1922
|
);
|
|
1858
1923
|
_phaseT.cleanCheckEnd = Date.now();
|
|
1859
1924
|
if (!cleanResult.clean) {
|
|
@@ -1912,12 +1977,26 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1912
1977
|
_phaseT.dirtyReusedCheckStart = Date.now();
|
|
1913
1978
|
const cleanResult = await assertCleanSharedWorktree(
|
|
1914
1979
|
rootDir, worktreePath, branchName, id, _gitOpts,
|
|
1915
|
-
{
|
|
1980
|
+
{
|
|
1981
|
+
quarantineOnUnsafe: true,
|
|
1982
|
+
mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch),
|
|
1983
|
+
statusTimeoutMs: engineConfig.assertCleanStatusTimeoutMs ?? ENGINE_DEFAULTS.assertCleanStatusTimeoutMs,
|
|
1984
|
+
},
|
|
1916
1985
|
);
|
|
1917
1986
|
_phaseT.dirtyReusedCheckEnd = Date.now();
|
|
1918
1987
|
if (!cleanResult.clean) {
|
|
1919
1988
|
const previewFiles = (cleanResult.dirtyFiles || []).slice(0, 5).join(', ');
|
|
1920
1989
|
const isDivergent = (cleanResult.ahead || 0) > 0 || cleanResult.reason === 'no-upstream';
|
|
1990
|
+
// W-mq1habhf: status-failed is a different beast than
|
|
1991
|
+
// has-unpushed-commits / no-upstream. We couldn't even read the
|
|
1992
|
+
// worktree state, so there is nothing to lose by retrying after the
|
|
1993
|
+
// quarantine has cleared the bad tree. Mark this branch retryable
|
|
1994
|
+
// (agentRetryable: true) so the next dispatch creates a fresh
|
|
1995
|
+
// worktree on the same WI without burning a retry slot against the
|
|
1996
|
+
// quarantineAutoRecoveryMax counter. has-unpushed-commits /
|
|
1997
|
+
// no-upstream / dirty-after-clean stay non-retryable because they
|
|
1998
|
+
// protect potentially-unpushed agent work.
|
|
1999
|
+
const isStatusProbeFailed = cleanResult.reason === 'status-failed';
|
|
1921
2000
|
const failureClassValue = isDivergent ? FAILURE_CLASS.WORKTREE_DIVERGENT : FAILURE_CLASS.WORKTREE_DIRTY;
|
|
1922
2001
|
const failureClassName = isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY';
|
|
1923
2002
|
const reasonMsg = cleanResult.quarantined
|
|
@@ -1930,7 +2009,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1930
2009
|
DISPATCH_RESULT.ERROR,
|
|
1931
2010
|
reasonMsg.slice(0, 500),
|
|
1932
2011
|
`Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996). Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : ''}`,
|
|
1933
|
-
{ agentRetryable:
|
|
2012
|
+
{ agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
|
|
1934
2013
|
);
|
|
1935
2014
|
cleanupTempAgent(agentId);
|
|
1936
2015
|
return null;
|
|
@@ -5740,9 +5819,27 @@ function getWorkItemPrRef(item) {
|
|
|
5740
5819
|
// (issue #2999 / W-mpx6i5kh000ac040). Defence in depth: even if a manual
|
|
5741
5820
|
// /api/work-items create slipped through without structured PR fields, the
|
|
5742
5821
|
// engine still detects the PR pointer here at dispatch time.
|
|
5822
|
+
//
|
|
5823
|
+
// NOTE: this loose form is for callers that downgrade gracefully on miss
|
|
5824
|
+
// (resolveWorkItemPrRecord returning null, withWorkItemPrContext skipping
|
|
5825
|
+
// injection, branch derivation falling through to default). The dispatch
|
|
5826
|
+
// *gate* (`pr_not_found`) must NOT use this — it would trip on commentary
|
|
5827
|
+
// like "see PR #3015 for context" in a description with no real PR target.
|
|
5828
|
+
// The gate uses getStructuredWorkItemPrRef below (W-mq18ec6h000p7b87).
|
|
5743
5829
|
return shared.extractWorkItemPrRef(item);
|
|
5744
5830
|
}
|
|
5745
5831
|
|
|
5832
|
+
function getStructuredWorkItemPrRef(item) {
|
|
5833
|
+
// Structured-only variant used ONLY by the dispatch `pr_not_found` gate
|
|
5834
|
+
// (W-mq18ec6h000p7b87). Walks targetPr/pr/pr_id/prId/sourcePr/pullRequest
|
|
5835
|
+
// /prUrl/prNumber, references[*].url, and meta.pr_followup.parent_pr_url
|
|
5836
|
+
// — never description/title prose. This asymmetry is intentional:
|
|
5837
|
+
// - GATE (here): require explicit operator intent to block dispatch.
|
|
5838
|
+
// - STAMP (dashboard.js#getWorkItemPrRef): keep loose form so operators
|
|
5839
|
+
// pasting PR URLs in description prose get targetPr auto-stamped.
|
|
5840
|
+
return shared.extractStructuredWorkItemPrRef(item);
|
|
5841
|
+
}
|
|
5842
|
+
|
|
5746
5843
|
function resolveWorkItemPrRecord(item, project) {
|
|
5747
5844
|
if (!project) return null;
|
|
5748
5845
|
const prRef = getWorkItemPrRef(item);
|
|
@@ -6058,9 +6155,16 @@ function discoverFromWorkItems(config, project) {
|
|
|
6058
6155
|
const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
|
|
6059
6156
|
const prBranch = linkedPr?.branch || '';
|
|
6060
6157
|
const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
|
|
6061
|
-
|
|
6158
|
+
// W-mq18ec6h000p7b87: gate on the STRUCTURED-only ref. Loose
|
|
6159
|
+
// getWorkItemPrRef would also pick up description-scan refs (e.g. a
|
|
6160
|
+
// refactor item whose prose mentioned "PR #3015 for context") and trip
|
|
6161
|
+
// the gate even though the item has no real PR target. The dashboard's
|
|
6162
|
+
// create-time stamp path keeps using the loose form — gate uses
|
|
6163
|
+
// structured-only; stamp uses loose. See engine/shared.js
|
|
6164
|
+
// extractStructuredWorkItemPrRef for the structured-source list.
|
|
6165
|
+
if (!linkedPr && getStructuredWorkItemPrRef(item) && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
|
|
6062
6166
|
if (item._pendingReason !== 'pr_not_found') { item._pendingReason = 'pr_not_found'; needsWrite = true; }
|
|
6063
|
-
log('warn', `Work item ${item.id} references PR ${
|
|
6167
|
+
log('warn', `Work item ${item.id} references PR ${getStructuredWorkItemPrRef(item)} but no tracked PR record was found`);
|
|
6064
6168
|
continue;
|
|
6065
6169
|
}
|
|
6066
6170
|
const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2129",
|
|
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"
|
package/prompts/cc-system.md
CHANGED
|
@@ -64,7 +64,7 @@ State the size in 3-4 words to yourself, then act:
|
|
|
64
64
|
|
|
65
65
|
### Step 2 — Delegate when ≥ Medium (the hard stop)
|
|
66
66
|
Always delegate these to an agent — do not attempt them yourself even if they look small at first:
|
|
67
|
-
- Code changes, fixes, refactors, new features → `POST /api/work-items` with `type: "implement"`
|
|
67
|
+
- Code changes, fixes, refactors, new features → `POST /api/work-items` with `type: "implement"` (use `"fix"` only when the work targets a specific tracked PR — see the type-selection note under "Calling the Minions API" below)
|
|
68
68
|
- Exploration, investigation, research, audits → `POST /api/work-items` with `type: "explore"`
|
|
69
69
|
- Code reviews → `POST /api/work-items` with `type: "review"`
|
|
70
70
|
- Testing → `POST /api/work-items` with `type: "test"`
|
|
@@ -110,12 +110,20 @@ Returns `{routes: [{method, path, description, params}]}`. Use this when you nee
|
|
|
110
110
|
|
|
111
111
|
**Worked examples** (always include `Content-Type: application/json` on POSTs with a JSON body):
|
|
112
112
|
|
|
113
|
-
Dispatch a
|
|
113
|
+
Dispatch a fresh bugfix (no tracked PR yet — opens a new PR):
|
|
114
114
|
```
|
|
115
115
|
curl -s -X POST http://localhost:{{dashboard_port}}/api/work-items \
|
|
116
116
|
-H 'Content-Type: application/json' \
|
|
117
117
|
-H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
118
|
-
-d '{"title":"Fix login bug","type":"
|
|
118
|
+
-d '{"title":"Fix login bug","type":"implement","project":"MyApp","description":"...","agent":"dallas"}'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Dispatch a fix against a tracked PR (responds to comments / failing builds on that PR):
|
|
122
|
+
```
|
|
123
|
+
curl -s -X POST http://localhost:{{dashboard_port}}/api/work-items \
|
|
124
|
+
-H 'Content-Type: application/json' \
|
|
125
|
+
-H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
126
|
+
-d '{"title":"Address review feedback on PR #2533","type":"fix","project":"MyApp","description":"...","references":[{"url":"https://github.com/owner/MyApp/pull/2533","label":"PR #2533"}],"agent":"dallas"}'
|
|
119
127
|
```
|
|
120
128
|
|
|
121
129
|
Save a note:
|
|
@@ -155,7 +163,7 @@ Set up a "watch + CC triage" follow-up — fire CC headlessly when a watch trips
|
|
|
155
163
|
curl -s -X POST http://localhost:{{dashboard_port}}/api/watches \
|
|
156
164
|
-H 'Content-Type: application/json' \
|
|
157
165
|
-H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
158
|
-
-d '{"target":"W-...","targetType":"work-item","condition":"failed","action":{"type":"cc-triage","params":{"prompt":"WI {{target}} failed. Read the completion report and tail of live-output. If it is a flaky CI failure (transient, unrelated to the diff), file a follow-up `
|
|
166
|
+
-d '{"target":"W-...","targetType":"work-item","condition":"failed","action":{"type":"cc-triage","params":{"prompt":"WI {{target}} failed. Read the completion report and tail of live-output. If it is a flaky CI failure (transient, unrelated to the diff), file a follow-up `implement` WI titled \"Investigate flake on {{target}}\" (no tracked PR yet — fresh bugfix opens a new PR, so type=implement, not fix). Otherwise post an inbox note explaining the root cause and do nothing else.","attachArtifacts":["completion-report","live-output"]}}}'
|
|
159
167
|
```
|
|
160
168
|
The `cc-triage` action is internal — it runs in an isolated CC session (not your interactive ccSession) and never shows up in the user's tab list. Use it when "fire a single fixed work item" isn't expressive enough.
|
|
161
169
|
|
|
@@ -227,6 +235,7 @@ curl -s -X POST http://localhost:{{dashboard_port}}/api/qa/session \
|
|
|
227
235
|
|
|
228
236
|
**Required fields per endpoint** — the server returns `{ error: "..." }` if missing. Common cases:
|
|
229
237
|
- `POST /api/work-items`: `title` REQUIRED. `description` recommended. `project` REQUIRED when multiple projects are configured (server returns the list of known names if you guess wrong). `type` defaults to `implement`; valid values: `fix`, `implement`, `implement:large`, `setup`, `explore`, `ask`, `review`, `test`, `verify`. Agent hint via `agent` (string) or `agents` (array).
|
|
238
|
+
- **`fix` vs `implement` — pick deliberately.** `type: "fix"` in the engine means "this work item responds to comments or build failures on a tracked PR" — it requires a PR pointer (in `references[*].url`, `meta.pr_followup.parent_pr_url`, or `targetPr`/`pr_id`/`prNumber`) and is dispatched onto the PR's source branch. **Fresh bugfix work** that will open a NEW PR (engine bugs, default-branch CI failures, repo-level regressions found from logs) is `type: "implement"`, not `type: "fix"`. Emitting `type: "fix"` without a PR pointer trips the `pr_not_found` discovery gate (engine.js#discoverWork) and the WI stays blocked indefinitely. Rule of thumb: if the work could land on master via a brand-new PR, it's `implement`; if it must push commits into an existing PR's source branch, it's `fix`.
|
|
230
239
|
- Exempt from the `project` requirement (these run rootless or via central paths): `ask`, `explore`, `plan`, `plan-to-prd`, `meeting`. (`docs` is intentionally NOT exempt — it's write-capable and lands in `WORKTREE_REQUIRING_TYPES`, so it needs a real project worktree. For minions-repo docs work, pass `project: "minions"` explicitly.) `setup` is also in the project-required set — it operates inside a real project worktree but produces no PR. Every other type needs a project worktree, so the server rejects project-less creates with `400 { error, knownProjects }` when ≠1 project is configured.
|
|
231
240
|
- **`meta.keep_processes: true`** — opt-in flag that lets the agent leave specific descendant PIDs running after it exits (default: engine reaps EVERYTHING the agent spawned). **Set this whenever the user's intent is to leave a process alive after the agent finishes** — e.g. "spin up the dev server and exit", "start the watcher and leave it running", "set up my dev env", "keep the emulator open", "launch the daemon for me", "boot the constellation host and disconnect". Don't set it for normal build/test/run-once tasks (`npm test`, `npm run build`, one-shot scripts) — those should be reaped. Also accepts optional `meta.keep_processes_ttl_minutes` (default 60, hard-cap 1440 = 24h). When you set this flag, also make the WI title/description say something like "leave the dev server running" so the agent knows to write `agents/<id>/keep-pids.json` before exiting (the playbook injects the contract automatically when the flag is on). Example: `-d '{"title":"Spin up Constellation dev env and leave server running","type":"implement","project":"constellation","description":"Run bun install + bun run dev. Leave the dev server (port 5173) and Constellation host (port 3001) running after you exit so the user can iterate.","meta":{"keep_processes":true,"keep_processes_ttl_minutes":240}}'`. Inspect / kill kept PIDs anytime via `GET /api/keep-processes` and `POST /api/keep-processes/kill`.
|
|
232
241
|
- **`skipPr: true`** — opt-in flag that tells the engine NOT to enforce the PR-attachment contract for this work item, so the WI can complete `done` without the missing-PR hard-fail. **Set this when the dispatch mutates state OUTSIDE any tracked git repo and therefore cannot produce a PR** — e.g. cleaning `~/.claude/skills/`, editing runtime config under `~/.config/`, resetting the dashboard cache, mutating engine JSON state files (`engine/*.json`) the engine itself owns, or local tooling installs. **Do NOT set it for any task that touches a tracked repo's source** — even one-line diffs in a real repo should produce a PR. Type-selection rule of thumb: prefer `type: "setup"` for infra/dev-env bootstrap tasks that mutate project state but produce no PR (it's implicitly PR-exempt — no `skipPr` needed); prefer `type: "explore"` for genuinely read-only tasks (rootless, no worktree, no PR contract); use `skipPr: true` only when the task is write-side mutation but the writes don't land in a git repo and `setup` doesn't fit (e.g. cleaning user-machine state outside any repo). Example: `-d '{"title":"Bootstrap Constellation dev stack","type":"setup","project":"constellation","description":"Run bun install + bun run dev and leave the dev server running.","meta":{"managed_spawn":true}}'`.
|
|
@@ -38,18 +38,27 @@ For explicit dispatch/delegation requests or medium/larger work without a direct
|
|
|
38
38
|
|
|
39
39
|
Always include the `X-CC-Turn-Id: {{cc_turn_id}}` header on state-changing calls so the dashboard can correlate the work item with this conversation turn and surface a confirmation chip in your reply.
|
|
40
40
|
|
|
41
|
-
Worked example for a dispatch:
|
|
41
|
+
Worked example for a fresh-bugfix dispatch (no tracked PR yet — opens a new PR):
|
|
42
42
|
|
|
43
43
|
```
|
|
44
44
|
curl -s -X POST http://localhost:{{dashboard_port}}/api/work-items \
|
|
45
45
|
-H 'Content-Type: application/json' \
|
|
46
46
|
-H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
47
|
-
-d '{"title":"Fix login bug","type":"
|
|
47
|
+
-d '{"title":"Fix login bug","type":"implement","project":"MyApp","description":"...","agent":"dallas"}'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Worked example for a PR-tied fix (responds to comments / failing builds on an existing tracked PR):
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
curl -s -X POST http://localhost:{{dashboard_port}}/api/work-items \
|
|
54
|
+
-H 'Content-Type: application/json' \
|
|
55
|
+
-H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
56
|
+
-d '{"title":"Address review feedback on PR #2533","type":"fix","project":"MyApp","description":"...","references":[{"url":"https://github.com/owner/MyApp/pull/2533","label":"PR #2533"}],"agent":"dallas"}'
|
|
48
57
|
```
|
|
49
58
|
|
|
50
59
|
Body fields:
|
|
51
60
|
- `title` REQUIRED.
|
|
52
|
-
- `type` REQUIRED. Use `explore` for audits/investigations/research/architecture analysis/substantial queries needing a written report. Use `ask` for substantial but non-investigative answer-only requests. Use `implement`/`fix`/`review`/`test`/`verify` when the requested outcome matches those engine flows.
|
|
61
|
+
- `type` REQUIRED. Use `explore` for audits/investigations/research/architecture analysis/substantial queries needing a written report. Use `ask` for substantial but non-investigative answer-only requests. Use `implement`/`fix`/`review`/`test`/`verify` when the requested outcome matches those engine flows. **`fix` vs `implement`:** `type: "fix"` is reserved for work targeting a specific tracked PR (responds to comments / build failures on that PR) — it requires a PR pointer (in `references[*].url` or `targetPr`/`pr_id`/`prNumber`) and dispatches onto the PR's source branch. **Fresh bugfix work** that will open a NEW PR (engine bugs, CI failures on the default branch, repo-level regressions) uses `type: "implement"`. Emitting `type: "fix"` without a tracked PR pointer blocks the WI at the engine's `pr_not_found` discovery gate.
|
|
53
62
|
- `priority` optional: `low`/`medium`/`high`.
|
|
54
63
|
- `description` recommended.
|
|
55
64
|
- `project` REQUIRED when multiple projects are configured.
|