@yemi33/minions 0.1.1641 → 0.1.1643
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dashboard.js +33 -8
- package/engine/ado.js +3 -2
- package/engine/copilot-models.json +1 -1
- package/engine/github.js +5 -4
- package/engine/lifecycle.js +57 -45
- package/engine/queries.js +13 -1
- package/engine/shared.js +56 -4
- package/engine/steering.js +187 -0
- package/engine/timeout.js +12 -15
- package/engine.js +88 -49
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1643 (2026-04-30)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- harden PR canonicalization
|
|
7
|
+
- fix PR comment auto-fix loop
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
- yemi33/minions#1918
|
|
11
|
+
- require explicit PR-created evidence
|
|
12
|
+
- steering messages lost when session is winding down
|
|
13
|
+
- surface missing PR branches
|
|
14
|
+
|
|
3
15
|
## 0.1.1641 (2026-04-30)
|
|
4
16
|
|
|
5
17
|
### Features
|
package/dashboard.js
CHANGED
|
@@ -28,6 +28,7 @@ const watchesMod = require('./engine/watches');
|
|
|
28
28
|
const routing = require('./engine/routing');
|
|
29
29
|
const playbook = require('./engine/playbook');
|
|
30
30
|
const dispatchMod = require('./engine/dispatch');
|
|
31
|
+
const steering = require('./engine/steering');
|
|
31
32
|
const os = require('os');
|
|
32
33
|
|
|
33
34
|
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
|
|
@@ -148,6 +149,21 @@ function _resolveSkillReadPath({ file, dir, source, config, skillFiles } = {}) {
|
|
|
148
149
|
return null;
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
function _agentSessionIsDraining(agentId) {
|
|
153
|
+
const activeForAgent = (getDispatchQueue().active || []).some(d => d.agent === agentId);
|
|
154
|
+
if (!activeForAgent) return false;
|
|
155
|
+
const liveLogPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
|
|
156
|
+
const tail = (safeRead(liveLogPath) || '').slice(-65536);
|
|
157
|
+
if (!tail) return false;
|
|
158
|
+
const lastSteer = tail.lastIndexOf('[human-steering]');
|
|
159
|
+
const terminalIdx = Math.max(
|
|
160
|
+
tail.lastIndexOf('[process-exit]'),
|
|
161
|
+
tail.lastIndexOf('"type":"session.task_complete"'),
|
|
162
|
+
tail.lastIndexOf('"type":"result"')
|
|
163
|
+
);
|
|
164
|
+
return terminalIdx >= 0 && terminalIdx > lastSteer;
|
|
165
|
+
}
|
|
166
|
+
|
|
151
167
|
const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
|
|
152
168
|
const TEAMS_INBOX_PATH = path.join(ENGINE_DIR, 'teams-inbox.json');
|
|
153
169
|
|
|
@@ -5924,7 +5940,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5924
5940
|
if (!pr.branch && prData.branch) {
|
|
5925
5941
|
pr.branch = prData.branch;
|
|
5926
5942
|
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
5927
|
-
if (pr._pendingReason ===
|
|
5943
|
+
if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
|
|
5928
5944
|
}
|
|
5929
5945
|
if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
|
|
5930
5946
|
return prs;
|
|
@@ -6002,21 +6018,30 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6002
6018
|
}},
|
|
6003
6019
|
{ method: 'POST', path: '/api/agents/steer', desc: 'Inject steering message into a running agent', params: 'agent, message', handler: async (req, res) => {
|
|
6004
6020
|
const body = await readBody(req);
|
|
6005
|
-
const { agent
|
|
6006
|
-
if (!
|
|
6021
|
+
const { agent, message } = body;
|
|
6022
|
+
if (!agent || !message) return jsonReply(res, 400, { error: 'agent and message required' });
|
|
6023
|
+
const agentId = String(agent).replace(/[^a-zA-Z0-9_-]/g, '');
|
|
6024
|
+
const text = String(message).trim();
|
|
6025
|
+
if (!agentId || !text) return jsonReply(res, 400, { error: 'agent and message required' });
|
|
6007
6026
|
|
|
6008
|
-
const steerPath = path.join(MINIONS_DIR, 'agents', agentId, 'steer.md');
|
|
6009
6027
|
const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
|
|
6010
6028
|
if (!fs.existsSync(agentDir)) return jsonReply(res, 404, { error: 'Agent not found' });
|
|
6029
|
+
if (_agentSessionIsDraining(agentId)) {
|
|
6030
|
+
return jsonReply(res, 409, { error: 'Agent session is finishing; retry when the next session starts' });
|
|
6031
|
+
}
|
|
6011
6032
|
|
|
6012
|
-
|
|
6013
|
-
safeWrite(steerPath, message);
|
|
6033
|
+
const entry = steering.writeSteeringMessage(agentId, text);
|
|
6014
6034
|
|
|
6015
6035
|
// Also append to live-output.log so it shows in the chat view
|
|
6016
6036
|
const liveLogPath = path.join(agentDir, 'live-output.log');
|
|
6017
|
-
try { fs.appendFileSync(liveLogPath, '\n[human-steering] ' +
|
|
6037
|
+
try { fs.appendFileSync(liveLogPath, '\n[human-steering] ' + text + '\n'); } catch { /* optional */ }
|
|
6018
6038
|
|
|
6019
|
-
return jsonReply(res, 200, {
|
|
6039
|
+
return jsonReply(res, 200, {
|
|
6040
|
+
ok: true,
|
|
6041
|
+
message: 'Steering message queued',
|
|
6042
|
+
file: entry?.file || null,
|
|
6043
|
+
inboxCount: steering.listUnreadSteeringMessages(agentId).length,
|
|
6044
|
+
});
|
|
6020
6045
|
}},
|
|
6021
6046
|
{ method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent?, task?', handler: handleAgentsCancel },
|
|
6022
6047
|
{ method: 'POST', path: /^\/api\/agent\/([\w-]+)\/kill$/, desc: 'Kill a running agent: stop process, clear dispatch, reset work items to pending', handler: handleAgentKill },
|
package/engine/ado.js
CHANGED
|
@@ -268,7 +268,8 @@ async function forEachActivePr(config, token, callback) {
|
|
|
268
268
|
if (!project.adoOrg || !project.adoProject) continue;
|
|
269
269
|
|
|
270
270
|
const prs = getPrs(project);
|
|
271
|
-
const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status)
|
|
271
|
+
const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status)
|
|
272
|
+
&& shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
|
|
272
273
|
if (activePrs.length === 0) continue;
|
|
273
274
|
|
|
274
275
|
const adoRepositoryId = getAdoRepositoryId(project);
|
|
@@ -798,7 +799,7 @@ async function reconcilePrs(config) {
|
|
|
798
799
|
if (existing && !existing.branch && branch) {
|
|
799
800
|
existing.branch = branch;
|
|
800
801
|
if (existing._branchResolutionError) delete existing._branchResolutionError;
|
|
801
|
-
if (existing._pendingReason ===
|
|
802
|
+
if (existing._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete existing._pendingReason;
|
|
802
803
|
metadataUpdated++;
|
|
803
804
|
}
|
|
804
805
|
// PR already tracked — write link to pr-links.json if we can extract an ID
|
package/engine/github.js
CHANGED
|
@@ -206,7 +206,8 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
206
206
|
if (isSlugInBackoff(slug)) continue;
|
|
207
207
|
|
|
208
208
|
const prs = getPrs(project);
|
|
209
|
-
const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status)
|
|
209
|
+
const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status)
|
|
210
|
+
&& shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
|
|
210
211
|
if (activePrs.length === 0) continue;
|
|
211
212
|
|
|
212
213
|
// Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
|
|
@@ -285,7 +286,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
285
286
|
if (!pr.branch && prData.head?.ref) {
|
|
286
287
|
pr.branch = prData.head.ref;
|
|
287
288
|
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
288
|
-
if (pr._pendingReason ===
|
|
289
|
+
if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
|
|
289
290
|
}
|
|
290
291
|
}
|
|
291
292
|
}
|
|
@@ -332,7 +333,7 @@ async function pollPrStatus(config) {
|
|
|
332
333
|
if (headBranch && pr.branch !== headBranch) {
|
|
333
334
|
pr.branch = headBranch;
|
|
334
335
|
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
335
|
-
if (pr._pendingReason ===
|
|
336
|
+
if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
|
|
336
337
|
updated = true;
|
|
337
338
|
}
|
|
338
339
|
|
|
@@ -713,7 +714,7 @@ async function reconcilePrs(config) {
|
|
|
713
714
|
if (existing && !existing.branch && branch) {
|
|
714
715
|
existing.branch = branch;
|
|
715
716
|
if (existing._branchResolutionError) delete existing._branchResolutionError;
|
|
716
|
-
if (existing._pendingReason ===
|
|
717
|
+
if (existing._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete existing._pendingReason;
|
|
717
718
|
metadataUpdated++;
|
|
718
719
|
}
|
|
719
720
|
if (confirmedItemId) {
|
package/engine/lifecycle.js
CHANGED
|
@@ -707,11 +707,46 @@ function reconcilePrdStatuses(config) {
|
|
|
707
707
|
|
|
708
708
|
function syncPrsFromOutput(output, agentId, meta, config) {
|
|
709
709
|
|
|
710
|
-
const
|
|
711
|
-
const
|
|
712
|
-
const
|
|
710
|
+
const prEvidence = new Map();
|
|
711
|
+
const trustedPrCreateToolIds = new Set();
|
|
712
|
+
const prUrlPattern = /(https?:\/\/github\.com\/[^\s"'\\)\]]+\/[^\s"'\\)\]]+\/pull\/(\d+)(?:[^\s"'\\)\]]*)?|https?:\/\/(?:dev\.azure\.com|[^/\s"'\\)\]]+\.visualstudio\.com)[^\s"'\\)\]]*?pullrequest\/(\d+)(?:[^\s"'\\)\]]*)?)/gi;
|
|
713
713
|
let match;
|
|
714
714
|
|
|
715
|
+
function cleanPrUrl(url) {
|
|
716
|
+
return String(url || '').replace(/[.,;:]+$/, '');
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function addPrUrlEvidence(text) {
|
|
720
|
+
if (!text) return;
|
|
721
|
+
prUrlPattern.lastIndex = 0;
|
|
722
|
+
while ((match = prUrlPattern.exec(String(text))) !== null) {
|
|
723
|
+
const prId = match[2] || match[3];
|
|
724
|
+
if (prId && !prEvidence.has(prId)) prEvidence.set(prId, cleanPrUrl(match[1]));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function addExplicitPrCreatedEvidence(text) {
|
|
729
|
+
if (!text) return;
|
|
730
|
+
const explicitPrCreatedPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request|E2E\s+PR)\s+(?:created|opened|submitted)\*{0,2}\s*[:\-]\s*([^\n]+)/gi;
|
|
731
|
+
let createdMatch;
|
|
732
|
+
while ((createdMatch = explicitPrCreatedPattern.exec(String(text))) !== null) {
|
|
733
|
+
addPrUrlEvidence(createdMatch[1]);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function isTrustedPrCreateToolUse(block) {
|
|
738
|
+
const name = String(block?.name || '');
|
|
739
|
+
if (/(?:create|open|submit)[_-]?(?:pull[_-]?request|pr)|(?:pull[_-]?request|pr)[_-]?(?:create|open|submit)/i.test(name)) {
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
const inputText = typeof block?.input === 'string' ? block.input : JSON.stringify(block?.input || {});
|
|
743
|
+
if (/\bgh(?:\.exe)?\s+pr\s+create\b/i.test(inputText)) return true;
|
|
744
|
+
if (/\baz(?:\.cmd|\.exe)?\s+repos\s+pr\s+create\b/i.test(inputText)) return true;
|
|
745
|
+
const callsAdoCreateApi = /_apis\/git\/repositories\/[^\s"'\\]+\/pullrequests\b/i.test(inputText);
|
|
746
|
+
const usesPost = /\bPOST\b|-X\s*POST|-Method\s+POST|method["']?\s*:\s*["']?POST/i.test(inputText);
|
|
747
|
+
return callsAdoCreateApi && usesPost;
|
|
748
|
+
}
|
|
749
|
+
|
|
715
750
|
try {
|
|
716
751
|
const lines = output.split('\n');
|
|
717
752
|
for (const line of lines) {
|
|
@@ -720,60 +755,43 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
720
755
|
const parsed = JSON.parse(line);
|
|
721
756
|
const content = parsed.message?.content || [];
|
|
722
757
|
for (const block of content) {
|
|
723
|
-
|
|
758
|
+
if (block.type === 'tool_use' && block.id && isTrustedPrCreateToolUse(block)) {
|
|
759
|
+
trustedPrCreateToolIds.add(block.id);
|
|
760
|
+
}
|
|
761
|
+
// Tool output is trusted only when tied to a known PR-create command/API call.
|
|
724
762
|
if (block.type === 'tool_result' && block.content) {
|
|
725
|
-
|
|
726
|
-
|
|
763
|
+
if (trustedPrCreateToolIds.has(block.tool_use_id)) {
|
|
764
|
+
const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
765
|
+
addPrUrlEvidence(text);
|
|
766
|
+
}
|
|
727
767
|
}
|
|
728
|
-
//
|
|
768
|
+
// Assistant text must use the explicit Minions PR-created protocol line.
|
|
729
769
|
if (block.type === 'text' && block.text) {
|
|
730
|
-
|
|
731
|
-
textCreatedPattern.lastIndex = 0;
|
|
732
|
-
let m2;
|
|
733
|
-
while ((m2 = textCreatedPattern.exec(block.text)) !== null) prMatches.add(m2[1]);
|
|
770
|
+
addExplicitPrCreatedEvidence(block.text);
|
|
734
771
|
}
|
|
735
772
|
}
|
|
736
773
|
if (parsed.type === 'result' && parsed.result) {
|
|
737
|
-
const resultText = parsed.result;
|
|
738
|
-
|
|
739
|
-
while ((match = createdPattern.exec(resultText)) !== null) prMatches.add(match[1] || match[2]);
|
|
740
|
-
const createdIdPattern = /(?:created|opened|submitted|new)\s+PR[# -]*(\d{1,})/gi;
|
|
741
|
-
while ((match = createdIdPattern.exec(resultText)) !== null) prMatches.add(match[1]);
|
|
774
|
+
const resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
|
|
775
|
+
addExplicitPrCreatedEvidence(resultText);
|
|
742
776
|
}
|
|
743
777
|
} catch {}
|
|
744
778
|
}
|
|
745
779
|
} catch {}
|
|
746
780
|
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// the URL (the W-moljyu60wuzr / #1902 case — gh pr create ran in a sibling
|
|
750
|
-
// dispatch and only the inbox note carries the link).
|
|
751
|
-
const inboxUrls = new Map();
|
|
781
|
+
// Accept inbox fallback only when the agent wrote the explicit PR-created
|
|
782
|
+
// protocol line; generic PR mentions in findings/review notes are not evidence.
|
|
752
783
|
const today = dateStamp();
|
|
753
784
|
const inboxFiles = getInboxFiles().filter(f => f.includes(agentId) && f.includes(today));
|
|
754
785
|
for (const f of inboxFiles) {
|
|
755
786
|
const content = safeRead(path.join(INBOX_DIR, f));
|
|
756
787
|
if (!content) continue;
|
|
757
|
-
|
|
758
|
-
// optional "Pull Request" spelling, line-anchored so "see PR https://..."
|
|
759
|
-
// mid-paragraph mentions don't trigger a false-positive. The protocol
|
|
760
|
-
// and host prefix is optional so "PR: https://github.com/..." ,
|
|
761
|
-
// "**PR:** github.com/...", etc. all match.
|
|
762
|
-
const prHeaderPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request)[:\*]*\*?\s*[#-]*\s*(?:https?:\/\/)?[^\s"]*?(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
|
|
788
|
+
const prHeaderPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request|E2E\s+PR)\s+(?:created|opened|submitted)\*{0,2}\s*[:\-]\s*([^\n]+)/gi;
|
|
763
789
|
while ((match = prHeaderPattern.exec(content)) !== null) {
|
|
764
|
-
|
|
765
|
-
prMatches.add(prId);
|
|
766
|
-
// Pull the URL substring out of the matched chunk so we can hand it to
|
|
767
|
-
// extractPrUrl as a fallback. Prefer the first inbox URL we see for a
|
|
768
|
-
// given prId — later notes don't override the canonical record.
|
|
769
|
-
if (!inboxUrls.has(prId)) {
|
|
770
|
-
const urlMatch = match[0].match(/https?:\/\/[^\s"\\)]+/);
|
|
771
|
-
if (urlMatch) inboxUrls.set(prId, urlMatch[0].replace(/[.,;:]+$/, ''));
|
|
772
|
-
}
|
|
790
|
+
addPrUrlEvidence(match[1]);
|
|
773
791
|
}
|
|
774
792
|
}
|
|
775
793
|
|
|
776
|
-
if (
|
|
794
|
+
if (prEvidence.size === 0) return 0;
|
|
777
795
|
|
|
778
796
|
const projects = shared.getProjects(config);
|
|
779
797
|
if (projects.length === 0 && !meta?.project?.name) return 0;
|
|
@@ -798,13 +816,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
798
816
|
// doesn't contain the link (gh pr create may have run in a sibling dispatch
|
|
799
817
|
// whose stdout was rotated; the inbox note is the durable artifact).
|
|
800
818
|
function extractPrUrl(prId) {
|
|
801
|
-
|
|
802
|
-
// backslash-n), so without this the regex would capture e.g. "pull/1804\n/usr/bin/bash".
|
|
803
|
-
const ghMatch = output.match(new RegExp(`https?://github\\.com/[^\\s"'\\)\\]\\\\]*?/pull/${prId}(?:[^\\s"'\\)\\]\\\\]*)`, 'i'));
|
|
804
|
-
if (ghMatch) return ghMatch[0].replace(/[.,;:]+$/, '');
|
|
805
|
-
const adoMatch = output.match(new RegExp(`https?://(?:dev\\.azure\\.com|[^/]+\\.visualstudio\\.com)[^\\s"'\\)\\]\\\\]*?pullrequest/${prId}(?:[^\\s"'\\)\\]\\\\]*)`, 'i'));
|
|
806
|
-
if (adoMatch) return adoMatch[0].replace(/[.,;:]+$/, '');
|
|
807
|
-
return inboxUrls.get(prId) || '';
|
|
819
|
+
return prEvidence.get(prId) || '';
|
|
808
820
|
}
|
|
809
821
|
|
|
810
822
|
const agentName = config.agents?.[agentId]?.name || agentId;
|
|
@@ -814,7 +826,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
814
826
|
// Group new PRs by target file path
|
|
815
827
|
const newPrsByPath = new Map(); // prPath -> [{ prId, newEntry }]
|
|
816
828
|
|
|
817
|
-
for (const prId of
|
|
829
|
+
for (const prId of prEvidence.keys()) {
|
|
818
830
|
const targetProject = useCentral ? null : resolveProjectForPr(prId);
|
|
819
831
|
const targetName = targetProject ? targetProject.name : '_central';
|
|
820
832
|
const prPath = targetProject ? shared.projectPrPath(targetProject) : centralPrPath;
|
package/engine/queries.js
CHANGED
|
@@ -8,6 +8,7 @@ const fs = require('fs');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const shared = require('./shared');
|
|
11
|
+
const steering = require('./steering');
|
|
11
12
|
|
|
12
13
|
const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileLocked,
|
|
13
14
|
projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
|
|
@@ -418,12 +419,18 @@ function getAgents(config) {
|
|
|
418
419
|
// runtime tag next to the agent name.
|
|
419
420
|
const runtime = shared.resolveAgentCli(a, config.engine || {});
|
|
420
421
|
const inboxFiles = allInboxFiles.filter(f => f.includes(a.id));
|
|
422
|
+
let steeringInboxFiles = [];
|
|
423
|
+
try { steeringInboxFiles = steering.listUnreadSteeringMessages(a.id); } catch { steeringInboxFiles = []; }
|
|
421
424
|
const s = getAgentStatus(a.id); // derives from dispatch.json
|
|
422
425
|
|
|
423
426
|
let lastAction = 'Waiting for assignment';
|
|
424
427
|
if (s.status === 'working') lastAction = s._runningToolDescription ? `Running: ${s._runningToolDescription}` : `Working: ${s.task}`;
|
|
425
428
|
else if (s.status === 'done') lastAction = `Done: ${s.task}`;
|
|
426
429
|
else if (s.status === 'error') lastAction = `Error: ${s.task}`;
|
|
430
|
+
else if (steeringInboxFiles.length > 0) {
|
|
431
|
+
const lastSteer = steeringInboxFiles[steeringInboxFiles.length - 1];
|
|
432
|
+
lastAction = `Pending steering: ${lastSteer.file} (${timeSince(lastSteer.createdAtMs)})`;
|
|
433
|
+
}
|
|
427
434
|
else if (inboxFiles.length > 0) {
|
|
428
435
|
const lastOutput = path.join(INBOX_DIR, inboxFiles[inboxFiles.length - 1]);
|
|
429
436
|
try { lastAction = `Output: ${path.basename(lastOutput)} (${timeSince(fs.statSync(lastOutput).mtimeMs)})`; } catch { /* optional */ }
|
|
@@ -440,7 +447,7 @@ function getAgents(config) {
|
|
|
440
447
|
_blockingToolCall: s._blockingToolCall || null,
|
|
441
448
|
_warning: s._warning || null,
|
|
442
449
|
_permissionMode: s._permissionMode || null,
|
|
443
|
-
chartered, inboxCount: inboxFiles.length
|
|
450
|
+
chartered, inboxCount: inboxFiles.length + steeringInboxFiles.length
|
|
444
451
|
};
|
|
445
452
|
});
|
|
446
453
|
}
|
|
@@ -458,6 +465,11 @@ function getAgentDetail(id) {
|
|
|
458
465
|
const inboxContents = safeReadDir(INBOX_DIR)
|
|
459
466
|
.filter(f => f.includes(id))
|
|
460
467
|
.map(f => ({ name: f, content: safeRead(path.join(INBOX_DIR, f)) || '' }));
|
|
468
|
+
try {
|
|
469
|
+
for (const entry of steering.listUnreadSteeringMessages(id)) {
|
|
470
|
+
inboxContents.push({ name: entry.file, content: entry.raw || '', type: 'steering' });
|
|
471
|
+
}
|
|
472
|
+
} catch { /* optional */ }
|
|
461
473
|
|
|
462
474
|
let recentDispatches = [];
|
|
463
475
|
try {
|
package/engine/shared.js
CHANGED
|
@@ -1073,6 +1073,9 @@ const PRD_MATERIALIZABLE = new Set([PRD_ITEM_STATUS.MISSING, PRD_ITEM_STATUS.UPD
|
|
|
1073
1073
|
const PR_STATUS = { ACTIVE: 'active', MERGED: 'merged', ABANDONED: 'abandoned', CLOSED: 'closed', LINKED: 'linked' };
|
|
1074
1074
|
// PRs eligible for polling (status/build/comment checks) — excludes terminal statuses
|
|
1075
1075
|
const PR_POLLABLE_STATUSES = new Set([PR_STATUS.ACTIVE, PR_STATUS.LINKED]);
|
|
1076
|
+
const PR_PENDING_REASON = {
|
|
1077
|
+
MISSING_BRANCH: 'missing_pr_branch',
|
|
1078
|
+
};
|
|
1076
1079
|
|
|
1077
1080
|
// Watch statuses — engine-level persistent watches that survive restarts
|
|
1078
1081
|
const WATCH_STATUS = { ACTIVE: 'active', PAUSED: 'paused', TRIGGERED: 'triggered', EXPIRED: 'expired' };
|
|
@@ -1661,6 +1664,10 @@ function parseAdoPrUrl(url) {
|
|
|
1661
1664
|
};
|
|
1662
1665
|
}
|
|
1663
1666
|
|
|
1667
|
+
function parsePrUrl(url) {
|
|
1668
|
+
return parseGitHubPrUrl(url) || parseAdoPrUrl(url);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1664
1671
|
function getProjectPrScope(project) {
|
|
1665
1672
|
if (!project) return '';
|
|
1666
1673
|
const host = String(project.repoHost || '').toLowerCase();
|
|
@@ -1705,16 +1712,47 @@ function getPrDisplayId(value, fallbackPrNumber = null) {
|
|
|
1705
1712
|
return typeof value === 'object' ? String(value?.id || '') : String(value || '');
|
|
1706
1713
|
}
|
|
1707
1714
|
|
|
1715
|
+
function getPrScopeInfo(prRef, url = '') {
|
|
1716
|
+
const isObjectRef = !!prRef && typeof prRef === 'object';
|
|
1717
|
+
const rawUrl = url || (isObjectRef ? prRef.url || '' : String(prRef || ''));
|
|
1718
|
+
const parsedUrl = parsePrUrl(rawUrl);
|
|
1719
|
+
if (parsedUrl) return { ...parsedUrl, source: 'url' };
|
|
1720
|
+
const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
|
|
1721
|
+
const canonical = parseCanonicalPrId(rawId);
|
|
1722
|
+
return canonical ? { ...canonical, source: 'id' } : null;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function getPrProjectScopeMismatch(project, prRef, url = '') {
|
|
1726
|
+
const projectScope = getProjectPrScope(project);
|
|
1727
|
+
if (!projectScope) return null;
|
|
1728
|
+
const refScope = getPrScopeInfo(prRef, url)?.scope || '';
|
|
1729
|
+
if (!refScope) return null;
|
|
1730
|
+
if (refScope === projectScope) return null;
|
|
1731
|
+
const [projectHost, projectRest = ''] = projectScope.split(':');
|
|
1732
|
+
const [refHost, refRest = ''] = refScope.split(':');
|
|
1733
|
+
if (projectHost === refHost && projectHost === 'ado' && !project.prUrlBase) {
|
|
1734
|
+
const projectParts = projectRest.split('/');
|
|
1735
|
+
const refParts = refRest.split('/');
|
|
1736
|
+
if (projectParts[0] === refParts[0] && projectParts[1] === refParts[1]) return null;
|
|
1737
|
+
}
|
|
1738
|
+
return { reason: 'pr_scope_mismatch', projectScope, prScope: refScope };
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function isPrCompatibleWithProject(project, prRef, url = '') {
|
|
1742
|
+
return !getPrProjectScopeMismatch(project, prRef, url);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1708
1745
|
function getCanonicalPrId(project, prRef, url = '') {
|
|
1709
1746
|
const isObjectRef = !!prRef && typeof prRef === 'object';
|
|
1710
1747
|
const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
|
|
1748
|
+
const rawUrl = url || (isObjectRef ? prRef.url || '' : String(prRef || ''));
|
|
1749
|
+
const parsedUrl = parsePrUrl(rawUrl);
|
|
1750
|
+
if (parsedUrl) return `${parsedUrl.scope}#${parsedUrl.prNumber}`;
|
|
1711
1751
|
const canonical = parseCanonicalPrId(rawId);
|
|
1712
1752
|
if (canonical) return `${canonical.scope}#${canonical.prNumber}`;
|
|
1713
|
-
const parsedUrl = parseGitHubPrUrl(url || (isObjectRef ? prRef.url || '' : ''))
|
|
1714
|
-
|| parseAdoPrUrl(url || (isObjectRef ? prRef.url || '' : ''));
|
|
1715
1753
|
const prNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
|
|
1716
1754
|
if (prNumber == null) return rawId;
|
|
1717
|
-
const scope = getProjectPrScope(project) ||
|
|
1755
|
+
const scope = getProjectPrScope(project) || '';
|
|
1718
1756
|
return scope ? `${scope}#${prNumber}` : `PR-${prNumber}`;
|
|
1719
1757
|
}
|
|
1720
1758
|
|
|
@@ -1755,6 +1793,17 @@ function normalizePrRecord(pr, project = null) {
|
|
|
1755
1793
|
pr.id = canonicalId;
|
|
1756
1794
|
changed = true;
|
|
1757
1795
|
}
|
|
1796
|
+
const mismatch = getPrProjectScopeMismatch(project, pr, pr.url || '');
|
|
1797
|
+
if (mismatch) {
|
|
1798
|
+
const current = pr._invalidProjectScope || {};
|
|
1799
|
+
if (current.reason !== mismatch.reason || current.projectScope !== mismatch.projectScope || current.prScope !== mismatch.prScope) {
|
|
1800
|
+
pr._invalidProjectScope = mismatch;
|
|
1801
|
+
changed = true;
|
|
1802
|
+
}
|
|
1803
|
+
} else if (Object.prototype.hasOwnProperty.call(pr, '_invalidProjectScope')) {
|
|
1804
|
+
delete pr._invalidProjectScope;
|
|
1805
|
+
changed = true;
|
|
1806
|
+
}
|
|
1758
1807
|
return changed;
|
|
1759
1808
|
}
|
|
1760
1809
|
|
|
@@ -2255,7 +2304,7 @@ module.exports = {
|
|
|
2255
2304
|
resolveAgentMaxBudget, resolveAgentBareMode,
|
|
2256
2305
|
applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
|
|
2257
2306
|
runtimeConfigWarnings,
|
|
2258
|
-
WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
|
|
2307
|
+
WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
|
|
2259
2308
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
|
|
2260
2309
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
2261
2310
|
FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
|
|
@@ -2273,6 +2322,9 @@ module.exports = {
|
|
|
2273
2322
|
getProjectPrScope,
|
|
2274
2323
|
getPrNumber,
|
|
2275
2324
|
getPrDisplayId,
|
|
2325
|
+
getPrScopeInfo,
|
|
2326
|
+
getPrProjectScopeMismatch,
|
|
2327
|
+
isPrCompatibleWithProject,
|
|
2276
2328
|
getCanonicalPrId,
|
|
2277
2329
|
findPrRecord,
|
|
2278
2330
|
normalizePrRecord,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/steering.js — Durable agent-scoped steering inbox helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const shared = require('./shared');
|
|
8
|
+
|
|
9
|
+
const AGENTS_DIR = path.join(shared.MINIONS_DIR, 'agents');
|
|
10
|
+
|
|
11
|
+
function agentInboxDir(agentId) {
|
|
12
|
+
return path.join(AGENTS_DIR, agentId, 'inbox');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function _createdAtFromPath(filePath, stat) {
|
|
16
|
+
const base = path.basename(filePath);
|
|
17
|
+
const m = base.match(/^steering-(\d+)/);
|
|
18
|
+
if (m) {
|
|
19
|
+
const n = Number(m[1]);
|
|
20
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
21
|
+
}
|
|
22
|
+
return stat?.mtimeMs || Date.now();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function _stripFrontmatter(raw) {
|
|
26
|
+
const text = String(raw || '');
|
|
27
|
+
if (!text.startsWith('---\n')) return text;
|
|
28
|
+
const end = text.indexOf('\n---\n', 4);
|
|
29
|
+
return end >= 0 ? text.slice(end + 5) : text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _frontmatterValue(raw, key) {
|
|
33
|
+
const text = String(raw || '');
|
|
34
|
+
if (!text.startsWith('---\n')) return null;
|
|
35
|
+
const end = text.indexOf('\n---\n', 4);
|
|
36
|
+
if (end < 0) return null;
|
|
37
|
+
const fm = text.slice(4, end).split(/\r?\n/);
|
|
38
|
+
const prefix = key + ':';
|
|
39
|
+
for (const line of fm) {
|
|
40
|
+
if (line.startsWith(prefix)) return line.slice(prefix.length).trim();
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _messageFromRaw(raw) {
|
|
46
|
+
let body = _stripFrontmatter(raw).trim();
|
|
47
|
+
const forwarded = body.match(/Original steering from human:\s*([\s\S]*)$/i);
|
|
48
|
+
if (forwarded) body = forwarded[1].trim();
|
|
49
|
+
return body;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _readEntry(filePath, legacy = false) {
|
|
53
|
+
let stat;
|
|
54
|
+
try { stat = fs.statSync(filePath); } catch { return null; }
|
|
55
|
+
const raw = shared.safeRead(filePath);
|
|
56
|
+
const fmCreatedAtMs = Number(_frontmatterValue(raw, 'createdAtMs'));
|
|
57
|
+
const createdAtMs = Number.isFinite(fmCreatedAtMs) && fmCreatedAtMs > 0
|
|
58
|
+
? fmCreatedAtMs
|
|
59
|
+
: _createdAtFromPath(filePath, stat);
|
|
60
|
+
return {
|
|
61
|
+
path: filePath,
|
|
62
|
+
file: path.basename(filePath),
|
|
63
|
+
createdAtMs,
|
|
64
|
+
createdAt: new Date(createdAtMs).toISOString(),
|
|
65
|
+
raw,
|
|
66
|
+
message: _messageFromRaw(raw),
|
|
67
|
+
legacy,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _uniqueSteeringPath(inboxDir, createdAtMs) {
|
|
72
|
+
let filePath = path.join(inboxDir, `steering-${createdAtMs}.md`);
|
|
73
|
+
for (let i = 1; fs.existsSync(filePath); i++) {
|
|
74
|
+
filePath = path.join(inboxDir, `steering-${createdAtMs}-${i}.md`);
|
|
75
|
+
}
|
|
76
|
+
return filePath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeSteeringMessage(agentId, message, opts = {}) {
|
|
80
|
+
const createdAtMs = Number(opts.createdAtMs) || Date.now();
|
|
81
|
+
const createdAt = new Date(createdAtMs).toISOString();
|
|
82
|
+
const inboxDir = agentInboxDir(agentId);
|
|
83
|
+
fs.mkdirSync(inboxDir, { recursive: true });
|
|
84
|
+
const filePath = _uniqueSteeringPath(inboxDir, createdAtMs);
|
|
85
|
+
const body = [
|
|
86
|
+
'---',
|
|
87
|
+
`createdAt: ${createdAt}`,
|
|
88
|
+
`createdAtMs: ${createdAtMs}`,
|
|
89
|
+
`source: ${opts.source || 'human'}`,
|
|
90
|
+
'---',
|
|
91
|
+
'',
|
|
92
|
+
String(message || '').trim(),
|
|
93
|
+
'',
|
|
94
|
+
].join('\n');
|
|
95
|
+
shared.safeWrite(filePath, body);
|
|
96
|
+
return _readEntry(filePath);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function listUnreadSteeringMessages(agentId, opts = {}) {
|
|
100
|
+
const includeLegacy = opts.includeLegacy !== false;
|
|
101
|
+
const entries = [];
|
|
102
|
+
const inboxDir = agentInboxDir(agentId);
|
|
103
|
+
for (const file of shared.safeReadDir(inboxDir)) {
|
|
104
|
+
if (!/^steering-.*\.md$/i.test(file)) continue;
|
|
105
|
+
const entry = _readEntry(path.join(inboxDir, file), false);
|
|
106
|
+
if (entry) entries.push(entry);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (includeLegacy) {
|
|
110
|
+
const legacyPath = path.join(AGENTS_DIR, agentId, 'steer.md');
|
|
111
|
+
const legacy = _readEntry(legacyPath, true);
|
|
112
|
+
if (legacy) entries.push(legacy);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
entries.sort((a, b) => (a.createdAtMs - b.createdAtMs) || a.file.localeCompare(b.file));
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildPendingSteeringPrompt(agentId) {
|
|
120
|
+
const entries = listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim());
|
|
121
|
+
if (entries.length === 0) return { entries, prompt: '' };
|
|
122
|
+
|
|
123
|
+
const sections = [
|
|
124
|
+
'## Pending instructions from prior session',
|
|
125
|
+
'',
|
|
126
|
+
'These human steering messages were not confirmed processed before the previous session ended. Address them before continuing with the task.',
|
|
127
|
+
];
|
|
128
|
+
entries.forEach((entry, idx) => {
|
|
129
|
+
sections.push('', `### Message ${idx + 1} — ${entry.createdAt}`, '', entry.message.trim());
|
|
130
|
+
});
|
|
131
|
+
return { entries, prompt: sections.join('\n') };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _eventTimestampMs(obj, observedAtMs) {
|
|
135
|
+
const value = obj?.timestamp || obj?.createdAt || obj?.created_at || obj?.time || obj?.data?.timestamp;
|
|
136
|
+
const parsed = value ? Date.parse(value) : NaN;
|
|
137
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
138
|
+
return Number(observedAtMs) || Date.now();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _isProcessEvidenceEvent(obj) {
|
|
142
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
143
|
+
const type = String(obj.type || '');
|
|
144
|
+
if (type === 'assistant' || type === 'tool_use') return true;
|
|
145
|
+
if (type.startsWith('assistant.') || type.startsWith('tool.')) return true;
|
|
146
|
+
if (Array.isArray(obj.message?.content)) {
|
|
147
|
+
return obj.message.content.some(block => block?.type === 'text' || block?.type === 'tool_use');
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _processEvidenceTimes(rawOutput, observedAtMs) {
|
|
153
|
+
const times = [];
|
|
154
|
+
for (const line of String(rawOutput || '').split(/\r?\n/)) {
|
|
155
|
+
const trimmed = line.trim();
|
|
156
|
+
if (!trimmed.startsWith('{')) continue;
|
|
157
|
+
try {
|
|
158
|
+
const obj = JSON.parse(trimmed);
|
|
159
|
+
if (_isProcessEvidenceEvent(obj)) times.push(_eventTimestampMs(obj, observedAtMs));
|
|
160
|
+
} catch { /* ignore non-JSON output */ }
|
|
161
|
+
}
|
|
162
|
+
return times;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ackProcessedSteeringMessages(agentId, pendingEntries, rawOutput, opts = {}) {
|
|
166
|
+
const entries = Array.isArray(pendingEntries) ? pendingEntries : [];
|
|
167
|
+
if (entries.length === 0) return [];
|
|
168
|
+
const times = _processEvidenceTimes(rawOutput, opts.observedAtMs);
|
|
169
|
+
if (times.length === 0) return [];
|
|
170
|
+
|
|
171
|
+
const acked = [];
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (!entry?.path) continue;
|
|
174
|
+
if (!times.some(t => t > entry.createdAtMs)) continue;
|
|
175
|
+
shared.safeUnlink(entry.path);
|
|
176
|
+
acked.push(entry);
|
|
177
|
+
}
|
|
178
|
+
return acked;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
agentInboxDir,
|
|
183
|
+
writeSteeringMessage,
|
|
184
|
+
listUnreadSteeringMessages,
|
|
185
|
+
buildPendingSteeringPrompt,
|
|
186
|
+
ackProcessedSteeringMessages,
|
|
187
|
+
};
|
package/engine/timeout.js
CHANGED
|
@@ -6,6 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const shared = require('./shared');
|
|
8
8
|
const queries = require('./queries');
|
|
9
|
+
const steering = require('./steering');
|
|
9
10
|
|
|
10
11
|
const { safeRead, safeWrite, safeJson, mutateJsonFileLocked, getProjects, projectWorkItemsPath, log, ts,
|
|
11
12
|
ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, DISPATCH_RESULT, AGENT_STATUS } = shared;
|
|
@@ -78,25 +79,20 @@ function checkSteering(config) {
|
|
|
78
79
|
// Skip if already being steered (prevents double-kill race)
|
|
79
80
|
if (info._steeringMessage || info._steeringAt) continue;
|
|
80
81
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
const alreadyPending = new Set((info._pendingSteeringFiles || []).map(entry => entry.path || entry));
|
|
83
|
+
const unread = steering.listUnreadSteeringMessages(info.agentId);
|
|
84
|
+
for (const empty of unread.filter(entry => !entry.message.trim())) {
|
|
85
|
+
shared.safeUnlink(empty.path);
|
|
86
|
+
}
|
|
87
|
+
const steerEntry = unread.find(entry => entry.message.trim() && !alreadyPending.has(entry.path));
|
|
88
|
+
if (!steerEntry) continue; // ENOENT/no agents/<id>/inbox/steering-*.md message
|
|
89
|
+
const message = steerEntry.message.trim();
|
|
89
90
|
|
|
90
91
|
const sessionId = info.sessionId;
|
|
91
92
|
if (!sessionId) {
|
|
92
|
-
// No session to resume — kill agent and
|
|
93
|
+
// No session to resume — kill agent and leave message unread in inbox for retry.
|
|
93
94
|
// Previously this silently skipped for up to 5m then deleted the message (#627).
|
|
94
|
-
log('info', `Steering: no sessionId for ${info.agentId} (${id}) — killing and
|
|
95
|
-
|
|
96
|
-
// Write steering message to agent inbox so it survives the retry
|
|
97
|
-
const inboxDir = path.join(AGENTS_DIR, info.agentId, 'inbox');
|
|
98
|
-
try { fs.mkdirSync(inboxDir, { recursive: true }); } catch {}
|
|
99
|
-
safeWrite(path.join(inboxDir, `steering-${Date.now()}.md`), `# Steering Message (Forwarded)\n\nOriginal steering from human:\n\n${message}\n`);
|
|
95
|
+
log('info', `Steering: no sessionId for ${info.agentId} (${id}) — killing and keeping unread message in inbox`);
|
|
100
96
|
|
|
101
97
|
// Append to live output so user sees confirmation in the dashboard
|
|
102
98
|
try {
|
|
@@ -115,6 +111,7 @@ function checkSteering(config) {
|
|
|
115
111
|
// Set steering state BEFORE kill — close event may fire synchronously on some platforms
|
|
116
112
|
info._steeringMessage = message;
|
|
117
113
|
info._steeringSessionId = sessionId;
|
|
114
|
+
info._steeringEntry = steerEntry;
|
|
118
115
|
info._steeringAt = Date.now();
|
|
119
116
|
|
|
120
117
|
shared.killImmediate(info.proc);
|
package/engine.js
CHANGED
|
@@ -107,6 +107,7 @@ const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatc
|
|
|
107
107
|
// ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
|
|
108
108
|
|
|
109
109
|
const { checkTimeouts, checkSteering, checkIdleThreshold } = require('./engine/timeout');
|
|
110
|
+
const steering = require('./engine/steering');
|
|
110
111
|
|
|
111
112
|
// ─── Cleanup (extracted to engine/cleanup.js) ────────────────────────────────
|
|
112
113
|
|
|
@@ -295,6 +296,17 @@ function _buildAgentSpawnFlags(runtime, opts = {}) {
|
|
|
295
296
|
return flags;
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
function ackPendingSteeringFiles(agentId, procInfo, rawOutput, observedAtMs = Date.now()) {
|
|
300
|
+
if (!procInfo?._pendingSteeringFiles?.length || !rawOutput) return;
|
|
301
|
+
const acked = steering.ackProcessedSteeringMessages(agentId, procInfo._pendingSteeringFiles, rawOutput, { observedAtMs });
|
|
302
|
+
if (acked.length === 0) return;
|
|
303
|
+
|
|
304
|
+
const ackedPaths = new Set(acked.map(entry => entry.path));
|
|
305
|
+
procInfo._pendingSteeringFiles = procInfo._pendingSteeringFiles.filter(entry => !ackedPaths.has(entry.path));
|
|
306
|
+
if (procInfo._pendingSteeringFiles.length === 0) delete procInfo._pendingSteeringFiles;
|
|
307
|
+
log('info', `Steering: ACKed ${acked.length} processed message(s) for ${agentId}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
298
310
|
// Resolve dependency plan item IDs to their PR branches
|
|
299
311
|
function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
300
312
|
const results = []; // [{ branch, prId }]
|
|
@@ -436,9 +448,13 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
436
448
|
// and this avoids blocking 200ms of file reads behind 20-60s of git operations
|
|
437
449
|
const systemPrompt = buildSystemPrompt(agentId, config, project);
|
|
438
450
|
const agentContext = buildAgentContext(agentId, config, project);
|
|
439
|
-
const
|
|
440
|
-
|
|
451
|
+
const pendingSteering = steering.buildPendingSteeringPrompt(agentId);
|
|
452
|
+
const taskPromptWithSteering = pendingSteering.prompt
|
|
453
|
+
? `${pendingSteering.prompt}\n\n---\n\n${taskPrompt}`
|
|
441
454
|
: taskPrompt;
|
|
455
|
+
const fullTaskPrompt = agentContext
|
|
456
|
+
? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPromptWithSteering}`
|
|
457
|
+
: taskPromptWithSteering;
|
|
442
458
|
const tmpDir = path.join(ENGINE_DIR, 'tmp');
|
|
443
459
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
444
460
|
const safeId = id.replace(/[:\\/*?"<>|]/g, '-');
|
|
@@ -1036,6 +1052,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1036
1052
|
}
|
|
1037
1053
|
} catch { /* JSON parse — output may not be valid JSON */ }
|
|
1038
1054
|
}
|
|
1055
|
+
|
|
1056
|
+
ackPendingSteeringFiles(agentId, procInfo, chunk);
|
|
1039
1057
|
});
|
|
1040
1058
|
|
|
1041
1059
|
proc.stderr.on('data', (data) => {
|
|
@@ -1058,13 +1076,17 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1058
1076
|
try { shared.safeUnlink(path.join(AGENTS_DIR, agentId, 'session.json')); } catch {}
|
|
1059
1077
|
}
|
|
1060
1078
|
|
|
1061
|
-
// Check if this was a steering kill — re-spawn with resume
|
|
1062
1079
|
const procInfo = activeProcesses.get(id);
|
|
1080
|
+
ackPendingSteeringFiles(agentId, procInfo, stdout);
|
|
1081
|
+
|
|
1082
|
+
// Check if this was a steering kill — re-spawn with resume
|
|
1063
1083
|
if (procInfo?._steeringMessage) {
|
|
1064
1084
|
const steerMsg = procInfo._steeringMessage;
|
|
1065
1085
|
const steerSessionId = procInfo._steeringSessionId;
|
|
1086
|
+
const steerEntry = procInfo._steeringEntry;
|
|
1066
1087
|
delete procInfo._steeringMessage;
|
|
1067
1088
|
delete procInfo._steeringSessionId;
|
|
1089
|
+
delete procInfo._steeringEntry;
|
|
1068
1090
|
|
|
1069
1091
|
// Guard: can't resume without a session
|
|
1070
1092
|
if (!steerSessionId) {
|
|
@@ -1156,7 +1178,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1156
1178
|
// into the resumed process, it kills the resumed session. The kill watcher only exists
|
|
1157
1179
|
// to handle cases where the original kill didn't take effect — once the process has
|
|
1158
1180
|
// exited and the resume is spawned, _steeringAt must not be present.
|
|
1159
|
-
activeProcesses.set(id, {
|
|
1181
|
+
activeProcesses.set(id, {
|
|
1182
|
+
proc: resumeProc,
|
|
1183
|
+
agentId,
|
|
1184
|
+
startedAt: procInfo.startedAt,
|
|
1185
|
+
sessionId: steerSessionId,
|
|
1186
|
+
lastRealOutputAt: Date.now(),
|
|
1187
|
+
_pendingSteeringFiles: steerEntry ? [steerEntry] : (procInfo._pendingSteeringFiles || []),
|
|
1188
|
+
});
|
|
1160
1189
|
|
|
1161
1190
|
// Reset output buffers so post-completion parsing only sees the resumed session
|
|
1162
1191
|
stdout = '';
|
|
@@ -1167,6 +1196,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1167
1196
|
realActivityMap.set(id, Date.now());
|
|
1168
1197
|
if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
|
|
1169
1198
|
try { fs.appendFileSync(liveOutputPath, chunk); } catch { /* optional */ }
|
|
1199
|
+
ackPendingSteeringFiles(agentId, activeProcesses.get(id), chunk);
|
|
1170
1200
|
});
|
|
1171
1201
|
resumeProc.stderr.on('data', (data) => {
|
|
1172
1202
|
const chunk = data.toString();
|
|
@@ -1370,7 +1400,13 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1370
1400
|
// realActivityMap was already seeded immediately after runFile() returned (#W-mo25loq8kjer);
|
|
1371
1401
|
// don't re-seed here — the stdout/stderr handlers above can already have updated it with
|
|
1372
1402
|
// a fresher timestamp, and overwriting would clobber the real "last activity" signal.
|
|
1373
|
-
activeProcesses.set(id, {
|
|
1403
|
+
activeProcesses.set(id, {
|
|
1404
|
+
proc,
|
|
1405
|
+
agentId,
|
|
1406
|
+
startedAt,
|
|
1407
|
+
sessionId: cachedSessionId,
|
|
1408
|
+
_pendingSteeringFiles: pendingSteering.entries,
|
|
1409
|
+
});
|
|
1374
1410
|
|
|
1375
1411
|
updateAgentStatus(id, AGENT_STATUS.RUNNING, `Process spawned for ${agentId}`);
|
|
1376
1412
|
|
|
@@ -1984,7 +2020,7 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
|
|
|
1984
2020
|
} catch (e) { log('warn', 'clear pending human feedback flag: ' + e.message); }
|
|
1985
2021
|
}
|
|
1986
2022
|
|
|
1987
|
-
const PR_PENDING_MISSING_BRANCH =
|
|
2023
|
+
const PR_PENDING_MISSING_BRANCH = shared.PR_PENDING_REASON.MISSING_BRANCH;
|
|
1988
2024
|
|
|
1989
2025
|
function normalizePrBranch(value) {
|
|
1990
2026
|
const raw = value == null ? '' : String(value).trim();
|
|
@@ -2117,6 +2153,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2117
2153
|
const knownAgents = new Set(Object.keys(config.agents || {}));
|
|
2118
2154
|
for (const pr of prs) {
|
|
2119
2155
|
if (pr.status !== PR_STATUS.ACTIVE || pr._contextOnly) continue;
|
|
2156
|
+
if (!shared.isPrCompatibleWithProject(project, pr, pr.url || '')) continue;
|
|
2120
2157
|
const prDisplayId = shared.getPrDisplayId(pr);
|
|
2121
2158
|
const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
|
|
2122
2159
|
if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
|
|
@@ -2195,12 +2232,55 @@ async function discoverFromPrs(config, project) {
|
|
|
2195
2232
|
if (item) { newWork.push(item); }
|
|
2196
2233
|
}
|
|
2197
2234
|
|
|
2235
|
+
let fixDispatched = false;
|
|
2236
|
+
|
|
2237
|
+
// Fresh reviewer comments are actionable fixes, even while the PR is otherwise
|
|
2238
|
+
// awaiting a stale-vote re-review or has build-fix retries escalated.
|
|
2239
|
+
const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2240
|
+
const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
|
|
2241
|
+
if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched) {
|
|
2242
|
+
const key = humanFixKey;
|
|
2243
|
+
let staleCoalesced = [];
|
|
2244
|
+
const alreadyDispatched = isAlreadyDispatched(key);
|
|
2245
|
+
const blockedByCooldown = isOnCooldown(key, cooldownMs);
|
|
2246
|
+
if (blockedByCooldown && !alreadyDispatched) {
|
|
2247
|
+
staleCoalesced = getCoalescedContexts(key);
|
|
2248
|
+
clearCooldown(key);
|
|
2249
|
+
log('info', `Cleared stale cooldown for ${key} — no matching dispatch history`);
|
|
2250
|
+
}
|
|
2251
|
+
if (alreadyDispatched || isOnCooldown(key, cooldownMs)) {
|
|
2252
|
+
// Coalesce: save feedback for next dispatch
|
|
2253
|
+
if (pr.humanFeedback?.feedbackContent) {
|
|
2254
|
+
setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
|
|
2255
|
+
}
|
|
2256
|
+
continue;
|
|
2257
|
+
}
|
|
2258
|
+
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2259
|
+
if (!agentId) continue;
|
|
2260
|
+
const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
|
|
2261
|
+
if (!prBranch) continue;
|
|
2262
|
+
|
|
2263
|
+
const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
|
|
2264
|
+
let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
|
|
2265
|
+
if (coalesced.length > 0) {
|
|
2266
|
+
const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
|
|
2267
|
+
if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2271
|
+
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
|
|
2272
|
+
reviewer: 'Human Reviewer',
|
|
2273
|
+
review_note: reviewNote,
|
|
2274
|
+
}, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
|
|
2275
|
+
if (item) { newWork.push(item); fixDispatched = true; }
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2198
2278
|
// Re-review after fix: trigger when a fix was pushed after the last minions review,
|
|
2199
2279
|
// or when no minions review has completed yet (e.g. human-feedback-only fix path).
|
|
2200
2280
|
const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
|
|
2201
2281
|
(!pr.lastReviewedAt || pr.minionsReview.fixedAt > pr.lastReviewedAt));
|
|
2202
2282
|
const needsReReview = reviewEnabled && reviewStatus === 'waiting' &&
|
|
2203
|
-
fixedAfterReview && !evalEscalated;
|
|
2283
|
+
fixedAfterReview && !evalEscalated && !fixDispatched;
|
|
2204
2284
|
if (needsReReview) {
|
|
2205
2285
|
const key = `rereview-${project?.name || 'default'}-${prDisplayId}`;
|
|
2206
2286
|
// Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
|
|
@@ -2240,8 +2320,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2240
2320
|
|
|
2241
2321
|
// PRs with changes requested → route back to author for fix
|
|
2242
2322
|
// Gate on evalLoopEnabled — the review→fix cycle is the eval loop
|
|
2243
|
-
|
|
2244
|
-
if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
|
|
2323
|
+
if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated && !fixDispatched) {
|
|
2245
2324
|
const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2246
2325
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2247
2326
|
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
@@ -2265,46 +2344,6 @@ async function discoverFromPrs(config, project) {
|
|
|
2265
2344
|
}
|
|
2266
2345
|
}
|
|
2267
2346
|
|
|
2268
|
-
// PRs with pending human feedback (skip if review-fix already dispatched above)
|
|
2269
|
-
const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2270
|
-
const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
|
|
2271
|
-
if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !awaitingReReview && !fixDispatched) {
|
|
2272
|
-
const key = humanFixKey;
|
|
2273
|
-
let staleCoalesced = [];
|
|
2274
|
-
const alreadyDispatched = isAlreadyDispatched(key);
|
|
2275
|
-
const blockedByCooldown = isOnCooldown(key, cooldownMs);
|
|
2276
|
-
if (blockedByCooldown && !alreadyDispatched) {
|
|
2277
|
-
staleCoalesced = getCoalescedContexts(key);
|
|
2278
|
-
clearCooldown(key);
|
|
2279
|
-
log('info', `Cleared stale cooldown for ${key} — no matching dispatch history`);
|
|
2280
|
-
}
|
|
2281
|
-
if (alreadyDispatched || isOnCooldown(key, cooldownMs)) {
|
|
2282
|
-
// Coalesce: save feedback for next dispatch
|
|
2283
|
-
if (pr.humanFeedback?.feedbackContent) {
|
|
2284
|
-
setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
|
|
2285
|
-
}
|
|
2286
|
-
continue;
|
|
2287
|
-
}
|
|
2288
|
-
const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
|
|
2289
|
-
if (!agentId) continue;
|
|
2290
|
-
const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
|
|
2291
|
-
if (!prBranch) continue;
|
|
2292
|
-
|
|
2293
|
-
const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
|
|
2294
|
-
let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
|
|
2295
|
-
if (coalesced.length > 0) {
|
|
2296
|
-
const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
|
|
2297
|
-
if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2301
|
-
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
|
|
2302
|
-
reviewer: 'Human Reviewer',
|
|
2303
|
-
review_note: reviewNote,
|
|
2304
|
-
}, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
|
|
2305
|
-
if (item) { newWork.push(item); fixDispatched = true; }
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
2347
|
// PRs with build failures — route to author (has session context from implementing)
|
|
2309
2348
|
// Grace period: after a build fix push, wait for CI to run before re-dispatching
|
|
2310
2349
|
// Skip if build hasn't transitioned since last fix (still showing the old failure)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1643",
|
|
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"
|