@exaudeus/workrail 3.38.0 → 3.40.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 +231 -0
- package/dist/console-ui/assets/{index-BtOJj6Xy.js → index-CXWCAonr.js} +1 -1
- package/dist/console-ui/index.html +1 -1
- package/dist/coordinators/pr-review.d.ts +62 -0
- package/dist/coordinators/pr-review.js +575 -0
- package/dist/daemon/workflow-runner.d.ts +3 -2
- package/dist/daemon/workflow-runner.js +6 -3
- package/dist/manifest.json +58 -34
- package/dist/mcp/output-schemas.d.ts +10 -10
- package/dist/mcp/tools.d.ts +12 -12
- package/dist/trigger/trigger-router.js +9 -2
- package/dist/types/workflow-source.d.ts +0 -1
- package/dist/types/workflow-source.js +3 -6
- package/dist/types/workflow.d.ts +1 -1
- package/dist/types/workflow.js +1 -2
- package/dist/v2/durable-core/domain/artifact-contract-validator.js +66 -0
- package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.d.ts +25 -0
- package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.js +31 -0
- package/dist/v2/durable-core/schemas/artifacts/index.d.ts +3 -1
- package/dist/v2/durable-core/schemas/artifacts/index.js +14 -1
- package/dist/v2/durable-core/schemas/artifacts/review-verdict.d.ts +41 -0
- package/dist/v2/durable-core/schemas/artifacts/review-verdict.js +30 -0
- package/dist/v2/durable-core/schemas/export-bundle/index.d.ts +236 -236
- package/dist/v2/durable-core/schemas/session/events.d.ts +50 -50
- package/dist/v2/durable-core/schemas/session/gaps.d.ts +2 -2
- package/dist/v2/durable-core/schemas/session/manifest.d.ts +4 -4
- package/dist/v2/durable-core/schemas/session/outputs.d.ts +8 -8
- package/dist/v2/usecases/console-routes.js +178 -0
- package/docs/design/coordinator-artifact-protocol-design-candidates.md +155 -0
- package/docs/design/coordinator-artifact-protocol-design-review.md +103 -0
- package/docs/design/coordinator-artifact-protocol-implementation-plan.md +259 -0
- 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/docs/ideas/backlog.md +158 -100
- package/package.json +1 -1
- package/workflows/mr-review-workflow.agentic.v2.json +5 -1
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseFindingsFromNotes = parseFindingsFromNotes;
|
|
4
|
+
exports.readVerdictArtifact = readVerdictArtifact;
|
|
5
|
+
exports.buildFixGoal = buildFixGoal;
|
|
6
|
+
exports.formatElapsed = formatElapsed;
|
|
7
|
+
exports.discoverConsolePort = discoverConsolePort;
|
|
8
|
+
exports.runPrReviewCoordinator = runPrReviewCoordinator;
|
|
9
|
+
const result_js_1 = require("../runtime/result.js");
|
|
10
|
+
const review_verdict_js_1 = require("../v2/durable-core/schemas/artifacts/review-verdict.js");
|
|
11
|
+
const MAX_FIX_PASSES = 3;
|
|
12
|
+
const CHILD_SESSION_TIMEOUT_MS = 15 * 60 * 1000;
|
|
13
|
+
const COORDINATOR_MAX_MS = 90 * 60 * 1000;
|
|
14
|
+
const COORDINATOR_SPAWN_CUTOFF_MS = COORDINATOR_MAX_MS - 20 * 60 * 1000;
|
|
15
|
+
const REVIEW_AWAIT_TIMEOUT_MS = 20 * 60 * 1000;
|
|
16
|
+
function parseFindingsFromNotes(notes) {
|
|
17
|
+
if (notes === null || notes.trim() === '') {
|
|
18
|
+
return (0, result_js_1.err)('notes is null or empty');
|
|
19
|
+
}
|
|
20
|
+
const jsonBlockRe = /```json\s*\n([\s\S]*?)\n```/g;
|
|
21
|
+
for (const blockMatch of notes.matchAll(jsonBlockRe)) {
|
|
22
|
+
const blockContent = blockMatch[1];
|
|
23
|
+
if (!blockContent)
|
|
24
|
+
continue;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(blockContent);
|
|
27
|
+
if (typeof parsed['recommendation'] === 'string' &&
|
|
28
|
+
['clean', 'minor', 'blocking'].includes(parsed['recommendation'])) {
|
|
29
|
+
const severity = parsed['recommendation'];
|
|
30
|
+
const findings = Array.isArray(parsed['findings'])
|
|
31
|
+
? parsed['findings']
|
|
32
|
+
.filter((f) => typeof f === 'object' && f !== null)
|
|
33
|
+
.map((f) => typeof f['summary'] === 'string' ? f['summary'] : JSON.stringify(f))
|
|
34
|
+
: [];
|
|
35
|
+
return (0, result_js_1.ok)({ severity, findingSummaries: findings, raw: notes });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const upperNotes = notes.toUpperCase();
|
|
42
|
+
const NEGATION_BLOCKING_RE = /\b(?:not|no|without)\b.{0,30}\bblocking\b/i;
|
|
43
|
+
const NEGATION_CRITICAL_RE = /\b(?:not|no|without)\b.{0,30}\bcritical\b/i;
|
|
44
|
+
const NEGATION_REQUEST_CHANGES_RE = /\b(?:not|no|without)\b.{0,30}\brequest[\s_]changes\b/i;
|
|
45
|
+
const hasBlockingKeyword = (upperNotes.includes('BLOCKING') && !NEGATION_BLOCKING_RE.test(notes)) ||
|
|
46
|
+
(upperNotes.includes('CRITICAL') && !NEGATION_CRITICAL_RE.test(notes)) ||
|
|
47
|
+
(upperNotes.includes('REQUEST CHANGES') && !NEGATION_REQUEST_CHANGES_RE.test(notes));
|
|
48
|
+
if (hasBlockingKeyword) {
|
|
49
|
+
return (0, result_js_1.ok)({
|
|
50
|
+
severity: 'blocking',
|
|
51
|
+
findingSummaries: extractFindingSummaries(notes),
|
|
52
|
+
raw: notes,
|
|
53
|
+
source: 'keyword_scan',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const hasCleanKeyword = upperNotes.includes('APPROVE') ||
|
|
57
|
+
upperNotes.includes('LGTM') ||
|
|
58
|
+
upperNotes.includes('NO FINDINGS') ||
|
|
59
|
+
upperNotes.includes('NO ISSUES') ||
|
|
60
|
+
/\bCLEAN\b/.test(upperNotes);
|
|
61
|
+
const hasMinorKeyword = upperNotes.includes('MINOR') ||
|
|
62
|
+
upperNotes.includes('NIT') ||
|
|
63
|
+
upperNotes.includes('NITPICK') ||
|
|
64
|
+
upperNotes.includes('SUGGESTION');
|
|
65
|
+
if (hasCleanKeyword && !hasMinorKeyword) {
|
|
66
|
+
return (0, result_js_1.ok)({
|
|
67
|
+
severity: 'clean',
|
|
68
|
+
findingSummaries: [],
|
|
69
|
+
raw: notes,
|
|
70
|
+
source: 'keyword_scan',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (hasMinorKeyword) {
|
|
74
|
+
return (0, result_js_1.ok)({
|
|
75
|
+
severity: 'minor',
|
|
76
|
+
findingSummaries: extractFindingSummaries(notes),
|
|
77
|
+
raw: notes,
|
|
78
|
+
source: 'keyword_scan',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return (0, result_js_1.ok)({
|
|
82
|
+
severity: 'unknown',
|
|
83
|
+
findingSummaries: [],
|
|
84
|
+
raw: notes,
|
|
85
|
+
source: 'keyword_scan',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function readVerdictArtifact(artifacts, sessionHandle) {
|
|
89
|
+
const handlePrefix = sessionHandle ? sessionHandle.slice(0, 16) : 'unknown';
|
|
90
|
+
for (const raw of artifacts) {
|
|
91
|
+
if (!(0, review_verdict_js_1.isReviewVerdictArtifact)(raw))
|
|
92
|
+
continue;
|
|
93
|
+
const result = review_verdict_js_1.ReviewVerdictArtifactV1Schema.safeParse(raw);
|
|
94
|
+
if (!result.success) {
|
|
95
|
+
const issues = result.error.issues
|
|
96
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
97
|
+
.join('; ');
|
|
98
|
+
process.stderr.write(`[WARN coord:reason=artifact_parse_failed handle=${handlePrefix}] readVerdictArtifact: wr.review_verdict schema validation failed: ${issues}\n`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const v = result.data;
|
|
102
|
+
return {
|
|
103
|
+
severity: v.verdict,
|
|
104
|
+
findingSummaries: v.findings.map((f) => f.summary),
|
|
105
|
+
raw: JSON.stringify(v),
|
|
106
|
+
source: 'artifact',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
function extractFindingSummaries(notes) {
|
|
112
|
+
const summaries = [];
|
|
113
|
+
const lines = notes.split('\n');
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (/^[-*]\s+.{10,}/.test(trimmed) || /^\d+\.\s+.{10,}/.test(trimmed)) {
|
|
117
|
+
const upper = trimmed.toUpperCase();
|
|
118
|
+
if (upper.includes('RECOMMEND') ||
|
|
119
|
+
upper.includes('CONFIDENCE') ||
|
|
120
|
+
upper.includes('COVERAGE') ||
|
|
121
|
+
upper.includes('SUMMARY')) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const summary = trimmed.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '');
|
|
125
|
+
summaries.push(summary.slice(0, 120));
|
|
126
|
+
if (summaries.length >= 5)
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return summaries;
|
|
131
|
+
}
|
|
132
|
+
function buildFixGoal(prNumber, findings) {
|
|
133
|
+
const findingList = findings.findingSummaries.length > 0
|
|
134
|
+
? ': ' + findings.findingSummaries.slice(0, 3).join('; ')
|
|
135
|
+
: '';
|
|
136
|
+
return `Fix review findings in PR #${prNumber}${findingList}`;
|
|
137
|
+
}
|
|
138
|
+
function formatElapsed(ms) {
|
|
139
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
140
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
141
|
+
const seconds = totalSeconds % 60;
|
|
142
|
+
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
143
|
+
}
|
|
144
|
+
const LOCK_FILE_NAMES = ['daemon-console.lock', 'dashboard.lock'];
|
|
145
|
+
const DEFAULT_CONSOLE_PORT = 3456;
|
|
146
|
+
async function discoverConsolePort(deps, portOverride) {
|
|
147
|
+
if (portOverride !== undefined && portOverride > 0) {
|
|
148
|
+
return portOverride;
|
|
149
|
+
}
|
|
150
|
+
for (const lockFileName of LOCK_FILE_NAMES) {
|
|
151
|
+
const lockPath = deps.joinPath(deps.homedir(), '.workrail', lockFileName);
|
|
152
|
+
try {
|
|
153
|
+
const raw = await deps.readFile(lockPath);
|
|
154
|
+
const parsed = JSON.parse(raw);
|
|
155
|
+
if (typeof parsed.port === 'number' && parsed.port > 0) {
|
|
156
|
+
return parsed.port;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return DEFAULT_CONSOLE_PORT;
|
|
163
|
+
}
|
|
164
|
+
async function runPrReviewCoordinator(deps, opts) {
|
|
165
|
+
const coordinatorStartMs = deps.now();
|
|
166
|
+
const today = new Date(deps.now()).toISOString().slice(0, 10);
|
|
167
|
+
const reportPath = opts.workspace + `/coordinator-pr-review-${today}.md`;
|
|
168
|
+
const reportLines = [];
|
|
169
|
+
function log(line) {
|
|
170
|
+
deps.stderr(line);
|
|
171
|
+
reportLines.push(line);
|
|
172
|
+
}
|
|
173
|
+
log('[1/3] Gathering open PRs...');
|
|
174
|
+
const stageStart = deps.now();
|
|
175
|
+
let prs;
|
|
176
|
+
if (opts.prs && opts.prs.length > 0) {
|
|
177
|
+
prs = opts.prs.map((n) => ({ number: n, title: `PR #${n}`, headRef: '' }));
|
|
178
|
+
}
|
|
179
|
+
else if (opts.dryRun) {
|
|
180
|
+
prs = [];
|
|
181
|
+
log(' [dry-run] would call gh pr list');
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
prs = await deps.listOpenPRs(opts.workspace);
|
|
185
|
+
}
|
|
186
|
+
log(` done (${formatElapsed(deps.now() - stageStart)}) -- ${prs.length} PR(s) found`);
|
|
187
|
+
if (prs.length === 0) {
|
|
188
|
+
const result = {
|
|
189
|
+
reviewed: 0,
|
|
190
|
+
approved: 0,
|
|
191
|
+
escalated: 0,
|
|
192
|
+
mergedPrs: [],
|
|
193
|
+
reportPath,
|
|
194
|
+
hasErrors: false,
|
|
195
|
+
};
|
|
196
|
+
await writeReport(deps, reportPath, reportLines, result);
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
log(`[2/3] Running reviews (${prs.length} parallel)...`);
|
|
200
|
+
const reviewStart = deps.now();
|
|
201
|
+
if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
|
|
202
|
+
log(' WARNING: coordinator elapsed > 70 minutes, refusing to spawn new sessions');
|
|
203
|
+
const result = {
|
|
204
|
+
reviewed: 0,
|
|
205
|
+
approved: 0,
|
|
206
|
+
escalated: prs.length,
|
|
207
|
+
mergedPrs: [],
|
|
208
|
+
reportPath,
|
|
209
|
+
hasErrors: true,
|
|
210
|
+
};
|
|
211
|
+
await writeReport(deps, reportPath, reportLines, result);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
const reviewHandles = new Map();
|
|
215
|
+
const spawnErrors = new Map();
|
|
216
|
+
for (const pr of prs) {
|
|
217
|
+
const goal = `Review PR #${pr.number} "${pr.title}" before merge`;
|
|
218
|
+
if (opts.dryRun) {
|
|
219
|
+
log(` PR #${pr.number} [dry-run] would spawn mr-review-workflow-agentic`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const spawnResult = await deps.spawnSession('mr-review-workflow-agentic', goal, opts.workspace);
|
|
223
|
+
if (spawnResult.kind === 'err') {
|
|
224
|
+
spawnErrors.set(pr.number, spawnResult.error);
|
|
225
|
+
log(` PR #${pr.number} spawn failed: ${spawnResult.error}`);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const handle = spawnResult.value;
|
|
229
|
+
if (!handle) {
|
|
230
|
+
spawnErrors.set(pr.number, 'spawn returned empty session handle (zombie detection)');
|
|
231
|
+
log(` PR #${pr.number} spawn returned empty handle -- zombie detection triggered`);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
reviewHandles.set(pr.number, handle);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const outcomes = new Map();
|
|
239
|
+
if (!opts.dryRun && reviewHandles.size > 0) {
|
|
240
|
+
const awaitResult = await deps.awaitSessions([...reviewHandles.values()], REVIEW_AWAIT_TIMEOUT_MS);
|
|
241
|
+
const handleToPr = new Map();
|
|
242
|
+
for (const [prNum, handle] of reviewHandles) {
|
|
243
|
+
handleToPr.set(handle, prNum);
|
|
244
|
+
}
|
|
245
|
+
for (const sessionResult of awaitResult.results) {
|
|
246
|
+
const prNum = handleToPr.get(sessionResult.handle);
|
|
247
|
+
if (prNum === undefined)
|
|
248
|
+
continue;
|
|
249
|
+
const elapsedMs = sessionResult.durationMs;
|
|
250
|
+
const handle = sessionResult.handle;
|
|
251
|
+
let notes = null;
|
|
252
|
+
let artifacts = [];
|
|
253
|
+
if (sessionResult.outcome === 'success') {
|
|
254
|
+
const agentResult = await deps.getAgentResult(handle);
|
|
255
|
+
notes = agentResult.recapMarkdown;
|
|
256
|
+
artifacts = agentResult.artifacts;
|
|
257
|
+
}
|
|
258
|
+
const verdictFromArtifact = readVerdictArtifact(artifacts, handle);
|
|
259
|
+
const findingsResult = verdictFromArtifact !== null
|
|
260
|
+
? (() => {
|
|
261
|
+
deps.stderr(`[INFO coord:source=artifact handle=${handle.slice(0, 16)}] readVerdictArtifact succeeded`);
|
|
262
|
+
return (0, result_js_1.ok)(verdictFromArtifact);
|
|
263
|
+
})()
|
|
264
|
+
: (() => {
|
|
265
|
+
const keywordResult = parseFindingsFromNotes(notes);
|
|
266
|
+
if (keywordResult.kind === 'ok') {
|
|
267
|
+
const reason = artifacts.length > 0 ? 'no_valid_artifact' : 'no_artifacts';
|
|
268
|
+
deps.stderr(`[INFO coord:source=keyword_scan reason=${reason} artifactCount=${artifacts.length} handle=${handle.slice(0, 16)}]`);
|
|
269
|
+
}
|
|
270
|
+
return keywordResult;
|
|
271
|
+
})();
|
|
272
|
+
const severity = findingsResult.kind === 'ok'
|
|
273
|
+
? findingsResult.value.severity
|
|
274
|
+
: 'unknown';
|
|
275
|
+
const traceBlock = JSON.stringify({
|
|
276
|
+
childSessionId: handle,
|
|
277
|
+
outcome: sessionResult.outcome,
|
|
278
|
+
elapsedMs,
|
|
279
|
+
severity,
|
|
280
|
+
});
|
|
281
|
+
deps.stderr(`[TRACE] ${traceBlock}`);
|
|
282
|
+
reportLines.push(`\n<!-- TRACE: ${traceBlock} -->`);
|
|
283
|
+
const pr = prs.find((p) => p.number === prNum);
|
|
284
|
+
const severityLabel = severity.toUpperCase();
|
|
285
|
+
log(` PR #${pr.number} ${pr.title} done (${formatElapsed(elapsedMs)}) ${severityLabel}`);
|
|
286
|
+
const findings = findingsResult.kind === 'ok' ? findingsResult.value : null;
|
|
287
|
+
const outcome = {
|
|
288
|
+
prNumber: prNum,
|
|
289
|
+
severity,
|
|
290
|
+
merged: false,
|
|
291
|
+
escalated: sessionResult.outcome !== 'success' || severity === 'blocking' || severity === 'unknown',
|
|
292
|
+
escalationReason: sessionResult.outcome !== 'success'
|
|
293
|
+
? `session ${sessionResult.outcome}`
|
|
294
|
+
: severity === 'blocking' || severity === 'unknown'
|
|
295
|
+
? `severity: ${severity}`
|
|
296
|
+
: null,
|
|
297
|
+
passCount: 0,
|
|
298
|
+
sessionHandles: [handle],
|
|
299
|
+
};
|
|
300
|
+
if (severity === 'minor' && findings && sessionResult.outcome === 'success') {
|
|
301
|
+
const processedOutcome = await runFixAgentLoop(deps, opts, pr, findings, outcome, coordinatorStartMs, log);
|
|
302
|
+
outcomes.set(prNum, processedOutcome);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
outcomes.set(prNum, outcome);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
for (const [prNum, errorMsg] of spawnErrors) {
|
|
310
|
+
outcomes.set(prNum, {
|
|
311
|
+
prNumber: prNum,
|
|
312
|
+
severity: 'unknown',
|
|
313
|
+
merged: false,
|
|
314
|
+
escalated: true,
|
|
315
|
+
escalationReason: `spawn error: ${errorMsg}`,
|
|
316
|
+
passCount: 0,
|
|
317
|
+
sessionHandles: [],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
log('[3/3] Processing results...');
|
|
321
|
+
const mergeQueue = [];
|
|
322
|
+
const escalated = [];
|
|
323
|
+
for (const outcome of outcomes.values()) {
|
|
324
|
+
if (!outcome.escalated && outcome.severity === 'clean') {
|
|
325
|
+
mergeQueue.push(outcome.prNumber);
|
|
326
|
+
log(` PR #${outcome.prNumber} -> queued for merge`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
escalated.push(outcome);
|
|
330
|
+
const reason = outcome.escalationReason ?? outcome.severity;
|
|
331
|
+
log(` PR #${outcome.prNumber} -> escalated (${reason})`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const mergedPrs = [];
|
|
335
|
+
for (const prNum of mergeQueue) {
|
|
336
|
+
if (opts.dryRun) {
|
|
337
|
+
log(` PR #${prNum} -> [dry-run] would merge`);
|
|
338
|
+
mergedPrs.push(prNum);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const mergeResult = await deps.mergePR(prNum, opts.workspace);
|
|
342
|
+
if (mergeResult.kind === 'ok') {
|
|
343
|
+
mergedPrs.push(prNum);
|
|
344
|
+
log(` PR #${prNum} -> merged`);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
log(` PR #${prNum} -> merge failed: ${mergeResult.error} (escalated)`);
|
|
348
|
+
escalated.push({
|
|
349
|
+
prNumber: prNum,
|
|
350
|
+
severity: 'clean',
|
|
351
|
+
merged: false,
|
|
352
|
+
escalated: true,
|
|
353
|
+
escalationReason: `merge failed: ${mergeResult.error}`,
|
|
354
|
+
passCount: 0,
|
|
355
|
+
sessionHandles: [],
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const result = {
|
|
360
|
+
reviewed: outcomes.size - spawnErrors.size,
|
|
361
|
+
approved: mergedPrs.length,
|
|
362
|
+
escalated: escalated.length,
|
|
363
|
+
mergedPrs,
|
|
364
|
+
reportPath,
|
|
365
|
+
hasErrors: spawnErrors.size > 0 || escalated.some((o) => o.escalationReason?.startsWith('spawn error') || o.escalationReason?.startsWith('session ') === true),
|
|
366
|
+
};
|
|
367
|
+
const mergedStr = mergedPrs.length > 0 ? `PR #${mergedPrs.join(', PR #')}` : 'none';
|
|
368
|
+
log('');
|
|
369
|
+
log(`RESULT: ${result.reviewed} PRs reviewed, ${result.approved} approved, ${result.escalated} escalated`);
|
|
370
|
+
log(`Merged: ${mergedStr}`);
|
|
371
|
+
log(`Full report: ${reportPath}`);
|
|
372
|
+
log(`\nTotal time: ${formatElapsed(deps.now() - reviewStart)}`);
|
|
373
|
+
await writeReport(deps, reportPath, reportLines, result);
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
async function runFixAgentLoop(deps, opts, pr, initialFindings, initialOutcome, coordinatorStartMs, log) {
|
|
377
|
+
let passCount = 0;
|
|
378
|
+
let currentFindings = initialFindings;
|
|
379
|
+
let sessionHandles = [...initialOutcome.sessionHandles];
|
|
380
|
+
while (passCount < MAX_FIX_PASSES) {
|
|
381
|
+
if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
|
|
382
|
+
log(` PR #${pr.number} -> coordinator elapsed > 70 minutes, escalating`);
|
|
383
|
+
return {
|
|
384
|
+
...initialOutcome,
|
|
385
|
+
passCount,
|
|
386
|
+
sessionHandles,
|
|
387
|
+
escalated: true,
|
|
388
|
+
escalationReason: 'coordinator elapsed > 70 minutes',
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
passCount++;
|
|
392
|
+
const fixGoal = buildFixGoal(pr.number, currentFindings);
|
|
393
|
+
if (opts.dryRun) {
|
|
394
|
+
log(` PR #${pr.number} -> [dry-run] would spawn fix agent (pass ${passCount}): ${fixGoal}`);
|
|
395
|
+
return {
|
|
396
|
+
...initialOutcome,
|
|
397
|
+
passCount,
|
|
398
|
+
sessionHandles,
|
|
399
|
+
severity: 'clean',
|
|
400
|
+
merged: false,
|
|
401
|
+
escalated: false,
|
|
402
|
+
escalationReason: null,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
log(` PR #${pr.number} -> spawning fix agent (pass ${passCount})...`);
|
|
406
|
+
const fixSpawnResult = await deps.spawnSession('coding-task-workflow-agentic', fixGoal, opts.workspace);
|
|
407
|
+
if (fixSpawnResult.kind === 'err') {
|
|
408
|
+
log(` PR #${pr.number} -> fix agent spawn failed: ${fixSpawnResult.error}`);
|
|
409
|
+
return {
|
|
410
|
+
...initialOutcome,
|
|
411
|
+
passCount,
|
|
412
|
+
sessionHandles,
|
|
413
|
+
escalated: true,
|
|
414
|
+
escalationReason: `fix agent spawn error (pass ${passCount}): ${fixSpawnResult.error}`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const fixHandle = fixSpawnResult.value;
|
|
418
|
+
if (!fixHandle) {
|
|
419
|
+
return {
|
|
420
|
+
...initialOutcome,
|
|
421
|
+
passCount,
|
|
422
|
+
sessionHandles,
|
|
423
|
+
escalated: true,
|
|
424
|
+
escalationReason: `fix agent returned empty handle (zombie, pass ${passCount})`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
sessionHandles = [...sessionHandles, fixHandle];
|
|
428
|
+
const fixAwait = await deps.awaitSessions([fixHandle], CHILD_SESSION_TIMEOUT_MS);
|
|
429
|
+
const fixResult = fixAwait.results[0];
|
|
430
|
+
if (!fixResult || fixResult.outcome !== 'success') {
|
|
431
|
+
const outcome = fixResult?.outcome ?? 'not_found';
|
|
432
|
+
log(` PR #${pr.number} -> fix agent ${outcome} (pass ${passCount})`);
|
|
433
|
+
return {
|
|
434
|
+
...initialOutcome,
|
|
435
|
+
passCount,
|
|
436
|
+
sessionHandles,
|
|
437
|
+
escalated: true,
|
|
438
|
+
escalationReason: `fix agent ${outcome} (pass ${passCount})`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
log(` PR #${pr.number} -> fix done (pass ${passCount}), re-reviewing...`);
|
|
442
|
+
if (!opts.dryRun && deps.now() - coordinatorStartMs > COORDINATOR_SPAWN_CUTOFF_MS) {
|
|
443
|
+
log(` PR #${pr.number} -> coordinator elapsed > 70 minutes, skipping re-review spawn`);
|
|
444
|
+
return {
|
|
445
|
+
...initialOutcome,
|
|
446
|
+
passCount,
|
|
447
|
+
sessionHandles,
|
|
448
|
+
escalated: true,
|
|
449
|
+
escalationReason: 'coordinator elapsed > 70 minutes (re-review cutoff)',
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const reReviewGoal = `Re-review PR #${pr.number} after fixes (pass ${passCount})`;
|
|
453
|
+
const reReviewSpawnResult = await deps.spawnSession('mr-review-workflow-agentic', reReviewGoal, opts.workspace);
|
|
454
|
+
if (reReviewSpawnResult.kind === 'err') {
|
|
455
|
+
log(` PR #${pr.number} -> re-review spawn failed: ${reReviewSpawnResult.error}`);
|
|
456
|
+
return {
|
|
457
|
+
...initialOutcome,
|
|
458
|
+
passCount,
|
|
459
|
+
sessionHandles,
|
|
460
|
+
escalated: true,
|
|
461
|
+
escalationReason: `re-review spawn error (pass ${passCount}): ${reReviewSpawnResult.error}`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const reReviewHandle = reReviewSpawnResult.value;
|
|
465
|
+
if (!reReviewHandle) {
|
|
466
|
+
return {
|
|
467
|
+
...initialOutcome,
|
|
468
|
+
passCount,
|
|
469
|
+
sessionHandles,
|
|
470
|
+
escalated: true,
|
|
471
|
+
escalationReason: `re-review returned empty handle (zombie, pass ${passCount})`,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
sessionHandles = [...sessionHandles, reReviewHandle];
|
|
475
|
+
const reReviewAwait = await deps.awaitSessions([reReviewHandle], REVIEW_AWAIT_TIMEOUT_MS);
|
|
476
|
+
const reReviewResult = reReviewAwait.results[0];
|
|
477
|
+
if (!reReviewResult || reReviewResult.outcome !== 'success') {
|
|
478
|
+
const outcome = reReviewResult?.outcome ?? 'not_found';
|
|
479
|
+
log(` PR #${pr.number} -> re-review ${outcome} (pass ${passCount})`);
|
|
480
|
+
return {
|
|
481
|
+
...initialOutcome,
|
|
482
|
+
passCount,
|
|
483
|
+
sessionHandles,
|
|
484
|
+
escalated: true,
|
|
485
|
+
escalationReason: `re-review ${outcome} (pass ${passCount})`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const reAgentResult = await deps.getAgentResult(reReviewHandle);
|
|
489
|
+
const reVerdictFromArtifact = readVerdictArtifact(reAgentResult.artifacts, reReviewHandle);
|
|
490
|
+
const reFindingsResult = reVerdictFromArtifact !== null
|
|
491
|
+
? (() => {
|
|
492
|
+
deps.stderr(`[INFO coord:source=artifact handle=${reReviewHandle.slice(0, 16)}] readVerdictArtifact succeeded (re-review pass ${passCount})`);
|
|
493
|
+
return (0, result_js_1.ok)(reVerdictFromArtifact);
|
|
494
|
+
})()
|
|
495
|
+
: (() => {
|
|
496
|
+
const reason = reAgentResult.artifacts.length > 0 ? 'no_valid_artifact' : 'no_artifacts';
|
|
497
|
+
deps.stderr(`[INFO coord:source=keyword_scan reason=${reason} artifactCount=${reAgentResult.artifacts.length} handle=${reReviewHandle.slice(0, 16)}]`);
|
|
498
|
+
return parseFindingsFromNotes(reAgentResult.recapMarkdown);
|
|
499
|
+
})();
|
|
500
|
+
const reSeverity = reFindingsResult.kind === 'ok'
|
|
501
|
+
? reFindingsResult.value.severity
|
|
502
|
+
: 'unknown';
|
|
503
|
+
const traceBlock = JSON.stringify({
|
|
504
|
+
childSessionId: reReviewHandle,
|
|
505
|
+
outcome: reReviewResult.outcome,
|
|
506
|
+
elapsedMs: reReviewResult.durationMs,
|
|
507
|
+
severity: reSeverity,
|
|
508
|
+
});
|
|
509
|
+
deps.stderr(`[TRACE] ${traceBlock}`);
|
|
510
|
+
log(` PR #${pr.number} -> re-review result: ${reSeverity.toUpperCase()} (pass ${passCount})`);
|
|
511
|
+
if (reSeverity === 'clean') {
|
|
512
|
+
return {
|
|
513
|
+
prNumber: pr.number,
|
|
514
|
+
severity: 'clean',
|
|
515
|
+
merged: false,
|
|
516
|
+
escalated: false,
|
|
517
|
+
escalationReason: null,
|
|
518
|
+
passCount,
|
|
519
|
+
sessionHandles,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (reSeverity === 'blocking' || reSeverity === 'unknown') {
|
|
523
|
+
return {
|
|
524
|
+
prNumber: pr.number,
|
|
525
|
+
severity: reSeverity,
|
|
526
|
+
merged: false,
|
|
527
|
+
escalated: true,
|
|
528
|
+
escalationReason: `severity: ${reSeverity} after fix (pass ${passCount})`,
|
|
529
|
+
passCount,
|
|
530
|
+
sessionHandles,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
currentFindings = reFindingsResult.kind === 'ok'
|
|
534
|
+
? reFindingsResult.value
|
|
535
|
+
: { severity: 'minor', findingSummaries: [], raw: reAgentResult.recapMarkdown ?? '' };
|
|
536
|
+
}
|
|
537
|
+
log(` PR #${pr.number} -> ${MAX_FIX_PASSES} fix passes exhausted, escalating`);
|
|
538
|
+
return {
|
|
539
|
+
prNumber: pr.number,
|
|
540
|
+
severity: 'minor',
|
|
541
|
+
merged: false,
|
|
542
|
+
escalated: true,
|
|
543
|
+
escalationReason: `${MAX_FIX_PASSES} fix passes exhausted`,
|
|
544
|
+
passCount: MAX_FIX_PASSES,
|
|
545
|
+
sessionHandles,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
async function writeReport(deps, reportPath, logLines, result) {
|
|
549
|
+
const today = new Date(deps.now()).toISOString().slice(0, 19).replace('T', ' ');
|
|
550
|
+
const content = [
|
|
551
|
+
`# PR Review Coordinator Report`,
|
|
552
|
+
``,
|
|
553
|
+
`Generated: ${today}`,
|
|
554
|
+
``,
|
|
555
|
+
`## Summary`,
|
|
556
|
+
``,
|
|
557
|
+
`- PRs reviewed: ${result.reviewed}`,
|
|
558
|
+
`- Approved and merged: ${result.approved}`,
|
|
559
|
+
`- Escalated: ${result.escalated}`,
|
|
560
|
+
result.mergedPrs.length > 0 ? `- Merged PRs: ${result.mergedPrs.map((n) => `#${n}`).join(', ')}` : `- Merged PRs: none`,
|
|
561
|
+
``,
|
|
562
|
+
`## Run Log`,
|
|
563
|
+
``,
|
|
564
|
+
`\`\`\``,
|
|
565
|
+
...logLines,
|
|
566
|
+
`\`\`\``,
|
|
567
|
+
``,
|
|
568
|
+
].join('\n');
|
|
569
|
+
try {
|
|
570
|
+
await deps.writeFile(reportPath, content);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
deps.stderr(`Warning: could not write report to ${reportPath}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
@@ -29,6 +29,7 @@ export interface WorkflowRunSuccess {
|
|
|
29
29
|
readonly workflowId: string;
|
|
30
30
|
readonly stopReason: string;
|
|
31
31
|
readonly lastStepNotes?: string;
|
|
32
|
+
readonly lastStepArtifacts?: readonly unknown[];
|
|
32
33
|
}
|
|
33
34
|
export interface WorkflowRunError {
|
|
34
35
|
readonly _tag: 'error';
|
|
@@ -64,8 +65,8 @@ export declare function readDaemonSessionState(sessionId: string): Promise<{
|
|
|
64
65
|
} | null>;
|
|
65
66
|
export declare function readAllDaemonSessions(sessionsDir?: string): Promise<OrphanedSession[]>;
|
|
66
67
|
export declare function runStartupRecovery(sessionsDir?: string): Promise<void>;
|
|
67
|
-
export declare function makeContinueWorkflowTool(sessionId: string, ctx: V2ToolContext, onAdvance: (nextStepText: string, continueToken: string) => void, onComplete: (notes: string | undefined) => void, schemas: Record<string, any>, _executeContinueWorkflowFn?: typeof executeContinueWorkflow, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
|
|
68
|
-
export declare function makeCompleteStepTool(sessionId: string, ctx: V2ToolContext, getCurrentToken: () => string, onAdvance: (nextStepText: string, continueToken: string) => void, onComplete: (notes: string | undefined) => void, onTokenUpdate: (t: string) => void, schemas: Record<string, any>, _executeContinueWorkflowFn?: typeof executeContinueWorkflow, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
|
|
68
|
+
export declare function makeContinueWorkflowTool(sessionId: string, ctx: V2ToolContext, onAdvance: (nextStepText: string, continueToken: string) => void, onComplete: (notes: string | undefined, artifacts?: readonly unknown[]) => void, schemas: Record<string, any>, _executeContinueWorkflowFn?: typeof executeContinueWorkflow, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
|
|
69
|
+
export declare function makeCompleteStepTool(sessionId: string, ctx: V2ToolContext, getCurrentToken: () => string, onAdvance: (nextStepText: string, continueToken: string) => void, onComplete: (notes: string | undefined, artifacts?: readonly unknown[]) => void, onTokenUpdate: (t: string) => void, schemas: Record<string, any>, _executeContinueWorkflowFn?: typeof executeContinueWorkflow, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
|
|
69
70
|
export declare function makeBashTool(workspacePath: string, schemas: Record<string, any>, sessionId?: string, emitter?: DaemonEventEmitter, workrailSessionId?: string | null): AgentTool;
|
|
70
71
|
export declare function makeSpawnAgentTool(sessionId: string, ctx: V2ToolContext, apiKey: string, thisWorkrailSessionId: string, currentDepth: number, maxDepth: number, runWorkflowFn: typeof runWorkflow, schemas: Record<string, any>, emitter?: DaemonEventEmitter): AgentTool;
|
|
71
72
|
export declare function makeReportIssueTool(sessionId: string, emitter?: DaemonEventEmitter, workrailSessionId?: string | null, issuesDirOverride?: string, onIssueSummary?: (summary: string) => void): AgentTool;
|
|
@@ -479,7 +479,7 @@ function makeContinueWorkflowTool(sessionId, ctx, onAdvance, onComplete, schemas
|
|
|
479
479
|
};
|
|
480
480
|
}
|
|
481
481
|
if (out.isComplete) {
|
|
482
|
-
onComplete(params.notesMarkdown);
|
|
482
|
+
onComplete(params.notesMarkdown, Array.isArray(params.artifacts) ? params.artifacts : undefined);
|
|
483
483
|
return {
|
|
484
484
|
content: [{ type: 'text', text: 'Workflow complete. All steps have been executed.' }],
|
|
485
485
|
details: out,
|
|
@@ -581,7 +581,7 @@ function makeCompleteStepTool(sessionId, ctx, getCurrentToken, onAdvance, onComp
|
|
|
581
581
|
};
|
|
582
582
|
}
|
|
583
583
|
if (out.isComplete) {
|
|
584
|
-
onComplete(notes);
|
|
584
|
+
onComplete(notes, Array.isArray(params.artifacts) ? params.artifacts : undefined);
|
|
585
585
|
return {
|
|
586
586
|
content: [{ type: 'text', text: JSON.stringify({ status: 'complete' }) }],
|
|
587
587
|
details: out,
|
|
@@ -1004,6 +1004,7 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
|
|
|
1004
1004
|
let isComplete = false;
|
|
1005
1005
|
let pendingSteerText = null;
|
|
1006
1006
|
let lastStepNotes;
|
|
1007
|
+
let lastStepArtifacts;
|
|
1007
1008
|
let stepAdvanceCount = 0;
|
|
1008
1009
|
const lastNToolCalls = [];
|
|
1009
1010
|
const STUCK_REPEAT_THRESHOLD = 3;
|
|
@@ -1017,9 +1018,10 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
|
|
|
1017
1018
|
daemonRegistry?.heartbeat(workrailSessionId);
|
|
1018
1019
|
emitter?.emit({ kind: 'step_advanced', sessionId, ...withWorkrailSession(workrailSessionId) });
|
|
1019
1020
|
};
|
|
1020
|
-
const onComplete = (notes) => {
|
|
1021
|
+
const onComplete = (notes, artifacts) => {
|
|
1021
1022
|
isComplete = true;
|
|
1022
1023
|
lastStepNotes = notes;
|
|
1024
|
+
lastStepArtifacts = artifacts;
|
|
1023
1025
|
};
|
|
1024
1026
|
let firstStep;
|
|
1025
1027
|
if (trigger._preAllocatedStartResponse !== undefined) {
|
|
@@ -1286,5 +1288,6 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter) {
|
|
|
1286
1288
|
workflowId: trigger.workflowId,
|
|
1287
1289
|
stopReason,
|
|
1288
1290
|
...(lastStepNotes !== undefined ? { lastStepNotes } : {}),
|
|
1291
|
+
...(lastStepArtifacts !== undefined ? { lastStepArtifacts } : {}),
|
|
1289
1292
|
};
|
|
1290
1293
|
}
|