@yemi33/minions 0.1.1910 → 0.1.1912
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/engine/cc-worker-pool.js +512 -0
- package/engine/copilot-models.json +5 -0
- package/engine/gh-comment.js +7 -0
- package/engine/github.js +98 -144
- package/package.json +1 -1
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/cc-worker-pool.js — Persistent per-tab Copilot worker pool (W-mp3ioyh300056f36).
|
|
3
|
+
*
|
|
4
|
+
* Sub-task B of W-mp2w003600196c51 ("CC perf: persistent per-tab Copilot
|
|
5
|
+
* worker"). Wraps `copilot --acp` (Agent Client Protocol — long-lived JSON-RPC
|
|
6
|
+
* server over stdin/stdout) so that a Command Center / doc-chat tab keeps a
|
|
7
|
+
* single warm Copilot process across N turns instead of paying the
|
|
8
|
+
* spawn + initialize + session/new (~14 s) cost on every turn.
|
|
9
|
+
*
|
|
10
|
+
* Public API:
|
|
11
|
+
*
|
|
12
|
+
* pool.getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd })
|
|
13
|
+
* → SessionHandle: {
|
|
14
|
+
* sessionId,
|
|
15
|
+
* stream(promptText, { onChunk, onDone, onError, signal, systemPromptText }) → Promise,
|
|
16
|
+
* cancel(), // session/cancel notification
|
|
17
|
+
* close(), // tear down this tab
|
|
18
|
+
* }
|
|
19
|
+
* pool.closeTab(tabId) // cancel inflight, kill proc, drop entry
|
|
20
|
+
* pool.shutdown() // close every tab and stop the idle reaper
|
|
21
|
+
*
|
|
22
|
+
* Internal model (one process per tab):
|
|
23
|
+
*
|
|
24
|
+
* tabs: Map<tabId, Worker> where Worker = {
|
|
25
|
+
* proc, sessionId, model, effort, mcpServers, mcpServersHash,
|
|
26
|
+
* systemPromptHash, cwd, lastUsedAt,
|
|
27
|
+
* pending: Map<id, { resolve, reject }>, inflight, readBuf, nextReqId
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* TODO (sub-task C/D follow-up): multiplex N tabs onto one ACP process
|
|
31
|
+
* (`session/new` per tab, single `proc`). ACP supports it; left as a
|
|
32
|
+
* follow-up perf win once one-process-per-tab is proven in the dashboard.
|
|
33
|
+
*
|
|
34
|
+
* Eviction rules:
|
|
35
|
+
* - mcpServers changes → kill proc entirely + respawn
|
|
36
|
+
* - systemPromptHash change → keep proc, send a fresh `session/new`
|
|
37
|
+
* - model / effort change → no eviction (state updated in place)
|
|
38
|
+
* - closeTab → `session/cancel` if inflight, then kill proc
|
|
39
|
+
* - Idle > 10 min → kill proc (reaper sweep every 60 s)
|
|
40
|
+
*
|
|
41
|
+
* Auth precheck: when `copilot --acp` exits before the initialize handshake
|
|
42
|
+
* completes, surface
|
|
43
|
+
* "copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login
|
|
44
|
+
* is complete (...)"
|
|
45
|
+
* so the dashboard can surface a precise reason instead of a generic timeout.
|
|
46
|
+
*
|
|
47
|
+
* No new npm deps — Node built-ins only (child_process, events, timers, crypto).
|
|
48
|
+
*
|
|
49
|
+
* See `notes/archive/2026-05-13-ripley-W-mp3h2wq2000e64f7-2026-05-13-0301.md`
|
|
50
|
+
* for the ACP protocol probe (line framing, request/response correlation,
|
|
51
|
+
* cold-vs-warm timings, cancel semantics, configOptions surface).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const { spawn } = require('child_process');
|
|
55
|
+
const crypto = require('crypto');
|
|
56
|
+
|
|
57
|
+
// 10 minutes — matches the work-item spec.
|
|
58
|
+
const IDLE_REAPER_MS = 10 * 60 * 1000;
|
|
59
|
+
// Reaper sweep cadence. Not exposed as ENGINE_DEFAULTS to keep the pool
|
|
60
|
+
// dependency-free; sub-task C/D can plumb a config knob if needed.
|
|
61
|
+
const REAPER_INTERVAL_MS = 60 * 1000;
|
|
62
|
+
|
|
63
|
+
// Test seam — every external side effect goes through `_internals` so
|
|
64
|
+
// test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate.
|
|
65
|
+
const _internals = {
|
|
66
|
+
spawnAcp({ cwd } = {}) {
|
|
67
|
+
return spawn(
|
|
68
|
+
'copilot',
|
|
69
|
+
['--acp', '--allow-all', '--max-autopilot-continues', '1'],
|
|
70
|
+
{
|
|
71
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
cwd: cwd || process.cwd(),
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
// Don't pass shell:true — Copilot is a binary on PATH and a shell
|
|
75
|
+
// wrapper would swallow stdin/stdout framing.
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
killImmediate(proc) {
|
|
80
|
+
// Lazy require so unit tests that don't load engine/shared still work.
|
|
81
|
+
try {
|
|
82
|
+
const shared = require('./shared');
|
|
83
|
+
shared.killImmediate(proc);
|
|
84
|
+
} catch {
|
|
85
|
+
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
now() { return Date.now(); },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const _tabs = new Map();
|
|
92
|
+
let _reaperTimer = null;
|
|
93
|
+
|
|
94
|
+
function _hashMcpServers(mcpServers) {
|
|
95
|
+
// Stable hash via JSON.stringify; mcpServers is an array of plain objects
|
|
96
|
+
// in practice (name/command/env) so the natural key order is fine.
|
|
97
|
+
const json = mcpServers == null ? '[]' : JSON.stringify(mcpServers);
|
|
98
|
+
return crypto.createHash('sha256').update(json).digest('hex');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class Worker {
|
|
102
|
+
constructor({ tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd }) {
|
|
103
|
+
this.tabId = tabId;
|
|
104
|
+
this.model = model;
|
|
105
|
+
this.effort = effort;
|
|
106
|
+
this.mcpServers = mcpServers || [];
|
|
107
|
+
this.mcpServersHash = mcpServersHash;
|
|
108
|
+
this.systemPromptHash = systemPromptHash;
|
|
109
|
+
this.cwd = cwd || process.cwd();
|
|
110
|
+
|
|
111
|
+
this.proc = null;
|
|
112
|
+
this.sessionId = null;
|
|
113
|
+
this.pending = new Map(); // id → { resolve, reject }
|
|
114
|
+
this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
|
|
115
|
+
this.readBuf = '';
|
|
116
|
+
this.nextReqId = 1;
|
|
117
|
+
this.lastUsedAt = _internals.now();
|
|
118
|
+
this.killed = false;
|
|
119
|
+
this.spawnError = null;
|
|
120
|
+
this.firstSystemPromptSent = false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Spawn + initialize handshake ────────────────────────────────────────
|
|
124
|
+
async _spawnAndInit() {
|
|
125
|
+
let proc;
|
|
126
|
+
try {
|
|
127
|
+
proc = _internals.spawnAcp({ cwd: this.cwd });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
this.proc = proc;
|
|
134
|
+
|
|
135
|
+
proc.stdout.on('data', (chunk) => this._onStdout(chunk));
|
|
136
|
+
// stderr is captured for diagnostics but we don't act on it.
|
|
137
|
+
if (proc.stderr) proc.stderr.on('data', () => { /* ignore */ });
|
|
138
|
+
|
|
139
|
+
// Race the init handshake against an early process exit so an auth
|
|
140
|
+
// failure (which kills the daemon before initialize completes) surfaces
|
|
141
|
+
// a clear error instead of hanging forever.
|
|
142
|
+
let earlyExitReject;
|
|
143
|
+
const earlyExitPromise = new Promise((_, reject) => {
|
|
144
|
+
earlyExitReject = (code) => {
|
|
145
|
+
this.killed = true;
|
|
146
|
+
const err = new Error(
|
|
147
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`
|
|
148
|
+
);
|
|
149
|
+
this.spawnError = err;
|
|
150
|
+
this._failAllPending(err);
|
|
151
|
+
reject(err);
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
const earlyExitHandler = (code) => earlyExitReject(code);
|
|
155
|
+
proc.once('exit', earlyExitHandler);
|
|
156
|
+
|
|
157
|
+
const errorHandler = (err) => {
|
|
158
|
+
const wrapped = new Error(
|
|
159
|
+
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`
|
|
160
|
+
);
|
|
161
|
+
this.spawnError = wrapped;
|
|
162
|
+
this.killed = true;
|
|
163
|
+
this._failAllPending(wrapped);
|
|
164
|
+
};
|
|
165
|
+
proc.on('error', errorHandler);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await Promise.race([
|
|
169
|
+
this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
|
|
170
|
+
earlyExitPromise,
|
|
171
|
+
]);
|
|
172
|
+
const result = await Promise.race([
|
|
173
|
+
this._call('session/new', { cwd: this.cwd, mcpServers: this.mcpServers }),
|
|
174
|
+
earlyExitPromise,
|
|
175
|
+
]);
|
|
176
|
+
this.sessionId = result && result.sessionId;
|
|
177
|
+
if (!this.sessionId) {
|
|
178
|
+
throw new Error('copilot --acp failed -- session/new returned no sessionId');
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
// Either the handshake finished (swap to a persistent exit handler that
|
|
182
|
+
// just marks killed) or it failed (proc is dying anyway).
|
|
183
|
+
proc.removeListener('exit', earlyExitHandler);
|
|
184
|
+
}
|
|
185
|
+
proc.on('exit', () => {
|
|
186
|
+
this.killed = true;
|
|
187
|
+
const err = new Error('copilot --acp process exited');
|
|
188
|
+
this._failAllPending(err);
|
|
189
|
+
// Settle inflight too if it's still hanging
|
|
190
|
+
if (this.inflight && !this.inflight.settled) {
|
|
191
|
+
const cb = this.inflight.onError;
|
|
192
|
+
this.inflight.settled = true;
|
|
193
|
+
this.inflight = null;
|
|
194
|
+
if (cb) try { cb(err); } catch { /* swallow */ }
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_failAllPending(err) {
|
|
200
|
+
for (const { reject } of this.pending.values()) {
|
|
201
|
+
try { reject(err); } catch { /* swallow */ }
|
|
202
|
+
}
|
|
203
|
+
this.pending.clear();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Newline-delimited JSON-RPC line framing ────────────────────────────
|
|
207
|
+
_onStdout(chunk) {
|
|
208
|
+
this.readBuf += chunk.toString('utf8');
|
|
209
|
+
let i;
|
|
210
|
+
while ((i = this.readBuf.indexOf('\n')) >= 0) {
|
|
211
|
+
const line = this.readBuf.slice(0, i).trim();
|
|
212
|
+
this.readBuf = this.readBuf.slice(i + 1);
|
|
213
|
+
if (!line) continue;
|
|
214
|
+
let obj;
|
|
215
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
216
|
+
this._handleMessage(obj);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_handleMessage(obj) {
|
|
221
|
+
// Response (matches an outbound id)
|
|
222
|
+
if (obj.id != null && this.pending.has(obj.id)) {
|
|
223
|
+
const { resolve, reject } = this.pending.get(obj.id);
|
|
224
|
+
this.pending.delete(obj.id);
|
|
225
|
+
if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
|
|
226
|
+
else resolve(obj.result);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Notification (no id) — only `session/update` matters for streaming.
|
|
230
|
+
if (obj.method === 'session/update' && obj.params && this.inflight) {
|
|
231
|
+
if (obj.params.sessionId !== this.inflight.sessionId) return;
|
|
232
|
+
const update = obj.params.update;
|
|
233
|
+
if (!update) return;
|
|
234
|
+
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
235
|
+
const text = _extractChunkText(update.content);
|
|
236
|
+
if (text && this.inflight.onChunk) {
|
|
237
|
+
try { this.inflight.onChunk(text); } catch { /* swallow */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Other update kinds (available_commands_update, tool_call, ...) are
|
|
241
|
+
// ignored in sub-task B. Sub-task C/D will surface tool_call to the
|
|
242
|
+
// dashboard's onToolUse callback.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_writeFrame(obj) {
|
|
247
|
+
if (this.killed) return;
|
|
248
|
+
if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
|
|
249
|
+
try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); }
|
|
250
|
+
catch { /* pipe may be broken; exit handler will fire */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_call(method, params) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const id = this.nextReqId++;
|
|
256
|
+
this.pending.set(id, { resolve, reject });
|
|
257
|
+
this._writeFrame({ jsonrpc: '2.0', id, method, params });
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_notify(method, params) {
|
|
262
|
+
this._writeFrame({ jsonrpc: '2.0', method, params });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Stream a single turn ───────────────────────────────────────────────
|
|
266
|
+
stream(promptText, opts = {}) {
|
|
267
|
+
const { onChunk, onDone, onError, signal, systemPromptText } = opts;
|
|
268
|
+
if (this.killed) {
|
|
269
|
+
const err = new Error('cc-worker-pool: tab is closed');
|
|
270
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
271
|
+
return Promise.resolve();
|
|
272
|
+
}
|
|
273
|
+
if (this.inflight) {
|
|
274
|
+
const err = new Error('cc-worker-pool: a prompt is already in flight on this tab');
|
|
275
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
276
|
+
return Promise.resolve();
|
|
277
|
+
}
|
|
278
|
+
this.lastUsedAt = _internals.now();
|
|
279
|
+
|
|
280
|
+
// Inject the <system> block on the first prompt of a session — Copilot
|
|
281
|
+
// ACP has no in-protocol "set system prompt" method (per Ripley's note),
|
|
282
|
+
// so the per-session adapter pattern of <system>...</system> prepended
|
|
283
|
+
// to the first user message remains correct.
|
|
284
|
+
let prompt = promptText;
|
|
285
|
+
if (!this.firstSystemPromptSent && systemPromptText) {
|
|
286
|
+
prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
|
|
287
|
+
}
|
|
288
|
+
this.firstSystemPromptSent = true;
|
|
289
|
+
|
|
290
|
+
const id = this.nextReqId++;
|
|
291
|
+
const inflight = {
|
|
292
|
+
id,
|
|
293
|
+
sessionId: this.sessionId,
|
|
294
|
+
onChunk,
|
|
295
|
+
onDone,
|
|
296
|
+
onError,
|
|
297
|
+
signal,
|
|
298
|
+
signalHandler: null,
|
|
299
|
+
settled: false,
|
|
300
|
+
};
|
|
301
|
+
this.inflight = inflight;
|
|
302
|
+
|
|
303
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
304
|
+
inflight.signalHandler = () => this.cancel();
|
|
305
|
+
try { signal.addEventListener('abort', inflight.signalHandler); } catch { /* swallow */ }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
const finalize = (err, result) => {
|
|
310
|
+
if (inflight.settled) return;
|
|
311
|
+
inflight.settled = true;
|
|
312
|
+
if (this.inflight === inflight) this.inflight = null;
|
|
313
|
+
this.lastUsedAt = _internals.now();
|
|
314
|
+
if (signal && inflight.signalHandler) {
|
|
315
|
+
try { signal.removeEventListener('abort', inflight.signalHandler); }
|
|
316
|
+
catch { /* swallow */ }
|
|
317
|
+
}
|
|
318
|
+
if (err) {
|
|
319
|
+
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
320
|
+
} else {
|
|
321
|
+
if (onDone) try { onDone(result); } catch { /* swallow */ }
|
|
322
|
+
}
|
|
323
|
+
resolve();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
this.pending.set(id, {
|
|
327
|
+
resolve: (result) => finalize(null, result),
|
|
328
|
+
reject: (err) => finalize(err, null),
|
|
329
|
+
});
|
|
330
|
+
this._writeFrame({
|
|
331
|
+
jsonrpc: '2.0',
|
|
332
|
+
id,
|
|
333
|
+
method: 'session/prompt',
|
|
334
|
+
params: { sessionId: this.sessionId, prompt: [{ type: 'text', text: prompt }] },
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
cancel() {
|
|
340
|
+
if (!this.inflight || this.killed) return;
|
|
341
|
+
// session/cancel is a JSON-RPC notification (no id). The in-flight
|
|
342
|
+
// session/prompt response will arrive with stopReason=cancelled per the
|
|
343
|
+
// ACP spec — we don't synthesize a fake response client-side.
|
|
344
|
+
this._notify('session/cancel', { sessionId: this.inflight.sessionId });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async newSession({ mcpServers, systemPromptHash }) {
|
|
348
|
+
// Cancel any inflight before swapping the underlying session.
|
|
349
|
+
if (this.inflight) {
|
|
350
|
+
this.cancel();
|
|
351
|
+
// Wait briefly for the cancelled response to settle so we don't leak
|
|
352
|
+
// a stale inflight reference into the new session.
|
|
353
|
+
const inflight = this.inflight;
|
|
354
|
+
await new Promise((resolve) => {
|
|
355
|
+
const start = _internals.now();
|
|
356
|
+
const wait = () => {
|
|
357
|
+
if (!inflight || inflight.settled || _internals.now() - start > 1000) resolve();
|
|
358
|
+
else setTimeout(wait, 5);
|
|
359
|
+
};
|
|
360
|
+
wait();
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const result = await this._call('session/new', {
|
|
364
|
+
cwd: this.cwd,
|
|
365
|
+
mcpServers: mcpServers || [],
|
|
366
|
+
});
|
|
367
|
+
this.sessionId = result && result.sessionId;
|
|
368
|
+
this.systemPromptHash = systemPromptHash;
|
|
369
|
+
this.firstSystemPromptSent = false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
close() {
|
|
373
|
+
if (this.killed) return;
|
|
374
|
+
this.killed = true;
|
|
375
|
+
// Best-effort cancel of an inflight prompt so the daemon doesn't keep
|
|
376
|
+
// generating into a torn-down session before we kill the proc.
|
|
377
|
+
if (this.inflight) {
|
|
378
|
+
try { this._notify('session/cancel', { sessionId: this.inflight.sessionId }); }
|
|
379
|
+
catch { /* swallow */ }
|
|
380
|
+
}
|
|
381
|
+
if (this.proc) {
|
|
382
|
+
try { _internals.killImmediate(this.proc); } catch { /* already dead */ }
|
|
383
|
+
}
|
|
384
|
+
this._failAllPending(new Error('cc-worker-pool: worker closed'));
|
|
385
|
+
if (this.inflight && !this.inflight.settled) {
|
|
386
|
+
const cb = this.inflight.onError;
|
|
387
|
+
this.inflight.settled = true;
|
|
388
|
+
this.inflight = null;
|
|
389
|
+
if (cb) try { cb(new Error('cc-worker-pool: worker closed')); } catch { /* swallow */ }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function _extractChunkText(content) {
|
|
395
|
+
if (content == null) return '';
|
|
396
|
+
if (typeof content === 'string') return content;
|
|
397
|
+
if (Array.isArray(content)) {
|
|
398
|
+
let out = '';
|
|
399
|
+
for (const block of content) {
|
|
400
|
+
if (block && typeof block.text === 'string') out += block.text;
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
if (typeof content === 'object' && typeof content.text === 'string') return content.text;
|
|
405
|
+
return '';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
|
|
411
|
+
if (!tabId) throw new Error('cc-worker-pool.getSession: tabId is required');
|
|
412
|
+
const mcpServersHash = _hashMcpServers(mcpServers);
|
|
413
|
+
let worker = _tabs.get(tabId);
|
|
414
|
+
|
|
415
|
+
if (worker) {
|
|
416
|
+
if (worker.killed) {
|
|
417
|
+
_tabs.delete(tabId);
|
|
418
|
+
worker = null;
|
|
419
|
+
} else if (worker.mcpServersHash !== mcpServersHash) {
|
|
420
|
+
// mcpServers shape changed → must respawn the proc; the daemon
|
|
421
|
+
// resolves MCP server config at process boot, not per session.
|
|
422
|
+
_tabs.delete(tabId);
|
|
423
|
+
worker.close();
|
|
424
|
+
worker = null;
|
|
425
|
+
} else if (worker.systemPromptHash !== systemPromptHash) {
|
|
426
|
+
// System prompt changed → keep the warm process, drop the session
|
|
427
|
+
// and create a fresh one. Saves the ~2.1 s initialize handshake.
|
|
428
|
+
await worker.newSession({ mcpServers, systemPromptHash });
|
|
429
|
+
worker.model = model;
|
|
430
|
+
worker.effort = effort;
|
|
431
|
+
worker.lastUsedAt = _internals.now();
|
|
432
|
+
} else {
|
|
433
|
+
// Warm reuse — only update bookkeeping. model/effort changes on a
|
|
434
|
+
// warm session are tracked here but not pushed via configOptions in
|
|
435
|
+
// sub-task B; sub-task C/D will wire that to ACP `session/configure`
|
|
436
|
+
// (or equivalent) once the dashboard exposes the toggle live.
|
|
437
|
+
worker.model = model;
|
|
438
|
+
worker.effort = effort;
|
|
439
|
+
worker.lastUsedAt = _internals.now();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!worker) {
|
|
444
|
+
worker = new Worker({
|
|
445
|
+
tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
|
|
446
|
+
});
|
|
447
|
+
_tabs.set(tabId, worker);
|
|
448
|
+
try {
|
|
449
|
+
await worker._spawnAndInit();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
_tabs.delete(tabId);
|
|
452
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_ensureReaper();
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
sessionId: worker.sessionId,
|
|
461
|
+
stream: (promptText, opts) => worker.stream(promptText, opts),
|
|
462
|
+
cancel: () => worker.cancel(),
|
|
463
|
+
close: () => closeTab(tabId),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function closeTab(tabId) {
|
|
468
|
+
const worker = _tabs.get(tabId);
|
|
469
|
+
if (!worker) return;
|
|
470
|
+
_tabs.delete(tabId);
|
|
471
|
+
worker.close();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function shutdown() {
|
|
475
|
+
for (const worker of _tabs.values()) {
|
|
476
|
+
try { worker.close(); } catch { /* swallow */ }
|
|
477
|
+
}
|
|
478
|
+
_tabs.clear();
|
|
479
|
+
if (_reaperTimer) {
|
|
480
|
+
clearInterval(_reaperTimer);
|
|
481
|
+
_reaperTimer = null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function _ensureReaper() {
|
|
486
|
+
if (_reaperTimer) return;
|
|
487
|
+
_reaperTimer = setInterval(_reapIdleTabs, REAPER_INTERVAL_MS);
|
|
488
|
+
if (typeof _reaperTimer.unref === 'function') _reaperTimer.unref();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function _reapIdleTabs() {
|
|
492
|
+
const now = _internals.now();
|
|
493
|
+
for (const [tabId, worker] of [..._tabs]) {
|
|
494
|
+
if (worker.inflight) continue;
|
|
495
|
+
if (now - worker.lastUsedAt > IDLE_REAPER_MS) {
|
|
496
|
+
_tabs.delete(tabId);
|
|
497
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
module.exports = {
|
|
503
|
+
getSession,
|
|
504
|
+
closeTab,
|
|
505
|
+
shutdown,
|
|
506
|
+
// Exposed for unit tests; engine code MUST go through the public API.
|
|
507
|
+
_internals,
|
|
508
|
+
_tabs,
|
|
509
|
+
_reapIdleTabs,
|
|
510
|
+
IDLE_REAPER_MS,
|
|
511
|
+
REAPER_INTERVAL_MS,
|
|
512
|
+
};
|
package/engine/gh-comment.js
CHANGED
|
@@ -46,6 +46,12 @@ const REPO_SLUG_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
|
46
46
|
const MINIONS_COMMENT_MARKER_RE =
|
|
47
47
|
/^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?\s*-->/m;
|
|
48
48
|
|
|
49
|
+
// Sample marker constant for tests / fixtures that need a body with a
|
|
50
|
+
// canonical-format marker but don't care about the specific agent / kind /
|
|
51
|
+
// wi fields. Matches MINIONS_COMMENT_MARKER_RE. Production comments build
|
|
52
|
+
// their marker via _buildMarker / buildMinionsCommentBody.
|
|
53
|
+
const MINIONS_COMMENT_MARKER = '<!-- minions:agent=minions kind=marker -->';
|
|
54
|
+
|
|
49
55
|
// Cheaper "is this body already marked?" check that matches only at position 0
|
|
50
56
|
// (for idempotency in buildMinionsCommentBody). Kept separate from the
|
|
51
57
|
// exported regex so the public regex can be used by downstream classifiers
|
|
@@ -231,6 +237,7 @@ module.exports = {
|
|
|
231
237
|
// Builders / parsers (pure functions — usable from anywhere)
|
|
232
238
|
buildMinionsCommentBody,
|
|
233
239
|
parseMinionsMarker,
|
|
240
|
+
MINIONS_COMMENT_MARKER,
|
|
234
241
|
MINIONS_COMMENT_MARKER_RE,
|
|
235
242
|
// Validation regexes (exported for downstream consumers)
|
|
236
243
|
AGENT_ID_RE,
|
package/engine/github.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const shared = require('./shared');
|
|
8
8
|
const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker, getProjectOrg } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
|
+
const { MINIONS_COMMENT_MARKER_RE } = require('./gh-comment');
|
|
10
11
|
const path = require('path');
|
|
11
12
|
|
|
12
13
|
// Lazy require to avoid circular dependency — only needed for engine().handlePostMerge
|
|
@@ -85,131 +86,94 @@ function _isNonActionableComment(c, config = {}) {
|
|
|
85
86
|
if (_isAgentComment(c)) return true;
|
|
86
87
|
if (_isCiReportCommentBody(c?.body)) return true;
|
|
87
88
|
if (_isPreviewStatusComment(c)) return true;
|
|
88
|
-
if (
|
|
89
|
+
if (_isMinionsAuthoredComment(c)) return true;
|
|
89
90
|
return false;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
//
|
|
93
|
-
// VERDICT:APPROVE recaps, noop:true confessions) posted via the shared `gh` PAT identity.
|
|
93
|
+
// W-mp3bp0ha000997ab-b — Structural classifier for Minions-authored PR comments.
|
|
94
94
|
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
95
|
+
// Replaces the body-shape classifier chain (PR #2431's _isAgentSelfReviewDeclinedComment,
|
|
96
|
+
// PR #2442's _isAgentPositiveSignalComment + _hasAgentPositiveSignalBody) with a single
|
|
97
|
+
// structural check: a Minions-authored comment carries `MINIONS_COMMENT_MARKER` (defined
|
|
98
|
+
// in engine/gh-comment.js) on its own first line AND was authored by the viewer (the
|
|
99
|
+
// shared PAT identity used by all minions agents).
|
|
98
100
|
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
101
|
+
// Both gates are required:
|
|
102
|
+
// - `viewerDidAuthor: true` prevents humans from spoofing the marker by quoting it.
|
|
103
|
+
// - `MINIONS_COMMENT_MARKER_RE` enforces "own first line" — quoted markers in the
|
|
104
|
+
// middle of a comment, or markers with trailing content on the same line, do NOT
|
|
105
|
+
// match. This is the structural complement to the viewer check.
|
|
102
106
|
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
// Production note: the GitHub REST endpoints we poll (/issues/:n/comments and
|
|
108
|
+
// /pulls/:n/comments) do NOT populate `viewerDidAuthor` — that field is GraphQL-
|
|
109
|
+
// only. `pollPrHumanComments` therefore backfills it via `_backfillViewerDidAuthor`
|
|
110
|
+
// using the viewer login resolved by `_resolveViewerLogin` (one `gh api user` call
|
|
111
|
+
// per process). This keeps the classifier alive in production without forcing a
|
|
112
|
+
// GraphQL migration on every fetch site.
|
|
113
|
+
//
|
|
114
|
+
// Sub-item -a populates the marker on every minions-posted comment via a helper.
|
|
115
|
+
// Sub-item -c migrates existing engine call sites + playbook examples.
|
|
116
|
+
function _isMinionsAuthoredComment(c) {
|
|
117
|
+
if (!c || c.viewerDidAuthor !== true) return false;
|
|
118
|
+
const body = c.body || '';
|
|
119
|
+
const m = MINIONS_COMMENT_MARKER_RE.exec(body);
|
|
120
|
+
if (!m) return false;
|
|
121
|
+
// Strict: marker must be on its OWN first line (no leading content,
|
|
122
|
+
// no trailing content on the same line). MINIONS_COMMENT_MARKER_RE uses
|
|
123
|
+
// /m so it would otherwise match the start of any line; we additionally
|
|
124
|
+
// require the match to be at index 0 AND to occupy the first line
|
|
125
|
+
// exactly (modulo surrounding whitespace).
|
|
126
|
+
if (m.index !== 0) return false;
|
|
127
|
+
const lineEnd = body.indexOf('\n');
|
|
128
|
+
const firstLine = lineEnd === -1 ? body : body.slice(0, lineEnd);
|
|
129
|
+
return firstLine.trim() === m[0].trim();
|
|
109
130
|
}
|
|
110
131
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return
|
|
132
|
+
// ─── Viewer Login Resolution ────────────────────────────────────────────────
|
|
133
|
+
// `_isMinionsAuthoredComment` (above) gates on `c.viewerDidAuthor === true`.
|
|
134
|
+
// That field is populated by GitHub's GraphQL API but NOT by the REST
|
|
135
|
+
// `/issues/:n/comments` and `/pulls/:n/comments` endpoints we use in
|
|
136
|
+
// `pollPrHumanComments` (REST returns `viewerDidAuthor: null`). To make the
|
|
137
|
+
// classifier alive in production without changing every fetch site to
|
|
138
|
+
// GraphQL, we backfill the field by comparing the comment author's login to
|
|
139
|
+
// the viewer login resolved via `gh api user` once per process. The shared
|
|
140
|
+
// PAT identity does not change during a process lifetime, so a simple
|
|
141
|
+
// in-memory cache is sufficient.
|
|
142
|
+
let _cachedViewerLogin = null;
|
|
143
|
+
|
|
144
|
+
async function _resolveViewerLogin() {
|
|
145
|
+
if (_cachedViewerLogin) return _cachedViewerLogin;
|
|
146
|
+
try {
|
|
147
|
+
const result = await execAsync('gh api user', { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
|
|
148
|
+
const parsed = JSON.parse(String(result || ''));
|
|
149
|
+
const login = parsed?.login ? String(parsed.login).toLowerCase() : null;
|
|
150
|
+
if (login) _cachedViewerLogin = login;
|
|
151
|
+
return login;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
log('warn', `GitHub viewer-login resolution failed: ${e?.message || e}`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
125
156
|
}
|
|
126
157
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const login = String(c?.user?.login || '').toLowerCase();
|
|
132
|
-
if (!login || !sharedLogins.has(login)) return false;
|
|
133
|
-
return _hasAgentPositiveSignalBody(c.body);
|
|
158
|
+
// Test hook: lets tests prime or clear the cached viewer login without
|
|
159
|
+
// shelling out. Pass `null` to force a re-resolve on the next call.
|
|
160
|
+
function _setCachedViewerLogin(login) {
|
|
161
|
+
_cachedViewerLogin = login ? String(login).toLowerCase() : null;
|
|
134
162
|
}
|
|
135
163
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// review/implement dispatch in the completed history,
|
|
149
|
-
// OR the most recent review dispatch on this PR was assigned to the same agent as the
|
|
150
|
-
// PR author and completed with the noop:true / verdict:null / needs_rerun:true contract.
|
|
151
|
-
//
|
|
152
|
-
// This is a narrow allowlist for the noop pattern — generic PAT-user comments still flow
|
|
153
|
-
// through `_isNonActionableComment` and trigger `humanFeedback.pendingFix=true` as before.
|
|
154
|
-
function _isAgentSelfReviewDeclinedComment(c, { pr, dispatch } = {}) {
|
|
155
|
-
if (!c || !pr) return false;
|
|
156
|
-
const body = String(c.body || '');
|
|
157
|
-
if (!body) return false;
|
|
158
|
-
if (_hasMinionsReviewVerdict(body)) return false;
|
|
159
|
-
|
|
160
|
-
const prAuthorAgent = String(pr.agent || '').toLowerCase();
|
|
161
|
-
if (!prAuthorAgent) return false;
|
|
162
|
-
|
|
163
|
-
const completed = (dispatch && Array.isArray(dispatch.completed)) ? dispatch.completed : [];
|
|
164
|
-
const isNoopContract = (sc) => {
|
|
165
|
-
if (!sc || typeof sc !== 'object') return false;
|
|
166
|
-
if (sc.noop !== true && sc.noop !== 'true') return false;
|
|
167
|
-
if (sc.verdict !== null && sc.verdict !== undefined && sc.verdict !== '') return false;
|
|
168
|
-
if (sc.needs_rerun !== true && sc.needsRerun !== true) return false;
|
|
169
|
-
return true;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
// Signal A — explicit "Self-review declined" phrase + verifiable dispatch id pointing
|
|
173
|
-
// at a same-agent review/implement/fix dispatch in the completed history.
|
|
174
|
-
const phraseMatch = /self[\s\-_]+review\s+declined/i.test(body);
|
|
175
|
-
if (phraseMatch) {
|
|
176
|
-
const dispatchIdMatches = body.match(/\b[a-z][a-z0-9]*-[a-z][a-z0-9]*-[a-z0-9]{8,}\b/g) || [];
|
|
177
|
-
for (const id of dispatchIdMatches) {
|
|
178
|
-
const entry = completed.find(d => d && d.id === id);
|
|
179
|
-
if (!entry) continue;
|
|
180
|
-
const agent = String(entry.agent || '').toLowerCase();
|
|
181
|
-
const t = String(entry.type || '').toLowerCase();
|
|
182
|
-
if (agent !== prAuthorAgent) continue;
|
|
183
|
-
if (t === 'review' || t === 'implement' || t === 'implement-large' || t === 'fix') {
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
164
|
+
// Backfill `c.viewerDidAuthor` for an array of REST-fetched comments using
|
|
165
|
+
// the resolved viewer login. Only sets the field when REST returned
|
|
166
|
+
// null/undefined — never overrides a value that GraphQL or the test fixture
|
|
167
|
+
// already supplied. Mutates the comment objects in place.
|
|
168
|
+
function _backfillViewerDidAuthor(comments, viewerLogin) {
|
|
169
|
+
if (!Array.isArray(comments) || !viewerLogin) return;
|
|
170
|
+
for (const c of comments) {
|
|
171
|
+
if (!c || typeof c !== 'object') continue;
|
|
172
|
+
if (c.viewerDidAuthor === true || c.viewerDidAuthor === false) continue;
|
|
173
|
+
const login = c.user?.login;
|
|
174
|
+
if (typeof login !== 'string' || !login) continue;
|
|
175
|
+
c.viewerDidAuthor = login.toLowerCase() === viewerLogin;
|
|
187
176
|
}
|
|
188
|
-
|
|
189
|
-
// Signal B — most recent review dispatch on this PR was assigned to the PR author
|
|
190
|
-
// and completed with the documented noop contract. Catches the case where the agent
|
|
191
|
-
// forgot the canonical phrase but the completion-report contract still flags a noop.
|
|
192
|
-
const prId = String(pr.id || '');
|
|
193
|
-
const prNumberRaw = pr.prNumber;
|
|
194
|
-
const prNumber = prNumberRaw == null ? null : Number(prNumberRaw);
|
|
195
|
-
const reviewDispatches = completed.filter(d => {
|
|
196
|
-
if (!d || String(d.type || '').toLowerCase() !== 'review') return false;
|
|
197
|
-
const dpr = d.meta && d.meta.pr;
|
|
198
|
-
if (!dpr) return false;
|
|
199
|
-
if (prId && String(dpr.id || '') === prId) return true;
|
|
200
|
-
if (prNumber != null && Number(dpr.prNumber) === prNumber) return true;
|
|
201
|
-
return false;
|
|
202
|
-
});
|
|
203
|
-
if (reviewDispatches.length === 0) return false;
|
|
204
|
-
reviewDispatches.sort((a, b) => {
|
|
205
|
-
const ta = String(a.completed_at || a.created_at || '');
|
|
206
|
-
const tb = String(b.completed_at || b.created_at || '');
|
|
207
|
-
return tb.localeCompare(ta);
|
|
208
|
-
});
|
|
209
|
-
const latest = reviewDispatches[0];
|
|
210
|
-
if (!latest) return false;
|
|
211
|
-
if (String(latest.agent || '').toLowerCase() !== prAuthorAgent) return false;
|
|
212
|
-
return isNoopContract(latest.structuredCompletion);
|
|
213
177
|
}
|
|
214
178
|
|
|
215
179
|
// ─── Per-Repo Poll Backoff ──────────────────────────────────────────────────
|
|
@@ -821,13 +785,18 @@ async function pollPrStatus(config) {
|
|
|
821
785
|
|
|
822
786
|
async function pollPrHumanComments(config) {
|
|
823
787
|
// Load dispatch state once per poll cycle (cached for ~2s by queries.getDispatch).
|
|
824
|
-
//
|
|
825
|
-
//
|
|
788
|
+
// (Previously used by `_isAgentSelfReviewDeclinedComment` to verify dispatch ids;
|
|
789
|
+
// kept loaded in case downstream phases need dispatch context — cheap on cache hit.)
|
|
826
790
|
const queries = require('./queries');
|
|
827
791
|
const dispatch = (() => {
|
|
828
792
|
try { return queries.getDispatch(); }
|
|
829
793
|
catch { return { pending: [], active: [], completed: [] }; }
|
|
830
794
|
})();
|
|
795
|
+
// Resolve viewer login once per poll cycle so we can backfill
|
|
796
|
+
// `viewerDidAuthor` on REST comments — the field GraphQL would have
|
|
797
|
+
// populated. `_resolveViewerLogin` caches process-wide, so repeated calls
|
|
798
|
+
// across PRs are free.
|
|
799
|
+
const viewerLogin = await _resolveViewerLogin();
|
|
831
800
|
const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
|
|
832
801
|
// Get issue comments (general PR comments)
|
|
833
802
|
const comments = await ghApi(`/issues/${prNum}/comments`, slug);
|
|
@@ -840,6 +809,11 @@ async function pollPrHumanComments(config) {
|
|
|
840
809
|
...(Array.isArray(reviewComments) ? reviewComments : []).map(c => ({ ...c, _type: 'review' }))
|
|
841
810
|
];
|
|
842
811
|
|
|
812
|
+
// Backfill viewerDidAuthor on each comment so _isMinionsAuthoredComment
|
|
813
|
+
// can actually fire on production traffic. No-op when GraphQL or a test
|
|
814
|
+
// fixture already populated the field.
|
|
815
|
+
_backfillViewerDidAuthor(allComments, viewerLogin);
|
|
816
|
+
|
|
843
817
|
const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || '1970-01-01';
|
|
844
818
|
const cutoffMs = new Date(cutoffStr).getTime() || 0;
|
|
845
819
|
|
|
@@ -853,12 +827,12 @@ async function pollPrHumanComments(config) {
|
|
|
853
827
|
for (const c of allComments) {
|
|
854
828
|
const date = c.created_at || c.updated_at || '';
|
|
855
829
|
const dateMs = date ? new Date(date).getTime() : 0;
|
|
856
|
-
// W-
|
|
857
|
-
//
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
|
|
861
|
-
|
|
830
|
+
// W-mp3bp0ha000997ab-b — All non-actionable filtering routes through
|
|
831
|
+
// _isNonActionableComment, which now composes _isMinionsAuthoredComment
|
|
832
|
+
// (structural marker check). The previous OR chain that appended
|
|
833
|
+
// _isAgentSelfReviewDeclinedComment is gone — that helper was deleted
|
|
834
|
+
// with this PR.
|
|
835
|
+
const isNonActionable = _isNonActionableComment(c, config);
|
|
862
836
|
if (dateMs) allCommentDates.push(date);
|
|
863
837
|
if (isNonActionable) continue;
|
|
864
838
|
const entry = {
|
|
@@ -883,26 +857,6 @@ async function pollPrHumanComments(config) {
|
|
|
883
857
|
}
|
|
884
858
|
if (newComments.length === 0) return false;
|
|
885
859
|
|
|
886
|
-
// P-a3f9b2c1 — Defense-in-depth: when the PR is already approved AND every new comment
|
|
887
|
-
// is from a configured shared-minions PAT identity AND every new comment matches the
|
|
888
|
-
// positive-signal body shape, suppress pendingFix and advance cutoff. Belt-and-suspenders
|
|
889
|
-
// against future regressions in `_isAgentPositiveSignalComment`. This is intentionally
|
|
890
|
-
// narrow — a single human-shaped comment in newComments (e.g. "rename _foo to bar") will
|
|
891
|
-
// disqualify the entire batch and let the normal human-feedback flow run.
|
|
892
|
-
if (String(pr.reviewStatus || '').toLowerCase() === 'approved') {
|
|
893
|
-
const sharedLogins = _sharedMinionsLogins(config);
|
|
894
|
-
if (sharedLogins.size > 0) {
|
|
895
|
-
const allFromSharedPat = newComments.every(nc => sharedLogins.has(String(nc.author || '').toLowerCase()));
|
|
896
|
-
const allPositiveShape = newComments.every(nc => _hasAgentPositiveSignalBody(nc.content));
|
|
897
|
-
if (allFromSharedPat && allPositiveShape) {
|
|
898
|
-
const cutoff = allNewDates.sort().pop() || newComments[newComments.length - 1].date;
|
|
899
|
-
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: cutoff };
|
|
900
|
-
log('info', `PR ${pr.id}: ${newComments.length} new shared-PAT positive-signal comment(s) on approved PR — defense-in-depth suppression`);
|
|
901
|
-
return true;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
860
|
// Sort all comments chronologically and build full context for the fix agent
|
|
907
861
|
allCommentEntries.sort((a, b) => a.date.localeCompare(b.date));
|
|
908
862
|
newComments.sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -1200,9 +1154,9 @@ module.exports = {
|
|
|
1200
1154
|
_hasMinionsReviewVerdict, // exported for testing
|
|
1201
1155
|
_isAgentComment, // exported for testing
|
|
1202
1156
|
_isNonActionableComment, // exported for testing
|
|
1203
|
-
|
|
1204
|
-
_isAgentPositiveSignalComment, // exported for testing (P-a3f9b2c1)
|
|
1205
|
-
_hasAgentPositiveSignalBody, // exported for testing (P-a3f9b2c1)
|
|
1206
|
-
_sharedMinionsLogins, // exported for testing (P-a3f9b2c1)
|
|
1157
|
+
_isMinionsAuthoredComment, // exported for testing (W-mp3bp0ha000997ab-b)
|
|
1207
1158
|
_isPreviewStatusComment, // exported for testing
|
|
1159
|
+
_resolveViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
|
|
1160
|
+
_setCachedViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
|
|
1161
|
+
_backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
|
|
1208
1162
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1912",
|
|
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"
|