@exaudeus/workrail 3.38.0 → 3.39.0
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/dist/cli-worktrain.js +207 -0
- package/dist/console-ui/assets/{index-BtOJj6Xy.js → index-3oXZ_A9m.js} +1 -1
- package/dist/console-ui/index.html +1 -1
- package/dist/coordinators/pr-review.d.ts +57 -0
- package/dist/coordinators/pr-review.js +520 -0
- package/dist/manifest.json +15 -7
- package/docs/discovery/coordinator-design-review.md +73 -0
- package/docs/discovery/coordinator-script-design.md +96 -679
- package/docs/discovery/hypothesis-challenge-report.md +44 -0
- package/docs/discovery/simulation-report.md +85 -0
- package/package.json +1 -1
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseFindingsFromNotes = parseFindingsFromNotes;
|
|
4
|
+
exports.buildFixGoal = buildFixGoal;
|
|
5
|
+
exports.formatElapsed = formatElapsed;
|
|
6
|
+
exports.discoverConsolePort = discoverConsolePort;
|
|
7
|
+
exports.runPrReviewCoordinator = runPrReviewCoordinator;
|
|
8
|
+
const result_js_1 = require("../runtime/result.js");
|
|
9
|
+
const MAX_FIX_PASSES = 3;
|
|
10
|
+
const CHILD_SESSION_TIMEOUT_MS = 15 * 60 * 1000;
|
|
11
|
+
const COORDINATOR_MAX_MS = 90 * 60 * 1000;
|
|
12
|
+
const COORDINATOR_SPAWN_CUTOFF_MS = COORDINATOR_MAX_MS - 20 * 60 * 1000;
|
|
13
|
+
const REVIEW_AWAIT_TIMEOUT_MS = 20 * 60 * 1000;
|
|
14
|
+
function parseFindingsFromNotes(notes) {
|
|
15
|
+
if (notes === null || notes.trim() === '') {
|
|
16
|
+
return (0, result_js_1.err)('notes is null or empty');
|
|
17
|
+
}
|
|
18
|
+
const jsonBlockRe = /```json\s*\n([\s\S]*?)\n```/g;
|
|
19
|
+
for (const blockMatch of notes.matchAll(jsonBlockRe)) {
|
|
20
|
+
const blockContent = blockMatch[1];
|
|
21
|
+
if (!blockContent)
|
|
22
|
+
continue;
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(blockContent);
|
|
25
|
+
if (typeof parsed['recommendation'] === 'string' &&
|
|
26
|
+
['clean', 'minor', 'blocking'].includes(parsed['recommendation'])) {
|
|
27
|
+
const severity = parsed['recommendation'];
|
|
28
|
+
const findings = Array.isArray(parsed['findings'])
|
|
29
|
+
? parsed['findings']
|
|
30
|
+
.filter((f) => typeof f === 'object' && f !== null)
|
|
31
|
+
.map((f) => typeof f['summary'] === 'string' ? f['summary'] : JSON.stringify(f))
|
|
32
|
+
: [];
|
|
33
|
+
return (0, result_js_1.ok)({ severity, findingSummaries: findings, raw: notes });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const upperNotes = notes.toUpperCase();
|
|
40
|
+
const NEGATION_BLOCKING_RE = /\b(?:not|no|without)\b.{0,30}\bblocking\b/i;
|
|
41
|
+
const NEGATION_CRITICAL_RE = /\b(?:not|no|without)\b.{0,30}\bcritical\b/i;
|
|
42
|
+
const NEGATION_REQUEST_CHANGES_RE = /\b(?:not|no|without)\b.{0,30}\brequest[\s_]changes\b/i;
|
|
43
|
+
const hasBlockingKeyword = (upperNotes.includes('BLOCKING') && !NEGATION_BLOCKING_RE.test(notes)) ||
|
|
44
|
+
(upperNotes.includes('CRITICAL') && !NEGATION_CRITICAL_RE.test(notes)) ||
|
|
45
|
+
(upperNotes.includes('REQUEST CHANGES') && !NEGATION_REQUEST_CHANGES_RE.test(notes));
|
|
46
|
+
if (hasBlockingKeyword) {
|
|
47
|
+
return (0, result_js_1.ok)({
|
|
48
|
+
severity: 'blocking',
|
|
49
|
+
findingSummaries: extractFindingSummaries(notes),
|
|
50
|
+
raw: notes,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const hasCleanKeyword = upperNotes.includes('APPROVE') ||
|
|
54
|
+
upperNotes.includes('LGTM') ||
|
|
55
|
+
upperNotes.includes('NO FINDINGS') ||
|
|
56
|
+
upperNotes.includes('NO ISSUES') ||
|
|
57
|
+
/\bCLEAN\b/.test(upperNotes);
|
|
58
|
+
const hasMinorKeyword = upperNotes.includes('MINOR') ||
|
|
59
|
+
upperNotes.includes('NIT') ||
|
|
60
|
+
upperNotes.includes('NITPICK') ||
|
|
61
|
+
upperNotes.includes('SUGGESTION');
|
|
62
|
+
if (hasCleanKeyword && !hasMinorKeyword) {
|
|
63
|
+
return (0, result_js_1.ok)({
|
|
64
|
+
severity: 'clean',
|
|
65
|
+
findingSummaries: [],
|
|
66
|
+
raw: notes,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (hasMinorKeyword) {
|
|
70
|
+
return (0, result_js_1.ok)({
|
|
71
|
+
severity: 'minor',
|
|
72
|
+
findingSummaries: extractFindingSummaries(notes),
|
|
73
|
+
raw: notes,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return (0, result_js_1.ok)({
|
|
77
|
+
severity: 'unknown',
|
|
78
|
+
findingSummaries: [],
|
|
79
|
+
raw: notes,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function extractFindingSummaries(notes) {
|
|
83
|
+
const summaries = [];
|
|
84
|
+
const lines = notes.split('\n');
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (/^[-*]\s+.{10,}/.test(trimmed) || /^\d+\.\s+.{10,}/.test(trimmed)) {
|
|
88
|
+
const upper = trimmed.toUpperCase();
|
|
89
|
+
if (upper.includes('RECOMMEND') ||
|
|
90
|
+
upper.includes('CONFIDENCE') ||
|
|
91
|
+
upper.includes('COVERAGE') ||
|
|
92
|
+
upper.includes('SUMMARY')) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const summary = trimmed.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '');
|
|
96
|
+
summaries.push(summary.slice(0, 120));
|
|
97
|
+
if (summaries.length >= 5)
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return summaries;
|
|
102
|
+
}
|
|
103
|
+
function buildFixGoal(prNumber, findings) {
|
|
104
|
+
const findingList = findings.findingSummaries.length > 0
|
|
105
|
+
? ': ' + findings.findingSummaries.slice(0, 3).join('; ')
|
|
106
|
+
: '';
|
|
107
|
+
return `Fix review findings in PR #${prNumber}${findingList}`;
|
|
108
|
+
}
|
|
109
|
+
function formatElapsed(ms) {
|
|
110
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
111
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
112
|
+
const seconds = totalSeconds % 60;
|
|
113
|
+
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
114
|
+
}
|
|
115
|
+
const LOCK_FILE_NAMES = ['daemon-console.lock', 'dashboard.lock'];
|
|
116
|
+
const DEFAULT_CONSOLE_PORT = 3456;
|
|
117
|
+
async function discoverConsolePort(deps, portOverride) {
|
|
118
|
+
if (portOverride !== undefined && portOverride > 0) {
|
|
119
|
+
return portOverride;
|
|
120
|
+
}
|
|
121
|
+
for (const lockFileName of LOCK_FILE_NAMES) {
|
|
122
|
+
const lockPath = deps.joinPath(deps.homedir(), '.workrail', lockFileName);
|
|
123
|
+
try {
|
|
124
|
+
const raw = await deps.readFile(lockPath);
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
if (typeof parsed.port === 'number' && parsed.port > 0) {
|
|
127
|
+
return parsed.port;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return DEFAULT_CONSOLE_PORT;
|
|
134
|
+
}
|
|
135
|
+
async function runPrReviewCoordinator(deps, opts) {
|
|
136
|
+
const coordinatorStartMs = deps.now();
|
|
137
|
+
const today = new Date(deps.now()).toISOString().slice(0, 10);
|
|
138
|
+
const reportPath = opts.workspace + `/coordinator-pr-review-${today}.md`;
|
|
139
|
+
const reportLines = [];
|
|
140
|
+
function log(line) {
|
|
141
|
+
deps.stderr(line);
|
|
142
|
+
reportLines.push(line);
|
|
143
|
+
}
|
|
144
|
+
log('[1/3] Gathering open PRs...');
|
|
145
|
+
const stageStart = deps.now();
|
|
146
|
+
let prs;
|
|
147
|
+
if (opts.prs && opts.prs.length > 0) {
|
|
148
|
+
prs = opts.prs.map((n) => ({ number: n, title: `PR #${n}`, headRef: '' }));
|
|
149
|
+
}
|
|
150
|
+
else if (opts.dryRun) {
|
|
151
|
+
prs = [];
|
|
152
|
+
log(' [dry-run] would call gh pr list');
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
prs = await deps.listOpenPRs(opts.workspace);
|
|
156
|
+
}
|
|
157
|
+
log(` done (${formatElapsed(deps.now() - stageStart)}) -- ${prs.length} PR(s) found`);
|
|
158
|
+
if (prs.length === 0) {
|
|
159
|
+
const result = {
|
|
160
|
+
reviewed: 0,
|
|
161
|
+
approved: 0,
|
|
162
|
+
escalated: 0,
|
|
163
|
+
mergedPrs: [],
|
|
164
|
+
reportPath,
|
|
165
|
+
hasErrors: false,
|
|
166
|
+
};
|
|
167
|
+
await writeReport(deps, reportPath, reportLines, result);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
log(`[2/3] Running reviews (${prs.length} parallel)...`);
|
|
171
|
+
const reviewStart = deps.now();
|
|
172
|
+
if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
|
|
173
|
+
log(' WARNING: coordinator elapsed > 70 minutes, refusing to spawn new sessions');
|
|
174
|
+
const result = {
|
|
175
|
+
reviewed: 0,
|
|
176
|
+
approved: 0,
|
|
177
|
+
escalated: prs.length,
|
|
178
|
+
mergedPrs: [],
|
|
179
|
+
reportPath,
|
|
180
|
+
hasErrors: true,
|
|
181
|
+
};
|
|
182
|
+
await writeReport(deps, reportPath, reportLines, result);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
const reviewHandles = new Map();
|
|
186
|
+
const spawnErrors = new Map();
|
|
187
|
+
for (const pr of prs) {
|
|
188
|
+
const goal = `Review PR #${pr.number} "${pr.title}" before merge`;
|
|
189
|
+
if (opts.dryRun) {
|
|
190
|
+
log(` PR #${pr.number} [dry-run] would spawn mr-review-workflow-agentic`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const spawnResult = await deps.spawnSession('mr-review-workflow-agentic', goal, opts.workspace);
|
|
194
|
+
if (spawnResult.kind === 'err') {
|
|
195
|
+
spawnErrors.set(pr.number, spawnResult.error);
|
|
196
|
+
log(` PR #${pr.number} spawn failed: ${spawnResult.error}`);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const handle = spawnResult.value;
|
|
200
|
+
if (!handle) {
|
|
201
|
+
spawnErrors.set(pr.number, 'spawn returned empty session handle (zombie detection)');
|
|
202
|
+
log(` PR #${pr.number} spawn returned empty handle -- zombie detection triggered`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
reviewHandles.set(pr.number, handle);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const outcomes = new Map();
|
|
210
|
+
if (!opts.dryRun && reviewHandles.size > 0) {
|
|
211
|
+
const awaitResult = await deps.awaitSessions([...reviewHandles.values()], REVIEW_AWAIT_TIMEOUT_MS);
|
|
212
|
+
const handleToPr = new Map();
|
|
213
|
+
for (const [prNum, handle] of reviewHandles) {
|
|
214
|
+
handleToPr.set(handle, prNum);
|
|
215
|
+
}
|
|
216
|
+
for (const sessionResult of awaitResult.results) {
|
|
217
|
+
const prNum = handleToPr.get(sessionResult.handle);
|
|
218
|
+
if (prNum === undefined)
|
|
219
|
+
continue;
|
|
220
|
+
const elapsedMs = sessionResult.durationMs;
|
|
221
|
+
const handle = sessionResult.handle;
|
|
222
|
+
let notes = null;
|
|
223
|
+
if (sessionResult.outcome === 'success') {
|
|
224
|
+
notes = await deps.getAgentResult(handle);
|
|
225
|
+
}
|
|
226
|
+
const findingsResult = parseFindingsFromNotes(notes);
|
|
227
|
+
const severity = findingsResult.kind === 'ok'
|
|
228
|
+
? findingsResult.value.severity
|
|
229
|
+
: 'unknown';
|
|
230
|
+
const traceBlock = JSON.stringify({
|
|
231
|
+
childSessionId: handle,
|
|
232
|
+
outcome: sessionResult.outcome,
|
|
233
|
+
elapsedMs,
|
|
234
|
+
severity,
|
|
235
|
+
});
|
|
236
|
+
deps.stderr(`[TRACE] ${traceBlock}`);
|
|
237
|
+
reportLines.push(`\n<!-- TRACE: ${traceBlock} -->`);
|
|
238
|
+
const pr = prs.find((p) => p.number === prNum);
|
|
239
|
+
const severityLabel = severity.toUpperCase();
|
|
240
|
+
log(` PR #${pr.number} ${pr.title} done (${formatElapsed(elapsedMs)}) ${severityLabel}`);
|
|
241
|
+
const findings = findingsResult.kind === 'ok' ? findingsResult.value : null;
|
|
242
|
+
const outcome = {
|
|
243
|
+
prNumber: prNum,
|
|
244
|
+
severity,
|
|
245
|
+
merged: false,
|
|
246
|
+
escalated: sessionResult.outcome !== 'success' || severity === 'blocking' || severity === 'unknown',
|
|
247
|
+
escalationReason: sessionResult.outcome !== 'success'
|
|
248
|
+
? `session ${sessionResult.outcome}`
|
|
249
|
+
: severity === 'blocking' || severity === 'unknown'
|
|
250
|
+
? `severity: ${severity}`
|
|
251
|
+
: null,
|
|
252
|
+
passCount: 0,
|
|
253
|
+
sessionHandles: [handle],
|
|
254
|
+
};
|
|
255
|
+
if (severity === 'minor' && findings && sessionResult.outcome === 'success') {
|
|
256
|
+
const processedOutcome = await runFixAgentLoop(deps, opts, pr, findings, outcome, coordinatorStartMs, log);
|
|
257
|
+
outcomes.set(prNum, processedOutcome);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
outcomes.set(prNum, outcome);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (const [prNum, errorMsg] of spawnErrors) {
|
|
265
|
+
outcomes.set(prNum, {
|
|
266
|
+
prNumber: prNum,
|
|
267
|
+
severity: 'unknown',
|
|
268
|
+
merged: false,
|
|
269
|
+
escalated: true,
|
|
270
|
+
escalationReason: `spawn error: ${errorMsg}`,
|
|
271
|
+
passCount: 0,
|
|
272
|
+
sessionHandles: [],
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
log('[3/3] Processing results...');
|
|
276
|
+
const mergeQueue = [];
|
|
277
|
+
const escalated = [];
|
|
278
|
+
for (const outcome of outcomes.values()) {
|
|
279
|
+
if (!outcome.escalated && outcome.severity === 'clean') {
|
|
280
|
+
mergeQueue.push(outcome.prNumber);
|
|
281
|
+
log(` PR #${outcome.prNumber} -> queued for merge`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
escalated.push(outcome);
|
|
285
|
+
const reason = outcome.escalationReason ?? outcome.severity;
|
|
286
|
+
log(` PR #${outcome.prNumber} -> escalated (${reason})`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const mergedPrs = [];
|
|
290
|
+
for (const prNum of mergeQueue) {
|
|
291
|
+
if (opts.dryRun) {
|
|
292
|
+
log(` PR #${prNum} -> [dry-run] would merge`);
|
|
293
|
+
mergedPrs.push(prNum);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const mergeResult = await deps.mergePR(prNum, opts.workspace);
|
|
297
|
+
if (mergeResult.kind === 'ok') {
|
|
298
|
+
mergedPrs.push(prNum);
|
|
299
|
+
log(` PR #${prNum} -> merged`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
log(` PR #${prNum} -> merge failed: ${mergeResult.error} (escalated)`);
|
|
303
|
+
escalated.push({
|
|
304
|
+
prNumber: prNum,
|
|
305
|
+
severity: 'clean',
|
|
306
|
+
merged: false,
|
|
307
|
+
escalated: true,
|
|
308
|
+
escalationReason: `merge failed: ${mergeResult.error}`,
|
|
309
|
+
passCount: 0,
|
|
310
|
+
sessionHandles: [],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const result = {
|
|
315
|
+
reviewed: outcomes.size - spawnErrors.size,
|
|
316
|
+
approved: mergedPrs.length,
|
|
317
|
+
escalated: escalated.length,
|
|
318
|
+
mergedPrs,
|
|
319
|
+
reportPath,
|
|
320
|
+
hasErrors: spawnErrors.size > 0 || escalated.some((o) => o.escalationReason?.startsWith('spawn error') || o.escalationReason?.startsWith('session ') === true),
|
|
321
|
+
};
|
|
322
|
+
const mergedStr = mergedPrs.length > 0 ? `PR #${mergedPrs.join(', PR #')}` : 'none';
|
|
323
|
+
log('');
|
|
324
|
+
log(`RESULT: ${result.reviewed} PRs reviewed, ${result.approved} approved, ${result.escalated} escalated`);
|
|
325
|
+
log(`Merged: ${mergedStr}`);
|
|
326
|
+
log(`Full report: ${reportPath}`);
|
|
327
|
+
log(`\nTotal time: ${formatElapsed(deps.now() - reviewStart)}`);
|
|
328
|
+
await writeReport(deps, reportPath, reportLines, result);
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
async function runFixAgentLoop(deps, opts, pr, initialFindings, initialOutcome, coordinatorStartMs, log) {
|
|
332
|
+
let passCount = 0;
|
|
333
|
+
let currentFindings = initialFindings;
|
|
334
|
+
let sessionHandles = [...initialOutcome.sessionHandles];
|
|
335
|
+
while (passCount < MAX_FIX_PASSES) {
|
|
336
|
+
if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
|
|
337
|
+
log(` PR #${pr.number} -> coordinator elapsed > 70 minutes, escalating`);
|
|
338
|
+
return {
|
|
339
|
+
...initialOutcome,
|
|
340
|
+
passCount,
|
|
341
|
+
sessionHandles,
|
|
342
|
+
escalated: true,
|
|
343
|
+
escalationReason: 'coordinator elapsed > 70 minutes',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
passCount++;
|
|
347
|
+
const fixGoal = buildFixGoal(pr.number, currentFindings);
|
|
348
|
+
if (opts.dryRun) {
|
|
349
|
+
log(` PR #${pr.number} -> [dry-run] would spawn fix agent (pass ${passCount}): ${fixGoal}`);
|
|
350
|
+
return {
|
|
351
|
+
...initialOutcome,
|
|
352
|
+
passCount,
|
|
353
|
+
sessionHandles,
|
|
354
|
+
severity: 'clean',
|
|
355
|
+
merged: false,
|
|
356
|
+
escalated: false,
|
|
357
|
+
escalationReason: null,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
log(` PR #${pr.number} -> spawning fix agent (pass ${passCount})...`);
|
|
361
|
+
const fixSpawnResult = await deps.spawnSession('coding-task-workflow-agentic', fixGoal, opts.workspace);
|
|
362
|
+
if (fixSpawnResult.kind === 'err') {
|
|
363
|
+
log(` PR #${pr.number} -> fix agent spawn failed: ${fixSpawnResult.error}`);
|
|
364
|
+
return {
|
|
365
|
+
...initialOutcome,
|
|
366
|
+
passCount,
|
|
367
|
+
sessionHandles,
|
|
368
|
+
escalated: true,
|
|
369
|
+
escalationReason: `fix agent spawn error (pass ${passCount}): ${fixSpawnResult.error}`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const fixHandle = fixSpawnResult.value;
|
|
373
|
+
if (!fixHandle) {
|
|
374
|
+
return {
|
|
375
|
+
...initialOutcome,
|
|
376
|
+
passCount,
|
|
377
|
+
sessionHandles,
|
|
378
|
+
escalated: true,
|
|
379
|
+
escalationReason: `fix agent returned empty handle (zombie, pass ${passCount})`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
sessionHandles = [...sessionHandles, fixHandle];
|
|
383
|
+
const fixAwait = await deps.awaitSessions([fixHandle], CHILD_SESSION_TIMEOUT_MS);
|
|
384
|
+
const fixResult = fixAwait.results[0];
|
|
385
|
+
if (!fixResult || fixResult.outcome !== 'success') {
|
|
386
|
+
const outcome = fixResult?.outcome ?? 'not_found';
|
|
387
|
+
log(` PR #${pr.number} -> fix agent ${outcome} (pass ${passCount})`);
|
|
388
|
+
return {
|
|
389
|
+
...initialOutcome,
|
|
390
|
+
passCount,
|
|
391
|
+
sessionHandles,
|
|
392
|
+
escalated: true,
|
|
393
|
+
escalationReason: `fix agent ${outcome} (pass ${passCount})`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
log(` PR #${pr.number} -> fix done (pass ${passCount}), re-reviewing...`);
|
|
397
|
+
if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
|
|
398
|
+
log(` PR #${pr.number} -> coordinator elapsed > 70 minutes, skipping re-review spawn`);
|
|
399
|
+
return {
|
|
400
|
+
...initialOutcome,
|
|
401
|
+
passCount,
|
|
402
|
+
sessionHandles,
|
|
403
|
+
escalated: true,
|
|
404
|
+
escalationReason: 'coordinator elapsed > 70 minutes (re-review cutoff)',
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const reReviewGoal = `Re-review PR #${pr.number} after fixes (pass ${passCount})`;
|
|
408
|
+
const reReviewSpawnResult = await deps.spawnSession('mr-review-workflow-agentic', reReviewGoal, opts.workspace);
|
|
409
|
+
if (reReviewSpawnResult.kind === 'err') {
|
|
410
|
+
log(` PR #${pr.number} -> re-review spawn failed: ${reReviewSpawnResult.error}`);
|
|
411
|
+
return {
|
|
412
|
+
...initialOutcome,
|
|
413
|
+
passCount,
|
|
414
|
+
sessionHandles,
|
|
415
|
+
escalated: true,
|
|
416
|
+
escalationReason: `re-review spawn error (pass ${passCount}): ${reReviewSpawnResult.error}`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const reReviewHandle = reReviewSpawnResult.value;
|
|
420
|
+
if (!reReviewHandle) {
|
|
421
|
+
return {
|
|
422
|
+
...initialOutcome,
|
|
423
|
+
passCount,
|
|
424
|
+
sessionHandles,
|
|
425
|
+
escalated: true,
|
|
426
|
+
escalationReason: `re-review returned empty handle (zombie, pass ${passCount})`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
sessionHandles = [...sessionHandles, reReviewHandle];
|
|
430
|
+
const reReviewAwait = await deps.awaitSessions([reReviewHandle], REVIEW_AWAIT_TIMEOUT_MS);
|
|
431
|
+
const reReviewResult = reReviewAwait.results[0];
|
|
432
|
+
if (!reReviewResult || reReviewResult.outcome !== 'success') {
|
|
433
|
+
const outcome = reReviewResult?.outcome ?? 'not_found';
|
|
434
|
+
log(` PR #${pr.number} -> re-review ${outcome} (pass ${passCount})`);
|
|
435
|
+
return {
|
|
436
|
+
...initialOutcome,
|
|
437
|
+
passCount,
|
|
438
|
+
sessionHandles,
|
|
439
|
+
escalated: true,
|
|
440
|
+
escalationReason: `re-review ${outcome} (pass ${passCount})`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const reNotes = await deps.getAgentResult(reReviewHandle);
|
|
444
|
+
const reFindingsResult = parseFindingsFromNotes(reNotes);
|
|
445
|
+
const reSeverity = reFindingsResult.kind === 'ok'
|
|
446
|
+
? reFindingsResult.value.severity
|
|
447
|
+
: 'unknown';
|
|
448
|
+
const traceBlock = JSON.stringify({
|
|
449
|
+
childSessionId: reReviewHandle,
|
|
450
|
+
outcome: reReviewResult.outcome,
|
|
451
|
+
elapsedMs: reReviewResult.durationMs,
|
|
452
|
+
severity: reSeverity,
|
|
453
|
+
});
|
|
454
|
+
deps.stderr(`[TRACE] ${traceBlock}`);
|
|
455
|
+
log(` PR #${pr.number} -> re-review result: ${reSeverity.toUpperCase()} (pass ${passCount})`);
|
|
456
|
+
if (reSeverity === 'clean') {
|
|
457
|
+
return {
|
|
458
|
+
prNumber: pr.number,
|
|
459
|
+
severity: 'clean',
|
|
460
|
+
merged: false,
|
|
461
|
+
escalated: false,
|
|
462
|
+
escalationReason: null,
|
|
463
|
+
passCount,
|
|
464
|
+
sessionHandles,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (reSeverity === 'blocking' || reSeverity === 'unknown') {
|
|
468
|
+
return {
|
|
469
|
+
prNumber: pr.number,
|
|
470
|
+
severity: reSeverity,
|
|
471
|
+
merged: false,
|
|
472
|
+
escalated: true,
|
|
473
|
+
escalationReason: `severity: ${reSeverity} after fix (pass ${passCount})`,
|
|
474
|
+
passCount,
|
|
475
|
+
sessionHandles,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
currentFindings = reFindingsResult.kind === 'ok'
|
|
479
|
+
? reFindingsResult.value
|
|
480
|
+
: { severity: 'minor', findingSummaries: [], raw: reNotes ?? '' };
|
|
481
|
+
}
|
|
482
|
+
log(` PR #${pr.number} -> ${MAX_FIX_PASSES} fix passes exhausted, escalating`);
|
|
483
|
+
return {
|
|
484
|
+
prNumber: pr.number,
|
|
485
|
+
severity: 'minor',
|
|
486
|
+
merged: false,
|
|
487
|
+
escalated: true,
|
|
488
|
+
escalationReason: `${MAX_FIX_PASSES} fix passes exhausted`,
|
|
489
|
+
passCount: MAX_FIX_PASSES,
|
|
490
|
+
sessionHandles,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
async function writeReport(deps, reportPath, logLines, result) {
|
|
494
|
+
const today = new Date(deps.now()).toISOString().slice(0, 19).replace('T', ' ');
|
|
495
|
+
const content = [
|
|
496
|
+
`# PR Review Coordinator Report`,
|
|
497
|
+
``,
|
|
498
|
+
`Generated: ${today}`,
|
|
499
|
+
``,
|
|
500
|
+
`## Summary`,
|
|
501
|
+
``,
|
|
502
|
+
`- PRs reviewed: ${result.reviewed}`,
|
|
503
|
+
`- Approved and merged: ${result.approved}`,
|
|
504
|
+
`- Escalated: ${result.escalated}`,
|
|
505
|
+
result.mergedPrs.length > 0 ? `- Merged PRs: ${result.mergedPrs.map((n) => `#${n}`).join(', ')}` : `- Merged PRs: none`,
|
|
506
|
+
``,
|
|
507
|
+
`## Run Log`,
|
|
508
|
+
``,
|
|
509
|
+
`\`\`\``,
|
|
510
|
+
...logLines,
|
|
511
|
+
`\`\`\``,
|
|
512
|
+
``,
|
|
513
|
+
].join('\n');
|
|
514
|
+
try {
|
|
515
|
+
await deps.writeFile(reportPath, content);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
deps.stderr(`Warning: could not write report to ${reportPath}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
package/dist/manifest.json
CHANGED
|
@@ -238,8 +238,8 @@
|
|
|
238
238
|
"bytes": 31
|
|
239
239
|
},
|
|
240
240
|
"cli-worktrain.js": {
|
|
241
|
-
"sha256": "
|
|
242
|
-
"bytes":
|
|
241
|
+
"sha256": "72187f7993fdad038cd3e164372a3e7fbbe630bc56d1b40bdea0062e2431e089",
|
|
242
|
+
"bytes": 43548
|
|
243
243
|
},
|
|
244
244
|
"cli.d.ts": {
|
|
245
245
|
"sha256": "43e818adf60173644896298637f47b01d5819b17eda46eaa32d0c7d64724d012",
|
|
@@ -449,16 +449,16 @@
|
|
|
449
449
|
"sha256": "5fe866e54f796975dec5d8ba9983aefd86074db212d3fccd64eed04bc9f0b3da",
|
|
450
450
|
"bytes": 8011
|
|
451
451
|
},
|
|
452
|
+
"console-ui/assets/index-3oXZ_A9m.js": {
|
|
453
|
+
"sha256": "b4d27f6107d5100ca44bb257e724beaf88df2ab235a21bdc88d5c9bfb89009ff",
|
|
454
|
+
"bytes": 754955
|
|
455
|
+
},
|
|
452
456
|
"console-ui/assets/index-8dh0Psu-.css": {
|
|
453
457
|
"sha256": "cf9d09641f1c31fffe6c7835b30bbbad52572befec1acab7fb9a0c188431af36",
|
|
454
458
|
"bytes": 60355
|
|
455
459
|
},
|
|
456
|
-
"console-ui/assets/index-BtOJj6Xy.js": {
|
|
457
|
-
"sha256": "92758189eacadde70ae3288c09100f130d51bf27811b3a92b9ea355f45243836",
|
|
458
|
-
"bytes": 754955
|
|
459
|
-
},
|
|
460
460
|
"console-ui/index.html": {
|
|
461
|
-
"sha256": "
|
|
461
|
+
"sha256": "bd9b538a079608bf3cf01289202d58a81c52336e3ee0bf797c863459c66d700f",
|
|
462
462
|
"bytes": 417
|
|
463
463
|
},
|
|
464
464
|
"console/standalone-console.d.ts": {
|
|
@@ -469,6 +469,14 @@
|
|
|
469
469
|
"sha256": "759d8be0b9804aa2e6301f14afb2dc97d45231434058f64d1cfbec6e025a1991",
|
|
470
470
|
"bytes": 6325
|
|
471
471
|
},
|
|
472
|
+
"coordinators/pr-review.d.ts": {
|
|
473
|
+
"sha256": "f544a13fbfbcbac56229914270aad58a91a3081ce1d5e1e667e8eef45fd698d2",
|
|
474
|
+
"bytes": 2570
|
|
475
|
+
},
|
|
476
|
+
"coordinators/pr-review.js": {
|
|
477
|
+
"sha256": "3a9cb96ea0b4e138dac28b13abcb172b5785fdf67d752a3c8f42d746fb07feec",
|
|
478
|
+
"bytes": 21154
|
|
479
|
+
},
|
|
472
480
|
"core/error-handler.d.ts": {
|
|
473
481
|
"sha256": "80451f12ac8e185133ec3dc4c57285491a785f27525ed21e729db1da3f61010d",
|
|
474
482
|
"bytes": 1368
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Design Review Findings: PR Review Coordinator
|
|
2
|
+
|
|
3
|
+
*Review completed: 2026-04-18*
|
|
4
|
+
|
|
5
|
+
## Tradeoff Review
|
|
6
|
+
|
|
7
|
+
| Tradeoff | Verdict | Conditions for Failure |
|
|
8
|
+
|----------|---------|------------------------|
|
|
9
|
+
| Port discovery duplicated from worktrain-spawn.ts | Acceptable | Lock file names change (low risk, bounded) |
|
|
10
|
+
| Two-tier parser is heuristic | Acceptable for MVP | False escalation rate > 20% of clean PRs |
|
|
11
|
+
| Fix-agent loop max 3 passes | Acceptable | If 3 passes is too few for real codebases (tunable) |
|
|
12
|
+
|
|
13
|
+
## Failure Mode Review
|
|
14
|
+
|
|
15
|
+
| Failure Mode | Coverage | Risk |
|
|
16
|
+
|-------------|----------|------|
|
|
17
|
+
| null recapMarkdown -> unknown -> escalate | Full | Low -- conservative, correct |
|
|
18
|
+
| Fix loop 3 passes, persistent minor -> escalate | Full | Low |
|
|
19
|
+
| ECONNREFUSED -> clear error exit | Partial (needs timeout) | Low |
|
|
20
|
+
| Keyword false positive -> false escalation | Partial (needs negation check) | Medium |
|
|
21
|
+
| gh pr merge failure -> escalate without retry | Full | Low |
|
|
22
|
+
| Zombie session (null childSessionId) | Full | Low |
|
|
23
|
+
|
|
24
|
+
## Runner-Up / Simpler Alternative Review
|
|
25
|
+
|
|
26
|
+
Candidate A (subprocess model) has nothing worth borrowing. No beneficial hybrid exists. Candidate B is the correct shape. One naming improvement identified: `postResult` -> `writeFile(path, content)` for clarity.
|
|
27
|
+
|
|
28
|
+
## Philosophy Alignment
|
|
29
|
+
|
|
30
|
+
- All 8 CLAUDE.md principles satisfied
|
|
31
|
+
- Two minor tensions: mutable loop counter (acceptable, local), two-tier parser as patch (acceptable, follow-up filed)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Findings
|
|
36
|
+
|
|
37
|
+
### ORANGE: Keyword false positive risk -- negation context not handled
|
|
38
|
+
|
|
39
|
+
The keyword scanner will match `BLOCKING` in contexts like 'this is not technically blocking'. This is the most likely source of false escalations in practice.
|
|
40
|
+
|
|
41
|
+
**Recommended revision:** Before returning `'blocking'`, check that the `BLOCKING` keyword is not preceded by a negation within ~30 chars: `/\b(?:not|no|without)\b.{0,30}\bblocking\b/i` -> if this matches, do not classify as blocking. Apply same check to CRITICAL.
|
|
42
|
+
|
|
43
|
+
### ORANGE: No fetch timeout on HTTP calls
|
|
44
|
+
|
|
45
|
+
`spawnSession()` and `getAgentResult()` use bare `fetch()` with no timeout. If the daemon is running but unresponsive, the coordinator hangs indefinitely.
|
|
46
|
+
|
|
47
|
+
**Recommended revision:** Add `AbortSignal.timeout(30_000)` to all dispatch and session/node fetch calls. Catch `AbortError` and return `err('Daemon request timed out after 30s')`.
|
|
48
|
+
|
|
49
|
+
### YELLOW: `postResult` dep name is unclear
|
|
50
|
+
|
|
51
|
+
The `postResult(notes: string)` name is ambiguous -- does it post to Slack? Write to a file? Create a GitHub comment?
|
|
52
|
+
|
|
53
|
+
**Recommended revision:** Rename to `writeFile(path: string, content: string): Promise<void>`. The coordinator decides the report file path. This matches the UX spec: `Full report: ./coordinator-pr-review-2026-04-18.md`.
|
|
54
|
+
|
|
55
|
+
### YELLOW: Rule 3 adaptation not explicit in original 5 robustness rules spec
|
|
56
|
+
|
|
57
|
+
The original Rule 3 (go/no-go time check) was designed for daemon sessions with known `maxSessionMinutes`. The CLI coordinator has no such parameter.
|
|
58
|
+
|
|
59
|
+
**Recommended revision:** Explicit Rule 3 adaptation: `const coordinatorStartMs = deps.now()` at startup; before each `spawnSession()` call, check `deps.now() - coordinatorStartMs > 70 * 60 * 1000` (70 min = 90 min coordinator max - 20 min buffer) and refuse to spawn if exceeded.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Recommended Revisions
|
|
64
|
+
|
|
65
|
+
1. Add negation context check in keyword parser (`/\b(?:not|no|without)\b.{0,30}\bblocking\b/i`)
|
|
66
|
+
2. Add `AbortSignal.timeout(30_000)` to fetch calls
|
|
67
|
+
3. Rename `postResult` to `writeFile(path, content)`
|
|
68
|
+
4. Add explicit wall-clock Rule 3 adaptation to implementation spec
|
|
69
|
+
|
|
70
|
+
## Residual Concerns
|
|
71
|
+
|
|
72
|
+
- The two-tier parser's keyword scan is a heuristic. False escalation rate unknown until tested against real mr-review outputs. Follow-up: update mr-review-workflow to emit `## COORDINATOR_OUTPUT` JSON block.
|
|
73
|
+
- The coordinator's own timeout (90 minutes) is hardcoded. Should be configurable via `--max-runtime` flag if needed. Not for MVP.
|