@mcoda/core 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -1
- package/README.md +22 -3
- package/dist/api/AgentsApi.d.ts +8 -1
- package/dist/api/AgentsApi.d.ts.map +1 -1
- package/dist/api/AgentsApi.js +70 -0
- package/dist/api/QaTasksApi.d.ts.map +1 -1
- package/dist/api/QaTasksApi.js +2 -0
- package/dist/api/TasksApi.d.ts.map +1 -1
- package/dist/api/TasksApi.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -1
- package/dist/prompts/PdrPrompts.js +3 -1
- package/dist/prompts/SdsPrompts.d.ts.map +1 -1
- package/dist/prompts/SdsPrompts.js +2 -0
- package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
- package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
- package/dist/services/agents/AgentRatingFormula.js +45 -0
- package/dist/services/agents/AgentRatingService.d.ts +41 -0
- package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
- package/dist/services/agents/AgentRatingService.js +299 -0
- package/dist/services/agents/GatewayAgentService.d.ts +3 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
- package/dist/services/agents/GatewayAgentService.js +68 -24
- package/dist/services/agents/GatewayHandoff.d.ts +7 -0
- package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
- package/dist/services/agents/GatewayHandoff.js +108 -0
- package/dist/services/backlog/TaskOrderingService.d.ts +1 -0
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
- package/dist/services/backlog/TaskOrderingService.js +19 -16
- package/dist/services/docs/DocsService.d.ts +11 -1
- package/dist/services/docs/DocsService.d.ts.map +1 -1
- package/dist/services/docs/DocsService.js +240 -52
- package/dist/services/execution/GatewayTrioService.d.ts +133 -0
- package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
- package/dist/services/execution/GatewayTrioService.js +1125 -0
- package/dist/services/execution/QaFollowupService.d.ts +1 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
- package/dist/services/execution/QaFollowupService.js +1 -0
- package/dist/services/execution/QaProfileService.d.ts +6 -0
- package/dist/services/execution/QaProfileService.d.ts.map +1 -1
- package/dist/services/execution/QaProfileService.js +165 -3
- package/dist/services/execution/QaTasksService.d.ts +18 -0
- package/dist/services/execution/QaTasksService.d.ts.map +1 -1
- package/dist/services/execution/QaTasksService.js +712 -34
- package/dist/services/execution/WorkOnTasksService.d.ts +14 -0
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
- package/dist/services/execution/WorkOnTasksService.js +1497 -240
- package/dist/services/openapi/OpenApiService.d.ts +10 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
- package/dist/services/openapi/OpenApiService.js +66 -10
- package/dist/services/planning/CreateTasksService.d.ts +6 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +261 -28
- package/dist/services/planning/RefineTasksService.d.ts +5 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +184 -35
- package/dist/services/review/CodeReviewService.d.ts +14 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -1
- package/dist/services/review/CodeReviewService.js +657 -61
- package/dist/services/shared/ProjectGuidance.d.ts +6 -0
- package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
- package/dist/services/shared/ProjectGuidance.js +21 -0
- package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
- package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
- package/dist/services/tasks/TaskCommentFormatter.js +54 -0
- package/dist/workspace/WorkspaceManager.d.ts +4 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
- package/dist/workspace/WorkspaceManager.js +3 -0
- package/package.json +5 -5
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import { AgentService } from "@mcoda/agents";
|
|
4
4
|
import { DocdexClient, VcsClient } from "@mcoda/integrations";
|
|
5
|
-
import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
|
|
5
|
+
import { GlobalRepository, WorkspaceRepository, } from "@mcoda/db";
|
|
6
6
|
import { PathHelper } from "@mcoda/shared";
|
|
7
7
|
import { JobService } from "../jobs/JobService.js";
|
|
8
8
|
import { TaskSelectionService } from "../execution/TaskSelectionService.js";
|
|
@@ -11,9 +11,20 @@ import { BacklogService } from "../backlog/BacklogService.js";
|
|
|
11
11
|
import yaml from "yaml";
|
|
12
12
|
import { createTaskKeyGenerator } from "../planning/KeyHelpers.js";
|
|
13
13
|
import { RoutingService } from "../agents/RoutingService.js";
|
|
14
|
+
import { AgentRatingService } from "../agents/AgentRatingService.js";
|
|
15
|
+
import { loadProjectGuidance } from "../shared/ProjectGuidance.js";
|
|
16
|
+
import { createTaskCommentSlug, formatTaskCommentBody } from "../tasks/TaskCommentFormatter.js";
|
|
14
17
|
const DEFAULT_BASE_BRANCH = "mcoda-dev";
|
|
15
18
|
const REVIEW_DIR = (workspaceRoot, jobId) => path.join(workspaceRoot, ".mcoda", "jobs", jobId, "review");
|
|
16
19
|
const STATE_PATH = (workspaceRoot, jobId) => path.join(REVIEW_DIR(workspaceRoot, jobId), "state.json");
|
|
20
|
+
const REVIEW_PROMPT_LIMITS = {
|
|
21
|
+
diff: 12000,
|
|
22
|
+
history: 3000,
|
|
23
|
+
docContext: 4000,
|
|
24
|
+
openapi: 8000,
|
|
25
|
+
checklist: 3000,
|
|
26
|
+
};
|
|
27
|
+
const DOCDEX_TIMEOUT_MS = 8000;
|
|
17
28
|
const DEFAULT_CODE_REVIEW_PROMPT = [
|
|
18
29
|
"You are the code-review agent. Before reviewing, query docdex with the task key and feature keywords (MCP `docdex_search` limit 4–8 or CLI `docdexd query --repo <repo> --query \"<term>\" --limit 6 --snippets=false`). If results look stale, reindex (`docdex_index` or `docdexd index --repo <repo>`) then re-run. Fetch snippets via `docdex_open` or `/snippet/:doc_id?text_only=true` only for specific hits.",
|
|
19
30
|
"Use docdex snippets to verify contracts (data shapes, offline scope, accessibility/perf guardrails, acceptance criteria). Call out mismatches, missing tests, and undocumented changes.",
|
|
@@ -21,22 +32,53 @@ const DEFAULT_CODE_REVIEW_PROMPT = [
|
|
|
21
32
|
const DEFAULT_JOB_PROMPT = "You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.";
|
|
22
33
|
const DEFAULT_CHARACTER_PROMPT = "Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.";
|
|
23
34
|
const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
|
|
35
|
+
const extractJsonSlice = (candidate) => {
|
|
36
|
+
const start = candidate.indexOf("{");
|
|
37
|
+
const end = candidate.lastIndexOf("}");
|
|
38
|
+
if (start === -1 || end === -1 || end <= start)
|
|
39
|
+
return undefined;
|
|
40
|
+
return candidate.slice(start, end + 1);
|
|
41
|
+
};
|
|
42
|
+
const sanitizeJsonCandidate = (value) => {
|
|
43
|
+
const cleanedLines = value
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.filter((line) => {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed)
|
|
48
|
+
return true;
|
|
49
|
+
if (trimmed.startsWith("{") ||
|
|
50
|
+
trimmed.startsWith("}") ||
|
|
51
|
+
trimmed.startsWith("[") ||
|
|
52
|
+
trimmed.startsWith("]") ||
|
|
53
|
+
trimmed.startsWith("\"")) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
})
|
|
58
|
+
.join("\n");
|
|
59
|
+
return cleanedLines.replace(/,\s*([}\]])/g, "$1");
|
|
60
|
+
};
|
|
24
61
|
const parseJsonOutput = (raw) => {
|
|
25
62
|
const trimmed = raw.trim();
|
|
26
63
|
const fenced = trimmed.replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
|
|
27
64
|
const candidates = [trimmed, fenced];
|
|
28
65
|
for (const candidate of candidates) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
if (start === -1 || end === -1 || end <= start)
|
|
66
|
+
const slice = extractJsonSlice(candidate);
|
|
67
|
+
if (!slice)
|
|
32
68
|
continue;
|
|
33
|
-
const slice = candidate.slice(start, end + 1);
|
|
34
69
|
try {
|
|
35
70
|
const parsed = JSON.parse(slice);
|
|
36
71
|
return { ...parsed, raw: raw };
|
|
37
72
|
}
|
|
38
73
|
catch {
|
|
39
|
-
|
|
74
|
+
const sanitized = sanitizeJsonCandidate(slice);
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(sanitized);
|
|
77
|
+
return { ...parsed, raw: raw };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
40
82
|
}
|
|
41
83
|
}
|
|
42
84
|
return undefined;
|
|
@@ -51,6 +93,30 @@ const summarizeComments = (comments) => {
|
|
|
51
93
|
})
|
|
52
94
|
.join("\n");
|
|
53
95
|
};
|
|
96
|
+
const truncateSection = (label, text, limit) => {
|
|
97
|
+
if (!text)
|
|
98
|
+
return text;
|
|
99
|
+
if (text.length <= limit)
|
|
100
|
+
return text;
|
|
101
|
+
const trimmed = text.slice(0, limit);
|
|
102
|
+
const remaining = text.length - limit;
|
|
103
|
+
return `${trimmed}\n...[truncated ${remaining} chars from ${label}]`;
|
|
104
|
+
};
|
|
105
|
+
const withTimeout = async (promise, ms, label) => {
|
|
106
|
+
let timeoutId;
|
|
107
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
108
|
+
timeoutId = setTimeout(() => {
|
|
109
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
110
|
+
}, ms);
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
if (timeoutId)
|
|
117
|
+
clearTimeout(timeoutId);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
54
120
|
const JSON_CONTRACT = `{
|
|
55
121
|
"decision": "approve | changes_requested | block | info_only",
|
|
56
122
|
"summary": "short textual summary",
|
|
@@ -64,28 +130,116 @@ const JSON_CONTRACT = `{
|
|
|
64
130
|
"suggestedFix": "Optional suggested change"
|
|
65
131
|
}
|
|
66
132
|
],
|
|
67
|
-
"testRecommendations": ["Optional test or QA recommendations per task"]
|
|
133
|
+
"testRecommendations": ["Optional test or QA recommendations per task"],
|
|
134
|
+
"resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],
|
|
135
|
+
"unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]
|
|
68
136
|
}`;
|
|
69
137
|
const normalizeSingleLine = (value, fallback) => {
|
|
70
138
|
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
|
|
71
139
|
return trimmed || fallback;
|
|
72
140
|
};
|
|
141
|
+
const normalizeSlugList = (input) => {
|
|
142
|
+
if (!Array.isArray(input))
|
|
143
|
+
return [];
|
|
144
|
+
const cleaned = new Set();
|
|
145
|
+
for (const slug of input) {
|
|
146
|
+
if (typeof slug !== "string")
|
|
147
|
+
continue;
|
|
148
|
+
const trimmed = slug.trim();
|
|
149
|
+
if (trimmed)
|
|
150
|
+
cleaned.add(trimmed);
|
|
151
|
+
}
|
|
152
|
+
return Array.from(cleaned);
|
|
153
|
+
};
|
|
154
|
+
const normalizePath = (value) => value
|
|
155
|
+
.replace(/\\/g, "/")
|
|
156
|
+
.replace(/^\.\//, "")
|
|
157
|
+
.replace(/^\/+/, "");
|
|
158
|
+
const parseCommentBody = (body) => {
|
|
159
|
+
const trimmed = (body ?? "").trim();
|
|
160
|
+
if (!trimmed)
|
|
161
|
+
return { message: "(no details provided)" };
|
|
162
|
+
const lines = trimmed.split(/\r?\n/);
|
|
163
|
+
const normalize = (value) => value.trim().toLowerCase();
|
|
164
|
+
const messageIndex = lines.findIndex((line) => normalize(line) === "message:");
|
|
165
|
+
const suggestedIndex = lines.findIndex((line) => {
|
|
166
|
+
const normalized = normalize(line);
|
|
167
|
+
return normalized === "suggested_fix:" || normalized === "suggested fix:";
|
|
168
|
+
});
|
|
169
|
+
if (messageIndex >= 0) {
|
|
170
|
+
const messageLines = lines.slice(messageIndex + 1, suggestedIndex >= 0 ? suggestedIndex : undefined);
|
|
171
|
+
const message = messageLines.join("\n").trim();
|
|
172
|
+
const suggestedLines = suggestedIndex >= 0 ? lines.slice(suggestedIndex + 1) : [];
|
|
173
|
+
const suggestedFix = suggestedLines.join("\n").trim();
|
|
174
|
+
return { message: message || trimmed, suggestedFix: suggestedFix || undefined };
|
|
175
|
+
}
|
|
176
|
+
if (suggestedIndex >= 0) {
|
|
177
|
+
const message = lines.slice(0, suggestedIndex).join("\n").trim() || trimmed;
|
|
178
|
+
const inlineFix = lines[suggestedIndex]?.split(/suggested fix:/i)[1]?.trim();
|
|
179
|
+
const suggestedTail = lines.slice(suggestedIndex + 1).join("\n").trim();
|
|
180
|
+
const suggestedFix = inlineFix || suggestedTail || undefined;
|
|
181
|
+
return { message, suggestedFix };
|
|
182
|
+
}
|
|
183
|
+
return { message: trimmed };
|
|
184
|
+
};
|
|
185
|
+
const buildCommentBacklog = (comments) => {
|
|
186
|
+
if (!comments.length)
|
|
187
|
+
return "";
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const lines = [];
|
|
190
|
+
const toSingleLine = (value) => value.replace(/\s+/g, " ").trim();
|
|
191
|
+
for (const comment of comments) {
|
|
192
|
+
const slug = comment.slug?.trim() || undefined;
|
|
193
|
+
const details = parseCommentBody(comment.body);
|
|
194
|
+
const key = slug ??
|
|
195
|
+
`${comment.sourceCommand}:${comment.file ?? ""}:${comment.line ?? ""}:${details.message || comment.body}`;
|
|
196
|
+
if (seen.has(key))
|
|
197
|
+
continue;
|
|
198
|
+
seen.add(key);
|
|
199
|
+
const location = comment.file
|
|
200
|
+
? `${comment.file}${typeof comment.line === "number" ? `:${comment.line}` : ""}`
|
|
201
|
+
: "(location not specified)";
|
|
202
|
+
const message = toSingleLine(details.message || comment.body || "(no details provided)");
|
|
203
|
+
lines.push(`- [${slug ?? "untracked"}] ${location} ${message}`);
|
|
204
|
+
const suggestedFix = comment.metadata?.suggestedFix ?? details.suggestedFix ?? undefined;
|
|
205
|
+
if (suggestedFix) {
|
|
206
|
+
lines.push(` Suggested fix: ${toSingleLine(suggestedFix)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return lines.join("\n");
|
|
210
|
+
};
|
|
211
|
+
const formatSlugList = (slugs, limit = 12) => {
|
|
212
|
+
if (!slugs.length)
|
|
213
|
+
return "none";
|
|
214
|
+
if (slugs.length <= limit)
|
|
215
|
+
return slugs.join(", ");
|
|
216
|
+
return `${slugs.slice(0, limit).join(", ")} (+${slugs.length - limit} more)`;
|
|
217
|
+
};
|
|
73
218
|
const buildStandardReviewComment = (params) => {
|
|
74
219
|
const decision = params.decision ?? (params.error ? "error" : "info_only");
|
|
75
220
|
const statusAfter = params.statusAfter ?? params.statusBefore;
|
|
76
221
|
const summary = normalizeSingleLine(params.summary, params.error ? "Review failed." : "No summary provided.");
|
|
77
222
|
const error = normalizeSingleLine(params.error, "none");
|
|
78
223
|
const followups = params.followupTaskKeys && params.followupTaskKeys.length ? params.followupTaskKeys.join(", ") : "none";
|
|
79
|
-
|
|
224
|
+
const lines = [
|
|
80
225
|
"[code-review]",
|
|
81
226
|
`decision: ${decision}`,
|
|
82
227
|
`status_before: ${params.statusBefore}`,
|
|
83
228
|
`status_after: ${statusAfter}`,
|
|
84
229
|
`findings: ${params.findingsCount}`,
|
|
85
230
|
`summary: ${summary}`,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
231
|
+
];
|
|
232
|
+
if (typeof params.resolvedCount === "number") {
|
|
233
|
+
lines.push(`resolved_slugs: ${params.resolvedCount}`);
|
|
234
|
+
}
|
|
235
|
+
if (typeof params.reopenedCount === "number") {
|
|
236
|
+
lines.push(`reopened_slugs: ${params.reopenedCount}`);
|
|
237
|
+
}
|
|
238
|
+
if (typeof params.openCount === "number") {
|
|
239
|
+
lines.push(`open_slugs: ${params.openCount}`);
|
|
240
|
+
}
|
|
241
|
+
lines.push(`followups: ${followups}`, `error: ${error}`);
|
|
242
|
+
return lines.join("\n");
|
|
89
243
|
};
|
|
90
244
|
export class CodeReviewService {
|
|
91
245
|
constructor(workspace, deps) {
|
|
@@ -96,6 +250,7 @@ export class CodeReviewService {
|
|
|
96
250
|
this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
|
|
97
251
|
this.vcs = deps.vcsClient ?? new VcsClient();
|
|
98
252
|
this.routingService = deps.routingService;
|
|
253
|
+
this.ratingService = deps.ratingService;
|
|
99
254
|
}
|
|
100
255
|
static async create(workspace) {
|
|
101
256
|
const repo = await GlobalRepository.create();
|
|
@@ -242,6 +397,26 @@ export class CodeReviewService {
|
|
|
242
397
|
});
|
|
243
398
|
return resolved.agent;
|
|
244
399
|
}
|
|
400
|
+
ensureRatingService() {
|
|
401
|
+
if (!this.ratingService) {
|
|
402
|
+
this.ratingService = new AgentRatingService(this.workspace, {
|
|
403
|
+
workspaceRepo: this.deps.workspaceRepo,
|
|
404
|
+
globalRepo: this.deps.repo,
|
|
405
|
+
agentService: this.deps.agentService,
|
|
406
|
+
routingService: this.routingService,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return this.ratingService;
|
|
410
|
+
}
|
|
411
|
+
resolveTaskComplexity(task) {
|
|
412
|
+
const metadata = task.metadata ?? {};
|
|
413
|
+
const metaComplexity = typeof metadata.complexity === "number" && Number.isFinite(metadata.complexity) ? metadata.complexity : undefined;
|
|
414
|
+
const storyPoints = typeof task.storyPoints === "number" && Number.isFinite(task.storyPoints) ? task.storyPoints : undefined;
|
|
415
|
+
const candidate = metaComplexity ?? storyPoints;
|
|
416
|
+
if (!Number.isFinite(candidate ?? NaN))
|
|
417
|
+
return undefined;
|
|
418
|
+
return Math.min(10, Math.max(1, Math.round(candidate)));
|
|
419
|
+
}
|
|
245
420
|
async selectTasksViaApi(filters) {
|
|
246
421
|
// Prefer the backlog/task OpenAPI surface (via BacklogService) to mirror API filtering semantics.
|
|
247
422
|
const backlog = await BacklogService.create(this.workspace);
|
|
@@ -309,12 +484,13 @@ export class CodeReviewService {
|
|
|
309
484
|
const snippets = [];
|
|
310
485
|
const warnings = [];
|
|
311
486
|
const queries = [...new Set([...(paths.length ? this.componentHintsFromPaths(paths) : []), taskTitle, ...(acceptance ?? [])])].slice(0, 8);
|
|
487
|
+
let reindexed = false;
|
|
312
488
|
for (const query of queries) {
|
|
313
489
|
try {
|
|
314
|
-
const docs = await this.deps.docdex.search({
|
|
490
|
+
const docs = await withTimeout(this.deps.docdex.search({
|
|
315
491
|
query,
|
|
316
492
|
profile: "workspace-code",
|
|
317
|
-
});
|
|
493
|
+
}), DOCDEX_TIMEOUT_MS, `docdex search for "${query}"`);
|
|
318
494
|
snippets.push(...docs.slice(0, 2).map((doc) => {
|
|
319
495
|
const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
|
|
320
496
|
const ref = doc.path ?? doc.id ?? doc.title ?? query;
|
|
@@ -322,6 +498,26 @@ export class CodeReviewService {
|
|
|
322
498
|
}));
|
|
323
499
|
}
|
|
324
500
|
catch (error) {
|
|
501
|
+
if (!reindexed && typeof this.deps.docdex.reindex === "function") {
|
|
502
|
+
reindexed = true;
|
|
503
|
+
try {
|
|
504
|
+
await this.deps.docdex.reindex();
|
|
505
|
+
const docs = await withTimeout(this.deps.docdex.search({
|
|
506
|
+
query,
|
|
507
|
+
profile: "workspace-code",
|
|
508
|
+
}), DOCDEX_TIMEOUT_MS, `docdex search for "${query}" after reindex`);
|
|
509
|
+
snippets.push(...docs.slice(0, 2).map((doc) => {
|
|
510
|
+
const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
|
|
511
|
+
const ref = doc.path ?? doc.id ?? doc.title ?? query;
|
|
512
|
+
return `- [${doc.docType ?? "doc"}] ${ref}: ${content}`;
|
|
513
|
+
}));
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
catch (retryError) {
|
|
517
|
+
warnings.push(`docdex search failed after reindex for ${query}: ${retryError.message}`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
325
521
|
warnings.push(`docdex search failed for ${query}: ${error.message}`);
|
|
326
522
|
}
|
|
327
523
|
}
|
|
@@ -333,6 +529,16 @@ export class CodeReviewService {
|
|
|
333
529
|
parts.push(params.systemPrompts.join("\n\n"));
|
|
334
530
|
}
|
|
335
531
|
const acceptance = params.task.acceptanceCriteria && params.task.acceptanceCriteria.length ? params.task.acceptanceCriteria.join(" | ") : "none provided";
|
|
532
|
+
const historySummary = truncateSection("history", params.historySummary, REVIEW_PROMPT_LIMITS.history);
|
|
533
|
+
const commentBacklog = params.commentBacklog
|
|
534
|
+
? truncateSection("comment backlog", params.commentBacklog, REVIEW_PROMPT_LIMITS.history)
|
|
535
|
+
: "";
|
|
536
|
+
const docContextText = params.docContext.length ? truncateSection("doc context", params.docContext.join("\n"), REVIEW_PROMPT_LIMITS.docContext) : "";
|
|
537
|
+
const openapiSnippet = params.openapiSnippet ? truncateSection("openapi", params.openapiSnippet, REVIEW_PROMPT_LIMITS.openapi) : undefined;
|
|
538
|
+
const checklistsText = params.checklists?.length
|
|
539
|
+
? truncateSection("checklists", params.checklists.join("\n\n"), REVIEW_PROMPT_LIMITS.checklist)
|
|
540
|
+
: "";
|
|
541
|
+
const diffText = truncateSection("diff", params.diff || "(no diff)", REVIEW_PROMPT_LIMITS.diff);
|
|
336
542
|
parts.push([
|
|
337
543
|
`Task ${params.task.key}: ${params.task.title}`,
|
|
338
544
|
`Epic: ${params.task.epicKey ?? ""} ${params.task.epicTitle ?? ""}`.trim(),
|
|
@@ -341,16 +547,17 @@ export class CodeReviewService {
|
|
|
341
547
|
`Story description: ${params.task.storyDescription ? params.task.storyDescription : "none"}`,
|
|
342
548
|
`Status: ${params.task.status}, Branch: ${params.branch ?? params.task.vcsBranch ?? "n/a"} (base ${params.baseRef})`,
|
|
343
549
|
`Task description: ${params.task.description ? params.task.description : "none"}`,
|
|
344
|
-
`History:\n${
|
|
550
|
+
`History:\n${historySummary}`,
|
|
551
|
+
commentBacklog ? `Comment backlog (unresolved slugs):\n${commentBacklog}` : "Comment backlog: none",
|
|
345
552
|
`Acceptance criteria: ${acceptance}`,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${
|
|
553
|
+
docContextText ? `Doc context (docdex excerpts):\n${docContextText}` : "Doc context: none",
|
|
554
|
+
openapiSnippet
|
|
555
|
+
? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${openapiSnippet}`
|
|
349
556
|
: "OpenAPI: not provided; avoid inventing endpoints.",
|
|
350
|
-
|
|
351
|
-
"Diff:\n" +
|
|
557
|
+
checklistsText ? `Review checklists/runbook:\n${checklistsText}` : "Checklists: none",
|
|
558
|
+
"Diff:\n" + diffText,
|
|
352
559
|
"Respond with STRICT JSON only, matching:\n" + JSON_CONTRACT,
|
|
353
|
-
"Rules: honor OpenAPI contracts; cite doc context where relevant; do not add prose outside JSON.",
|
|
560
|
+
"Rules: honor OpenAPI contracts; cite doc context where relevant; include resolvedSlugs/unresolvedSlugs for comment backlog items; do not add prose outside JSON.",
|
|
354
561
|
].join("\n"));
|
|
355
562
|
return parts.join("\n\n");
|
|
356
563
|
}
|
|
@@ -381,6 +588,190 @@ export class CodeReviewService {
|
|
|
381
588
|
return "No prior review or QA history.";
|
|
382
589
|
return parts.join("\n");
|
|
383
590
|
}
|
|
591
|
+
async loadCommentContext(taskId) {
|
|
592
|
+
const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
|
|
593
|
+
sourceCommands: ["code-review", "qa-tasks"],
|
|
594
|
+
limit: 50,
|
|
595
|
+
});
|
|
596
|
+
const unresolved = comments.filter((comment) => !comment.resolvedAt);
|
|
597
|
+
return { comments, unresolved };
|
|
598
|
+
}
|
|
599
|
+
commentSlugKey(file, line, category) {
|
|
600
|
+
if (!file)
|
|
601
|
+
return undefined;
|
|
602
|
+
const normalizedFile = normalizePath(file);
|
|
603
|
+
const linePart = typeof line === "number" ? String(line) : "";
|
|
604
|
+
const categoryPart = category?.toLowerCase() ?? "";
|
|
605
|
+
return `${normalizedFile}|${linePart}|${categoryPart}`;
|
|
606
|
+
}
|
|
607
|
+
buildCommentSlugIndex(comments) {
|
|
608
|
+
const index = new Map();
|
|
609
|
+
for (const comment of comments) {
|
|
610
|
+
if (!comment.slug)
|
|
611
|
+
continue;
|
|
612
|
+
const key = this.commentSlugKey(comment.file, comment.line, comment.category);
|
|
613
|
+
if (!key)
|
|
614
|
+
continue;
|
|
615
|
+
if (!index.has(key))
|
|
616
|
+
index.set(key, comment.slug);
|
|
617
|
+
}
|
|
618
|
+
return index;
|
|
619
|
+
}
|
|
620
|
+
resolveFindingSlug(finding, slugIndex) {
|
|
621
|
+
const key = this.commentSlugKey(finding.file, finding.line, finding.type ?? null);
|
|
622
|
+
const existing = key ? slugIndex.get(key) : undefined;
|
|
623
|
+
if (existing)
|
|
624
|
+
return existing;
|
|
625
|
+
const message = (finding.message ?? "").trim() || "Review finding.";
|
|
626
|
+
return createTaskCommentSlug({
|
|
627
|
+
source: "code-review",
|
|
628
|
+
message,
|
|
629
|
+
file: finding.file,
|
|
630
|
+
line: finding.line,
|
|
631
|
+
category: finding.type ?? null,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
async applyCommentResolutions(params) {
|
|
635
|
+
const existingBySlug = new Map();
|
|
636
|
+
const openBySlug = new Set();
|
|
637
|
+
const resolvedBySlug = new Set();
|
|
638
|
+
for (const comment of params.existingComments) {
|
|
639
|
+
if (!comment.slug)
|
|
640
|
+
continue;
|
|
641
|
+
if (!existingBySlug.has(comment.slug)) {
|
|
642
|
+
existingBySlug.set(comment.slug, comment);
|
|
643
|
+
}
|
|
644
|
+
if (comment.resolvedAt) {
|
|
645
|
+
resolvedBySlug.add(comment.slug);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
openBySlug.add(comment.slug);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const reviewSlugIndex = this.buildCommentSlugIndex(params.existingComments.filter((comment) => comment.sourceCommand === "code-review"));
|
|
652
|
+
const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined);
|
|
653
|
+
const resolvedSet = new Set(resolvedSlugs);
|
|
654
|
+
const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined));
|
|
655
|
+
const findingSlugs = [];
|
|
656
|
+
for (const finding of params.findings ?? []) {
|
|
657
|
+
const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
|
|
658
|
+
findingSlugs.push(slug);
|
|
659
|
+
const severity = (finding.severity ?? "").toLowerCase();
|
|
660
|
+
const autoResolve = (params.decision === "approve" || params.decision === "info_only") &&
|
|
661
|
+
["info", "low"].includes(severity);
|
|
662
|
+
if (!resolvedSet.has(slug) && !autoResolve) {
|
|
663
|
+
unresolvedSet.add(slug);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
for (const slug of resolvedSet) {
|
|
667
|
+
unresolvedSet.delete(slug);
|
|
668
|
+
}
|
|
669
|
+
const toResolve = resolvedSlugs.filter((slug) => openBySlug.has(slug));
|
|
670
|
+
const toReopen = Array.from(unresolvedSet).filter((slug) => resolvedBySlug.has(slug));
|
|
671
|
+
for (const slug of toResolve) {
|
|
672
|
+
await this.deps.workspaceRepo.resolveTaskComment({
|
|
673
|
+
taskId: params.task.id,
|
|
674
|
+
slug,
|
|
675
|
+
resolvedAt: new Date().toISOString(),
|
|
676
|
+
resolvedBy: params.agentId,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
for (const slug of toReopen) {
|
|
680
|
+
await this.deps.workspaceRepo.reopenTaskComment({ taskId: params.task.id, slug });
|
|
681
|
+
}
|
|
682
|
+
const createdSlugs = new Set();
|
|
683
|
+
for (const finding of params.findings ?? []) {
|
|
684
|
+
const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
|
|
685
|
+
if (existingBySlug.has(slug) || createdSlugs.has(slug))
|
|
686
|
+
continue;
|
|
687
|
+
const severity = (finding.severity ?? "").toLowerCase();
|
|
688
|
+
const autoResolve = (params.decision === "approve" || params.decision === "info_only") &&
|
|
689
|
+
["info", "low"].includes(severity);
|
|
690
|
+
const message = (finding.message ?? "").trim() || "(no details provided)";
|
|
691
|
+
const body = formatTaskCommentBody({
|
|
692
|
+
slug,
|
|
693
|
+
source: "code-review",
|
|
694
|
+
message,
|
|
695
|
+
status: autoResolve ? "resolved" : "open",
|
|
696
|
+
category: finding.type ?? "other",
|
|
697
|
+
file: finding.file ?? null,
|
|
698
|
+
line: finding.line ?? null,
|
|
699
|
+
suggestedFix: finding.suggestedFix ?? null,
|
|
700
|
+
});
|
|
701
|
+
const resolvedAt = autoResolve ? new Date().toISOString() : undefined;
|
|
702
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
703
|
+
taskId: params.task.id,
|
|
704
|
+
taskRunId: params.taskRunId,
|
|
705
|
+
jobId: params.jobId,
|
|
706
|
+
sourceCommand: "code-review",
|
|
707
|
+
authorType: "agent",
|
|
708
|
+
authorAgentId: params.agentId,
|
|
709
|
+
category: finding.type ?? "other",
|
|
710
|
+
slug,
|
|
711
|
+
status: autoResolve ? "resolved" : "open",
|
|
712
|
+
file: finding.file ?? null,
|
|
713
|
+
line: finding.line ?? null,
|
|
714
|
+
pathHint: finding.file ?? null,
|
|
715
|
+
body,
|
|
716
|
+
resolvedAt,
|
|
717
|
+
resolvedBy: autoResolve ? params.agentId : undefined,
|
|
718
|
+
metadata: {
|
|
719
|
+
severity: finding.severity,
|
|
720
|
+
suggestedFix: finding.suggestedFix,
|
|
721
|
+
},
|
|
722
|
+
createdAt: new Date().toISOString(),
|
|
723
|
+
});
|
|
724
|
+
createdSlugs.add(slug);
|
|
725
|
+
}
|
|
726
|
+
const openSet = new Set(openBySlug);
|
|
727
|
+
for (const slug of unresolvedSet) {
|
|
728
|
+
openSet.add(slug);
|
|
729
|
+
}
|
|
730
|
+
for (const slug of resolvedSet) {
|
|
731
|
+
openSet.delete(slug);
|
|
732
|
+
}
|
|
733
|
+
if (resolvedSlugs.length || toReopen.length || unresolvedSet.size) {
|
|
734
|
+
const resolutionMessage = [
|
|
735
|
+
`Resolved slugs: ${formatSlugList(toResolve)}`,
|
|
736
|
+
`Reopened slugs: ${formatSlugList(toReopen)}`,
|
|
737
|
+
`Open slugs: ${formatSlugList(Array.from(openSet))}`,
|
|
738
|
+
].join("\n");
|
|
739
|
+
const resolutionSlug = createTaskCommentSlug({
|
|
740
|
+
source: "code-review",
|
|
741
|
+
message: resolutionMessage,
|
|
742
|
+
category: "comment_resolution",
|
|
743
|
+
});
|
|
744
|
+
const resolutionBody = formatTaskCommentBody({
|
|
745
|
+
slug: resolutionSlug,
|
|
746
|
+
source: "code-review",
|
|
747
|
+
message: resolutionMessage,
|
|
748
|
+
status: "resolved",
|
|
749
|
+
category: "comment_resolution",
|
|
750
|
+
});
|
|
751
|
+
const createdAt = new Date().toISOString();
|
|
752
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
753
|
+
taskId: params.task.id,
|
|
754
|
+
taskRunId: params.taskRunId,
|
|
755
|
+
jobId: params.jobId,
|
|
756
|
+
sourceCommand: "code-review",
|
|
757
|
+
authorType: "agent",
|
|
758
|
+
authorAgentId: params.agentId,
|
|
759
|
+
category: "comment_resolution",
|
|
760
|
+
slug: resolutionSlug,
|
|
761
|
+
status: "resolved",
|
|
762
|
+
body: resolutionBody,
|
|
763
|
+
createdAt,
|
|
764
|
+
resolvedAt: createdAt,
|
|
765
|
+
resolvedBy: params.agentId,
|
|
766
|
+
metadata: {
|
|
767
|
+
resolvedSlugs: toResolve,
|
|
768
|
+
reopenedSlugs: toReopen,
|
|
769
|
+
openSlugs: Array.from(openSet),
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
return { resolved: toResolve, reopened: toReopen, open: Array.from(openSet) };
|
|
774
|
+
}
|
|
384
775
|
extractPathsFromDiff(diff) {
|
|
385
776
|
const regex = /^(?:\+\+\+ b\/|\-\-\- a\/)([^\s]+)$/gm;
|
|
386
777
|
const paths = new Set();
|
|
@@ -473,6 +864,9 @@ export class CodeReviewService {
|
|
|
473
864
|
summary: params.summary,
|
|
474
865
|
followupTaskKeys: params.followupTaskKeys,
|
|
475
866
|
error: params.error,
|
|
867
|
+
resolvedCount: params.resolvedCount,
|
|
868
|
+
reopenedCount: params.reopenedCount,
|
|
869
|
+
openCount: params.openCount,
|
|
476
870
|
});
|
|
477
871
|
await this.deps.workspaceRepo.createTaskComment({
|
|
478
872
|
taskId: params.task.id,
|
|
@@ -703,11 +1097,29 @@ export class CodeReviewService {
|
|
|
703
1097
|
if (!selectedTaskIds.length) {
|
|
704
1098
|
throw new Error("Resume requested but no task selection found in job payload");
|
|
705
1099
|
}
|
|
1100
|
+
selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
|
|
1101
|
+
const terminalStatuses = new Set(["completed", "cancelled"]);
|
|
1102
|
+
const terminalTasks = selectedTasks.filter((task) => terminalStatuses.has((task.status ?? "").toLowerCase()));
|
|
1103
|
+
if (terminalTasks.length) {
|
|
1104
|
+
const terminalIds = new Set(terminalTasks.map((task) => task.id));
|
|
1105
|
+
const terminalKeys = terminalTasks.map((task) => task.key);
|
|
1106
|
+
warnings.push(`Skipping terminal tasks on resume: ${terminalKeys.join(", ")}`);
|
|
1107
|
+
selectedTasks = selectedTasks.filter((task) => !terminalIds.has(task.id));
|
|
1108
|
+
selectedTaskIds = selectedTaskIds.filter((id) => !terminalIds.has(id));
|
|
1109
|
+
if (state) {
|
|
1110
|
+
state.selectedTaskIds = selectedTaskIds;
|
|
1111
|
+
await this.persistState(job.id, state);
|
|
1112
|
+
}
|
|
1113
|
+
await this.writeCheckpoint(job.id, "resume_filtered", {
|
|
1114
|
+
skippedTaskKeys: terminalKeys,
|
|
1115
|
+
selectedTaskIds,
|
|
1116
|
+
schema_version: 1,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
706
1119
|
await this.deps.jobService.updateJobStatus(job.id, "running", {
|
|
707
|
-
totalItems:
|
|
1120
|
+
totalItems: selectedTaskIds.length,
|
|
708
1121
|
processedItems: state?.reviewed.length ?? 0,
|
|
709
1122
|
});
|
|
710
|
-
selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
|
|
711
1123
|
}
|
|
712
1124
|
else {
|
|
713
1125
|
try {
|
|
@@ -785,9 +1197,77 @@ export class CodeReviewService {
|
|
|
785
1197
|
const agent = await this.resolveAgent(request.agentName);
|
|
786
1198
|
const prompts = await this.loadPrompts(agent.id);
|
|
787
1199
|
const extras = await this.loadRunbookAndChecklists();
|
|
788
|
-
const
|
|
1200
|
+
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
|
|
1201
|
+
if (projectGuidance) {
|
|
1202
|
+
console.info(`[code-review] loaded project guidance from ${projectGuidance.source}`);
|
|
1203
|
+
}
|
|
1204
|
+
const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
|
|
1205
|
+
const systemPrompts = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, ...extras].filter(Boolean);
|
|
1206
|
+
const abortSignal = request.abortSignal;
|
|
1207
|
+
const resolveAbortReason = () => {
|
|
1208
|
+
const reason = abortSignal?.reason;
|
|
1209
|
+
if (typeof reason === "string" && reason.trim().length > 0)
|
|
1210
|
+
return reason;
|
|
1211
|
+
if (reason instanceof Error && reason.message)
|
|
1212
|
+
return reason.message;
|
|
1213
|
+
return "code_review_aborted";
|
|
1214
|
+
};
|
|
1215
|
+
const abortIfSignaled = () => {
|
|
1216
|
+
if (abortSignal?.aborted) {
|
|
1217
|
+
throw new Error(resolveAbortReason());
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
const withAbort = async (promise) => {
|
|
1221
|
+
if (!abortSignal)
|
|
1222
|
+
return promise;
|
|
1223
|
+
if (abortSignal.aborted) {
|
|
1224
|
+
throw new Error(resolveAbortReason());
|
|
1225
|
+
}
|
|
1226
|
+
return await new Promise((resolve, reject) => {
|
|
1227
|
+
const onAbort = () => reject(new Error(resolveAbortReason()));
|
|
1228
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
1229
|
+
promise.then(resolve, reject).finally(() => {
|
|
1230
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
};
|
|
789
1234
|
const results = [];
|
|
1235
|
+
const maybeRateTask = async (task, taskRunId, tokensTotal) => {
|
|
1236
|
+
if (!request.rateAgents || tokensTotal <= 0)
|
|
1237
|
+
return;
|
|
1238
|
+
try {
|
|
1239
|
+
const ratingService = this.ensureRatingService();
|
|
1240
|
+
await ratingService.rate({
|
|
1241
|
+
workspace: this.workspace,
|
|
1242
|
+
agentId: agent.id,
|
|
1243
|
+
commandName: "code-review",
|
|
1244
|
+
jobId,
|
|
1245
|
+
commandRunId: commandRun.id,
|
|
1246
|
+
taskId: task.id,
|
|
1247
|
+
taskKey: task.key,
|
|
1248
|
+
discipline: task.type ?? undefined,
|
|
1249
|
+
complexity: this.resolveTaskComplexity(task),
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
catch (error) {
|
|
1253
|
+
const message = `Agent rating failed for ${task.key}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1254
|
+
warnings.push(message);
|
|
1255
|
+
try {
|
|
1256
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1257
|
+
taskRunId,
|
|
1258
|
+
sequence: this.sequenceForTask(taskRunId),
|
|
1259
|
+
timestamp: new Date().toISOString(),
|
|
1260
|
+
source: "rating",
|
|
1261
|
+
message,
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
/* ignore rating log failures */
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
790
1269
|
for (const task of tasks) {
|
|
1270
|
+
abortIfSignaled();
|
|
791
1271
|
const statusBefore = task.status;
|
|
792
1272
|
const taskRun = await this.deps.workspaceRepo.createTaskRun({
|
|
793
1273
|
taskId: task.id,
|
|
@@ -806,8 +1286,10 @@ export class CodeReviewService {
|
|
|
806
1286
|
let decision;
|
|
807
1287
|
let statusAfter;
|
|
808
1288
|
const followupCreated = [];
|
|
1289
|
+
let commentResolution;
|
|
809
1290
|
// Debug visibility: show prompts/task details for this run
|
|
810
1291
|
const systemPrompt = systemPrompts.join("\n\n");
|
|
1292
|
+
let tokensTotal = 0;
|
|
811
1293
|
try {
|
|
812
1294
|
const metadata = task.metadata ?? {};
|
|
813
1295
|
const allowedFiles = Array.isArray(metadata.files) ? metadata.files : [];
|
|
@@ -830,7 +1312,57 @@ export class CodeReviewService {
|
|
|
830
1312
|
allowedFiles,
|
|
831
1313
|
},
|
|
832
1314
|
});
|
|
1315
|
+
if (!diff.trim()) {
|
|
1316
|
+
const message = "Review diff is empty; blocking review until changes are produced.";
|
|
1317
|
+
warnings.push(`Empty diff for ${task.key}; blocking review.`);
|
|
1318
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1319
|
+
taskRunId: taskRun.id,
|
|
1320
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1321
|
+
timestamp: new Date().toISOString(),
|
|
1322
|
+
source: "review_warning",
|
|
1323
|
+
message,
|
|
1324
|
+
});
|
|
1325
|
+
if (!request.dryRun) {
|
|
1326
|
+
await this.stateService.markBlocked(task, "review_empty_diff");
|
|
1327
|
+
statusAfter = "blocked";
|
|
1328
|
+
}
|
|
1329
|
+
await this.writeReviewSummaryComment({
|
|
1330
|
+
task,
|
|
1331
|
+
taskRunId: taskRun.id,
|
|
1332
|
+
jobId,
|
|
1333
|
+
agentId: agent.id,
|
|
1334
|
+
statusBefore,
|
|
1335
|
+
statusAfter: statusAfter ?? statusBefore,
|
|
1336
|
+
decision: "block",
|
|
1337
|
+
summary: message,
|
|
1338
|
+
findingsCount: 0,
|
|
1339
|
+
});
|
|
1340
|
+
await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
|
|
1341
|
+
status: "failed",
|
|
1342
|
+
finishedAt: new Date().toISOString(),
|
|
1343
|
+
runContext: { decision: "block", reason: "empty_diff" },
|
|
1344
|
+
});
|
|
1345
|
+
state?.reviewed.push({ taskId: task.id, decision: "block" });
|
|
1346
|
+
await this.persistState(jobId, state);
|
|
1347
|
+
await this.writeCheckpoint(jobId, "review_applied", { reviewed: state?.reviewed ?? [], schema_version: 1 });
|
|
1348
|
+
results.push({
|
|
1349
|
+
taskId: task.id,
|
|
1350
|
+
taskKey: task.key,
|
|
1351
|
+
statusBefore,
|
|
1352
|
+
statusAfter: statusAfter ?? statusBefore,
|
|
1353
|
+
decision: "block",
|
|
1354
|
+
findings,
|
|
1355
|
+
followupTasks: followupCreated,
|
|
1356
|
+
});
|
|
1357
|
+
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1358
|
+
processedItems: state?.reviewed.length ?? 0,
|
|
1359
|
+
});
|
|
1360
|
+
await maybeRateTask(task, taskRun.id, tokensTotal);
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
833
1363
|
const historySummary = await this.buildHistorySummary(task.id);
|
|
1364
|
+
const commentContext = await this.loadCommentContext(task.id);
|
|
1365
|
+
const commentBacklog = buildCommentBacklog(commentContext.unresolved);
|
|
834
1366
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
835
1367
|
taskRunId: taskRun.id,
|
|
836
1368
|
sequence: this.sequenceForTask(taskRun.id),
|
|
@@ -868,6 +1400,7 @@ export class CodeReviewService {
|
|
|
868
1400
|
docContext: docLinks.snippets,
|
|
869
1401
|
openapiSnippet,
|
|
870
1402
|
historySummary,
|
|
1403
|
+
commentBacklog,
|
|
871
1404
|
baseRef: state?.baseRef ?? baseRef,
|
|
872
1405
|
branch: task.vcsBranch ?? undefined,
|
|
873
1406
|
});
|
|
@@ -892,6 +1425,7 @@ export class CodeReviewService {
|
|
|
892
1425
|
console.info(separator);
|
|
893
1426
|
await this.persistContext(jobId, task.id, {
|
|
894
1427
|
historySummary,
|
|
1428
|
+
commentBacklog,
|
|
895
1429
|
docdex: docLinks.snippets,
|
|
896
1430
|
openapiSnippet,
|
|
897
1431
|
changedPaths,
|
|
@@ -902,7 +1436,8 @@ export class CodeReviewService {
|
|
|
902
1436
|
const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta) => {
|
|
903
1437
|
const tokensPrompt = tokenMeta?.tokensPrompt ?? estimateTokens(promptText);
|
|
904
1438
|
const tokensCompletion = tokenMeta?.tokensCompletion ?? estimateTokens(outputText);
|
|
905
|
-
const
|
|
1439
|
+
const entryTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
|
|
1440
|
+
tokensTotal += entryTotal;
|
|
906
1441
|
await this.deps.jobService.recordTokenUsage({
|
|
907
1442
|
workspaceId: this.workspace.workspaceId,
|
|
908
1443
|
agentId: agent.id,
|
|
@@ -914,7 +1449,7 @@ export class CodeReviewService {
|
|
|
914
1449
|
projectId: task.projectId,
|
|
915
1450
|
tokensPrompt,
|
|
916
1451
|
tokensCompletion,
|
|
917
|
-
tokensTotal,
|
|
1452
|
+
tokensTotal: entryTotal,
|
|
918
1453
|
durationSeconds,
|
|
919
1454
|
timestamp: new Date().toISOString(),
|
|
920
1455
|
metadata: { commandName: "code-review", phase, action: phase },
|
|
@@ -925,8 +1460,13 @@ export class CodeReviewService {
|
|
|
925
1460
|
const started = Date.now();
|
|
926
1461
|
let lastStreamMeta;
|
|
927
1462
|
if (agentStream && this.deps.agentService.invokeStream) {
|
|
928
|
-
const stream = await this.deps.agentService.invokeStream(agent.id, { input: prompt, metadata: { taskKey: task.key } });
|
|
929
|
-
|
|
1463
|
+
const stream = await withAbort(this.deps.agentService.invokeStream(agent.id, { input: prompt, metadata: { taskKey: task.key } }));
|
|
1464
|
+
while (true) {
|
|
1465
|
+
abortIfSignaled();
|
|
1466
|
+
const { value, done } = await withAbort(stream.next());
|
|
1467
|
+
if (done)
|
|
1468
|
+
break;
|
|
1469
|
+
const chunk = value;
|
|
930
1470
|
agentOutput += chunk.output ?? "";
|
|
931
1471
|
lastStreamMeta = chunk.metadata ?? lastStreamMeta;
|
|
932
1472
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
@@ -940,7 +1480,7 @@ export class CodeReviewService {
|
|
|
940
1480
|
durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
|
|
941
1481
|
}
|
|
942
1482
|
else {
|
|
943
|
-
const response = await this.deps.agentService.invoke(agent.id, { input: prompt, metadata: { taskKey: task.key } });
|
|
1483
|
+
const response = await withAbort(this.deps.agentService.invoke(agent.id, { input: prompt, metadata: { taskKey: task.key } }));
|
|
944
1484
|
agentOutput = response.output ?? "";
|
|
945
1485
|
durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
|
|
946
1486
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
@@ -964,6 +1504,7 @@ export class CodeReviewService {
|
|
|
964
1504
|
: undefined;
|
|
965
1505
|
await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain);
|
|
966
1506
|
let parsed = parseJsonOutput(agentOutput);
|
|
1507
|
+
let invalidJson = false;
|
|
967
1508
|
if (!parsed) {
|
|
968
1509
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
969
1510
|
taskRunId: taskRun.id,
|
|
@@ -974,7 +1515,7 @@ export class CodeReviewService {
|
|
|
974
1515
|
});
|
|
975
1516
|
const retryPrompt = `${prompt}\n\nRespond ONLY with valid JSON matching the schema above. Do not include prose or fences.`;
|
|
976
1517
|
const retryStarted = Date.now();
|
|
977
|
-
const retryResp = await this.deps.agentService.invoke(agent.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } });
|
|
1518
|
+
const retryResp = await withAbort(this.deps.agentService.invoke(agent.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } }));
|
|
978
1519
|
const retryOutput = retryResp.output ?? "";
|
|
979
1520
|
const retryDuration = Math.round(((Date.now() - retryStarted) / 1000) * 1000) / 1000;
|
|
980
1521
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
@@ -1003,15 +1544,78 @@ export class CodeReviewService {
|
|
|
1003
1544
|
agentOutput = retryOutput;
|
|
1004
1545
|
}
|
|
1005
1546
|
if (!parsed) {
|
|
1006
|
-
|
|
1547
|
+
invalidJson = true;
|
|
1548
|
+
const fallbackSummary = "Review agent returned non-JSON output after retry; block review and re-run with a stricter JSON-only model.";
|
|
1549
|
+
warnings.push(`Review agent returned non-JSON output for ${task.key}; blocking review.`);
|
|
1550
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1551
|
+
taskRunId: taskRun.id,
|
|
1552
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1553
|
+
timestamp: new Date().toISOString(),
|
|
1554
|
+
source: "review_warning",
|
|
1555
|
+
message: fallbackSummary,
|
|
1556
|
+
});
|
|
1557
|
+
parsed = {
|
|
1558
|
+
decision: "block",
|
|
1559
|
+
summary: fallbackSummary,
|
|
1560
|
+
findings: [],
|
|
1561
|
+
testRecommendations: [],
|
|
1562
|
+
raw: agentOutput,
|
|
1563
|
+
};
|
|
1007
1564
|
}
|
|
1008
1565
|
parsed.raw = agentOutput;
|
|
1566
|
+
const originalDecision = parsed.decision;
|
|
1009
1567
|
decision = parsed.decision;
|
|
1010
1568
|
findings.push(...(parsed.findings ?? []));
|
|
1011
|
-
|
|
1569
|
+
commentResolution = await this.applyCommentResolutions({
|
|
1012
1570
|
task,
|
|
1571
|
+
taskRunId: taskRun.id,
|
|
1572
|
+
jobId,
|
|
1573
|
+
agentId: agent.id,
|
|
1013
1574
|
findings: parsed.findings ?? [],
|
|
1575
|
+
resolvedSlugs: parsed.resolvedSlugs ?? undefined,
|
|
1576
|
+
unresolvedSlugs: parsed.unresolvedSlugs ?? undefined,
|
|
1014
1577
|
decision: parsed.decision,
|
|
1578
|
+
existingComments: commentContext.comments,
|
|
1579
|
+
});
|
|
1580
|
+
let finalDecision = parsed.decision;
|
|
1581
|
+
if (commentResolution?.open?.length &&
|
|
1582
|
+
(finalDecision === "approve" || finalDecision === "info_only")) {
|
|
1583
|
+
const openSlugs = commentResolution.open;
|
|
1584
|
+
finalDecision = "changes_requested";
|
|
1585
|
+
const message = `Unresolved comment slugs remain: ${formatSlugList(openSlugs)}. Review approval requires resolving these items.`;
|
|
1586
|
+
const backlogSlug = createTaskCommentSlug({
|
|
1587
|
+
source: "code-review",
|
|
1588
|
+
message,
|
|
1589
|
+
category: "comment_backlog",
|
|
1590
|
+
});
|
|
1591
|
+
const backlogBody = formatTaskCommentBody({
|
|
1592
|
+
slug: backlogSlug,
|
|
1593
|
+
source: "code-review",
|
|
1594
|
+
message,
|
|
1595
|
+
status: "open",
|
|
1596
|
+
category: "comment_backlog",
|
|
1597
|
+
});
|
|
1598
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
1599
|
+
taskId: task.id,
|
|
1600
|
+
taskRunId: taskRun.id,
|
|
1601
|
+
jobId,
|
|
1602
|
+
sourceCommand: "code-review",
|
|
1603
|
+
authorType: "agent",
|
|
1604
|
+
authorAgentId: agent.id,
|
|
1605
|
+
category: "comment_backlog",
|
|
1606
|
+
slug: backlogSlug,
|
|
1607
|
+
status: "open",
|
|
1608
|
+
body: backlogBody,
|
|
1609
|
+
metadata: { openSlugs },
|
|
1610
|
+
createdAt: new Date().toISOString(),
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
parsed.decision = finalDecision;
|
|
1614
|
+
decision = finalDecision;
|
|
1615
|
+
const followups = await this.createFollowupTasksForFindings({
|
|
1616
|
+
task,
|
|
1617
|
+
findings: parsed.findings ?? [],
|
|
1618
|
+
decision: originalDecision,
|
|
1015
1619
|
jobId,
|
|
1016
1620
|
commandRunId: commandRun.id,
|
|
1017
1621
|
taskRunId: taskRun.id,
|
|
@@ -1028,18 +1632,25 @@ export class CodeReviewService {
|
|
|
1028
1632
|
}
|
|
1029
1633
|
let taskStatusUpdate = statusBefore;
|
|
1030
1634
|
if (!request.dryRun) {
|
|
1031
|
-
if (
|
|
1032
|
-
await this.stateService.
|
|
1033
|
-
taskStatusUpdate = "ready_to_qa";
|
|
1034
|
-
}
|
|
1035
|
-
else if (parsed.decision === "changes_requested") {
|
|
1036
|
-
await this.stateService.returnToInProgress(task);
|
|
1037
|
-
taskStatusUpdate = "in_progress";
|
|
1038
|
-
}
|
|
1039
|
-
else if (parsed.decision === "block") {
|
|
1040
|
-
await this.stateService.markBlocked(task, "review_blocked");
|
|
1635
|
+
if (invalidJson) {
|
|
1636
|
+
await this.stateService.markBlocked(task, "review_invalid_output");
|
|
1041
1637
|
taskStatusUpdate = "blocked";
|
|
1042
1638
|
}
|
|
1639
|
+
else {
|
|
1640
|
+
const approveDecision = parsed.decision === "approve" || parsed.decision === "info_only";
|
|
1641
|
+
if (approveDecision) {
|
|
1642
|
+
await this.stateService.markReadyToQa(task);
|
|
1643
|
+
taskStatusUpdate = "ready_to_qa";
|
|
1644
|
+
}
|
|
1645
|
+
else if (parsed.decision === "changes_requested") {
|
|
1646
|
+
await this.stateService.returnToInProgress(task);
|
|
1647
|
+
taskStatusUpdate = "in_progress";
|
|
1648
|
+
}
|
|
1649
|
+
else if (parsed.decision === "block") {
|
|
1650
|
+
await this.stateService.markBlocked(task, "review_blocked");
|
|
1651
|
+
taskStatusUpdate = "blocked";
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1043
1654
|
}
|
|
1044
1655
|
else {
|
|
1045
1656
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
@@ -1052,26 +1663,6 @@ export class CodeReviewService {
|
|
|
1052
1663
|
});
|
|
1053
1664
|
}
|
|
1054
1665
|
statusAfter = taskStatusUpdate;
|
|
1055
|
-
for (const finding of parsed.findings ?? []) {
|
|
1056
|
-
await this.deps.workspaceRepo.createTaskComment({
|
|
1057
|
-
taskId: task.id,
|
|
1058
|
-
taskRunId: taskRun.id,
|
|
1059
|
-
jobId,
|
|
1060
|
-
sourceCommand: "code-review",
|
|
1061
|
-
authorType: "agent",
|
|
1062
|
-
authorAgentId: agent.id,
|
|
1063
|
-
category: finding.type ?? "other",
|
|
1064
|
-
file: finding.file,
|
|
1065
|
-
line: finding.line,
|
|
1066
|
-
pathHint: finding.file,
|
|
1067
|
-
body: finding.message + (finding.suggestedFix ? `\n\nSuggested fix: ${finding.suggestedFix}` : ""),
|
|
1068
|
-
metadata: {
|
|
1069
|
-
severity: finding.severity,
|
|
1070
|
-
suggestedFix: finding.suggestedFix,
|
|
1071
|
-
},
|
|
1072
|
-
createdAt: new Date().toISOString(),
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
1666
|
await this.writeReviewSummaryComment({
|
|
1076
1667
|
task,
|
|
1077
1668
|
taskRunId: taskRun.id,
|
|
@@ -1083,6 +1674,9 @@ export class CodeReviewService {
|
|
|
1083
1674
|
summary: parsed.summary,
|
|
1084
1675
|
findingsCount: parsed.findings?.length ?? 0,
|
|
1085
1676
|
followupTaskKeys: followupCreated.map((t) => t.taskKey),
|
|
1677
|
+
resolvedCount: commentResolution?.resolved.length,
|
|
1678
|
+
reopenedCount: commentResolution?.reopened.length,
|
|
1679
|
+
openCount: commentResolution?.open.length,
|
|
1086
1680
|
});
|
|
1087
1681
|
await this.deps.workspaceRepo.createTaskReview({
|
|
1088
1682
|
taskId: task.id,
|
|
@@ -1151,6 +1745,7 @@ export class CodeReviewService {
|
|
|
1151
1745
|
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1152
1746
|
processedItems: state?.reviewed.length ?? 0,
|
|
1153
1747
|
});
|
|
1748
|
+
await maybeRateTask(task, taskRun.id, tokensTotal);
|
|
1154
1749
|
continue;
|
|
1155
1750
|
}
|
|
1156
1751
|
results.push({
|
|
@@ -1165,6 +1760,7 @@ export class CodeReviewService {
|
|
|
1165
1760
|
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1166
1761
|
processedItems: state?.reviewed.length ?? 0,
|
|
1167
1762
|
});
|
|
1763
|
+
await maybeRateTask(task, taskRun.id, tokensTotal);
|
|
1168
1764
|
}
|
|
1169
1765
|
await this.deps.jobService.updateJobStatus(jobId, "completed", {
|
|
1170
1766
|
processedItems: state?.reviewed.length ?? selectedTaskIds.length,
|