@ddtcorex/dsh-maestro-review 0.6.0 → 0.6.2
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/lib/orchestrator.d.ts +16 -6
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +74 -23
- package/lib/orchestrator.js.map +1 -1
- package/lib/review-signals.d.ts.map +1 -1
- package/lib/review-signals.js +39 -11
- package/lib/review-signals.js.map +1 -1
- package/package.json +2 -2
- package/profiles/reviewer-ci/cordis.patch.yml +12 -1
- package/profiles/reviewer-ci/package.json +2 -1
- package/profiles/reviewer-ci/pnpm-lock.yaml +3219 -874
- package/src/host/orchestrator.ts +77 -25
- package/src/host/review-signals.ts +39 -11
- package/templates/reviewer-project.gitlab-ci.yml +1 -1
- package/templates/source-project.gitlab-ci.yml +7 -0
package/src/host/orchestrator.ts
CHANGED
|
@@ -114,7 +114,8 @@ export function buildReviewerScopePrompt(opts: ReviewerScopePromptOpts): string
|
|
|
114
114
|
if (opts.scopeKind === 'discussion') {
|
|
115
115
|
return `${opts.profileInstruction}Review only the requested inline discussion ${opts.discussionId} at ${opts.path}:${opts.line}. Do not review unrelated files or start a broad audit. Call gitlab_get_mr_diff, then gitlab_get_file_diff for the file under review, then call report_review_findings exactly once when done. ${lintRule}`
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
const deletedFilesRule = 'DELETED FILES: a deleted file\'s diff only tells you what logic is gone — never file a finding positioned on a deleted file (there is no line left to fix). If removing it drops behavior that is not replicated elsewhere, report that as a finding against the file that should carry the replacement logic (or the closest call site), not the deleted file itself.'
|
|
118
|
+
return `${opts.profileInstruction}Review this merge request (${opts.mode} mode). Call gitlab_list_own_review_threads and gitlab_get_mr_diff first, then gitlab_get_file_diff per file you inspect (inline results never spill), then call report_review_findings exactly once when done. ${lintRule} DEDUP RULE: when a finding matches the substance of an existing own thread (same file and same underlying issue, even if worded differently — including resolved threads, whose reply reopens them), report it as {status: "reply", discussionId} instead of posting a new thread. Use status "new" only for issues with no matching thread. ${deletedFilesRule}`
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
|
@@ -213,10 +214,43 @@ export function isUnsupportedReasoningError(err: unknown): boolean {
|
|
|
213
214
|
|| text.includes('UNSUPPORTED_REASONING_EFFORT')
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Read an agent session's event log across host/session API skew: hosts
|
|
219
|
+
* built from the harness checkout expose `ownEvents()`/`snapshotEvents()`
|
|
220
|
+
* with no `.events` getter, while older packaged `@deepseek-ai/dsh-session`
|
|
221
|
+
* builds expose only the `.events` getter. `ownEvents()` (child-owned
|
|
222
|
+
* suffix, no fork prefix) wins when present — it matches what most callers
|
|
223
|
+
* want (this session's own turns, not an inherited fork prefix).
|
|
224
|
+
*
|
|
225
|
+
* Live-verified 2026-09-05: a real `AgentHandle`'s `agent.session.events` is
|
|
226
|
+
* `undefined` (the property does not exist on the current pinned
|
|
227
|
+
* `@deepseek-ai/dsh-session`), which previously made `getTurnErrorMessage()`
|
|
228
|
+
* silently return `undefined` for every real turn — the exact
|
|
229
|
+
* auth/billing-failure detection PR #81/#90 exist to provide never actually
|
|
230
|
+
* ran outside of unit tests that happened to mock the same wrong shape.
|
|
231
|
+
* Returns `[]` — never throws — when no event source exists.
|
|
232
|
+
*/
|
|
233
|
+
function readSessionEvents(session: unknown): unknown[] {
|
|
234
|
+
const candidate = session as {
|
|
235
|
+
ownEvents?: unknown
|
|
236
|
+
snapshotEvents?: unknown
|
|
237
|
+
events?: unknown
|
|
238
|
+
} | null | undefined
|
|
239
|
+
let events: unknown
|
|
240
|
+
if (typeof candidate?.ownEvents === 'function') {
|
|
241
|
+
events = (candidate.ownEvents as () => unknown)()
|
|
242
|
+
} else if (typeof candidate?.snapshotEvents === 'function') {
|
|
243
|
+
events = (candidate.snapshotEvents as () => unknown)()
|
|
244
|
+
} else {
|
|
245
|
+
events = candidate?.events
|
|
246
|
+
}
|
|
247
|
+
return Array.isArray(events) ? events : []
|
|
248
|
+
}
|
|
249
|
+
|
|
216
250
|
export function getTurnErrorMessage(handle: unknown): string | undefined {
|
|
217
251
|
try {
|
|
218
|
-
const events = (handle as { agent?: { session?:
|
|
219
|
-
if (
|
|
252
|
+
const events = readSessionEvents((handle as { agent?: { session?: unknown } })?.agent?.session)
|
|
253
|
+
if (events.length === 0) return undefined
|
|
220
254
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
221
255
|
const ev = events[i] as { type?: unknown; data?: { reason?: { kind?: unknown; error?: { message?: unknown; code?: unknown } } } }
|
|
222
256
|
if (ev?.type === 'turn/end' && ev?.data?.reason?.kind === 'error') {
|
|
@@ -250,6 +284,25 @@ export function assertTurnSucceeded(handle: unknown): void {
|
|
|
250
284
|
}
|
|
251
285
|
}
|
|
252
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Like assertTurnSucceeded, but only throws when there is nothing left to
|
|
289
|
+
* salvage. A turn can error AFTER already producing real output — findings
|
|
290
|
+
* reported via the tool, or audit text written — when only the turn's own
|
|
291
|
+
* closing step fails afterward (a trailing rate-limit/auth hiccup). Throwing
|
|
292
|
+
* unconditionally there discarded already-produced, real work for no
|
|
293
|
+
* benefit; this only fails closed when the turn errored AND there is
|
|
294
|
+
* nothing usable to fall back on. Either way the turn error is never
|
|
295
|
+
* silent: logged as a warning when salvaging, thrown when not.
|
|
296
|
+
*/
|
|
297
|
+
export function assertTurnSucceededOrSalvage(handle: unknown, hasSalvageableOutput: boolean, label: string): void {
|
|
298
|
+
const turnMsg = getTurnErrorMessage(handle)
|
|
299
|
+
if (turnMsg === undefined) return
|
|
300
|
+
if (!hasSalvageableOutput) {
|
|
301
|
+
throw new Error(`Review turn failed before completing: ${turnMsg}`)
|
|
302
|
+
}
|
|
303
|
+
console.warn(`maestro-orchestrator: ${label} turn ended with an error after already producing usable output — using it anyway: ${turnMsg}`)
|
|
304
|
+
}
|
|
305
|
+
|
|
253
306
|
/**
|
|
254
307
|
* Resolve the model to use for an automated review. Priority: per-project
|
|
255
308
|
* override > global reviewModel (Maestro Settings) > row-config reviewModel
|
|
@@ -400,6 +453,7 @@ interface GitlabMrChange {
|
|
|
400
453
|
diff: string
|
|
401
454
|
collapsed?: boolean
|
|
402
455
|
too_large?: boolean
|
|
456
|
+
deleted_file?: boolean
|
|
403
457
|
}
|
|
404
458
|
|
|
405
459
|
interface GitlabDiffPosition {
|
|
@@ -504,6 +558,18 @@ export async function postReviewFindings(findings: ReviewFinding[], config: Gitl
|
|
|
504
558
|
if (change.collapsed === true || change.too_large === true || change.diff === '') {
|
|
505
559
|
throw new Error(`cannot post inline finding at ${finding.path}:${finding.line}: GitLab did not return this file's complete diff`)
|
|
506
560
|
}
|
|
561
|
+
// A deleted file has no "new" side to attach an inline comment to — post as a
|
|
562
|
+
// top-level note instead of relying on diffPositionForNewLine() incidentally
|
|
563
|
+
// returning undefined (true today since a full deletion has no +/context rows,
|
|
564
|
+
// but that's a byproduct of diff-hunk mechanics, not a designed guard).
|
|
565
|
+
if (change.deleted_file === true) {
|
|
566
|
+
response = await fetcher(`${apiBase}/notes`, {
|
|
567
|
+
method: 'POST', headers,
|
|
568
|
+
body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}\` was deleted in this MR (no line ${finding.line} to attach a comment to)**\n\n${body}` }),
|
|
569
|
+
})
|
|
570
|
+
if (!response.ok) throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
571
|
+
continue
|
|
572
|
+
}
|
|
507
573
|
const linePosition = diffPositionForNewLine(change.diff, finding.line)
|
|
508
574
|
if (linePosition === undefined) {
|
|
509
575
|
// Line not in diff — fallback to MR note so finding is not silently lost.
|
|
@@ -569,29 +635,15 @@ function assertSafeId(value: number, label: string): void {
|
|
|
569
635
|
}
|
|
570
636
|
|
|
571
637
|
/**
|
|
572
|
-
* Read an agent session's transcript for the auditor's final output
|
|
573
|
-
*
|
|
574
|
-
* `
|
|
575
|
-
*
|
|
576
|
-
* getter. `ownEvents()` (child-owned suffix, no fork prefix) matches
|
|
577
|
-
* `finalAssistantOutput`'s documented input best, so it wins when present.
|
|
638
|
+
* Read an agent session's transcript for the auditor's final output.
|
|
639
|
+
* `ownEvents()` (child-owned suffix, no fork prefix) matches
|
|
640
|
+
* `finalAssistantOutput`'s documented input best, so `readSessionEvents`
|
|
641
|
+
* preferring it over `snapshotEvents()`/`.events` suits this caller too.
|
|
578
642
|
* Returns `[]` — never throws — when no event source exists.
|
|
579
643
|
*/
|
|
580
644
|
export function auditorOutputFromSession(session: unknown) {
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
snapshotEvents?: unknown
|
|
584
|
-
events?: unknown
|
|
585
|
-
} | null | undefined
|
|
586
|
-
let events: unknown
|
|
587
|
-
if (typeof candidate?.ownEvents === 'function') {
|
|
588
|
-
events = (candidate.ownEvents as () => unknown)()
|
|
589
|
-
} else if (typeof candidate?.snapshotEvents === 'function') {
|
|
590
|
-
events = (candidate.snapshotEvents as () => unknown)()
|
|
591
|
-
} else {
|
|
592
|
-
events = candidate?.events
|
|
593
|
-
}
|
|
594
|
-
if (!Array.isArray(events)) return []
|
|
645
|
+
const events = readSessionEvents(session)
|
|
646
|
+
if (events.length === 0) return []
|
|
595
647
|
return finalAssistantOutput(events as Parameters<typeof finalAssistantOutput>[0]) ?? []
|
|
596
648
|
}
|
|
597
649
|
|
|
@@ -1097,7 +1149,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1097
1149
|
source: { kind: 'user' },
|
|
1098
1150
|
}))
|
|
1099
1151
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
1100
|
-
|
|
1152
|
+
assertTurnSucceededOrSalvage(handle, capturedFindings.length > 0, 'reviewer')
|
|
1101
1153
|
if (reviewProfile !== undefined && (reviewerContext === undefined || loadedReviewProfile(reviewerContext) !== reviewProfile)) {
|
|
1102
1154
|
throw new Error(`reviewer did not successfully load the required ${reviewProfile} review skill profile; no findings were posted`)
|
|
1103
1155
|
}
|
|
@@ -1195,9 +1247,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1195
1247
|
const prompt = buildAuditorPrompt({ staticOnly: opts?.staticOnly === true })
|
|
1196
1248
|
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }))
|
|
1197
1249
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
1198
|
-
assertTurnSucceeded(handle)
|
|
1199
1250
|
const output = auditorOutputFromSession(handle.agent.session)
|
|
1200
1251
|
const text = output.map(block => ('text' in block ? block.text : '')).join('')
|
|
1252
|
+
assertTurnSucceededOrSalvage(handle, text.trim() !== '', 'auditor')
|
|
1201
1253
|
return `## Maestro Performance Audit\n\n${text}`
|
|
1202
1254
|
} finally {
|
|
1203
1255
|
await handle.dispose()
|
|
@@ -13,26 +13,46 @@ export interface ReviewSignals {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
async function award(baseUrl: string, token: string, projectId: number, mrIid: number, name: string): Promise<void> {
|
|
16
|
-
await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
16
|
+
const response = await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
17
17
|
method: 'POST',
|
|
18
18
|
headers: { ...gitlabAuthHeaders(token), 'Content-Type': 'application/json' },
|
|
19
19
|
body: JSON.stringify({ name }),
|
|
20
20
|
})
|
|
21
|
+
// fetchWithTimeout only throws on a network/abort failure; a non-2xx status
|
|
22
|
+
// (expired token, missing scope, rate-limited) resolves normally and must
|
|
23
|
+
// be checked explicitly, or the caller's failure logging never fires.
|
|
24
|
+
if (!response.ok) throw new Error(`GitLab API error ${response.status} awarding "${name}": ${await response.text()}`)
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
27
|
+
/** Every marker name this bot ever awards — kept in one place so `unawardOwn` can always clear all of them, leaving at most one visible at a time. */
|
|
28
|
+
const MARKER_NAMES = ['eyes', 'white_check_mark', 'warning'] as const
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Remove this bot's own markers among `names`; other users' awards stay untouched.
|
|
32
|
+
* Callers pass `MARKER_NAMES` (not just "eyes") so a stale terminal marker from a
|
|
33
|
+
* prior run (e.g. `white_check_mark`) is cleared before re-awarding the same name —
|
|
34
|
+
* GitLab rejects a duplicate award of the same name by the same user with a 404
|
|
35
|
+
* ("Award Emoji Name has already been taken"), which otherwise surfaces as a
|
|
36
|
+
* confusing failure on the second consecutive completed/failed review of one MR.
|
|
37
|
+
*/
|
|
38
|
+
async function unawardOwn(baseUrl: string, token: string, projectId: number, mrIid: number, botUsername: string, names: readonly string[]): Promise<void> {
|
|
25
39
|
const response = await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
26
40
|
headers: gitlabAuthHeaders(token),
|
|
27
41
|
})
|
|
28
|
-
if (!response.ok)
|
|
42
|
+
if (!response.ok) throw new Error(`GitLab API error ${response.status} listing award emoji: ${await response.text()}`)
|
|
29
43
|
const awards = (await response.json()) as Array<{ id?: number; name?: string; user?: { username?: string } }>
|
|
30
44
|
for (const awardItem of Array.isArray(awards) ? awards : []) {
|
|
31
|
-
if (awardItem.name
|
|
32
|
-
await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji/${awardItem.id}`, {
|
|
45
|
+
if (awardItem.name === undefined || !names.includes(awardItem.name) || awardItem.user?.username !== botUsername || typeof awardItem.id !== 'number') continue
|
|
46
|
+
const deleteResponse = await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji/${awardItem.id}`, {
|
|
33
47
|
method: 'DELETE',
|
|
34
48
|
headers: gitlabAuthHeaders(token),
|
|
35
|
-
}).catch(() => {
|
|
49
|
+
}).catch((err: unknown) => {
|
|
50
|
+
console.error(`review-signals: failed to delete stale ${awardItem.name} marker ${awardItem.id} on MR !${mrIid}`, err)
|
|
51
|
+
return undefined
|
|
52
|
+
})
|
|
53
|
+
if (deleteResponse !== undefined && !deleteResponse.ok) {
|
|
54
|
+
console.error(`review-signals: GitLab API error ${deleteResponse.status} deleting stale ${awardItem.name} marker ${awardItem.id} on MR !${mrIid}`)
|
|
55
|
+
}
|
|
36
56
|
}
|
|
37
57
|
}
|
|
38
58
|
|
|
@@ -41,15 +61,23 @@ export function createReviewSignals(options: { baseUrl: string; token: string; p
|
|
|
41
61
|
return {
|
|
42
62
|
async start() {
|
|
43
63
|
try {
|
|
44
|
-
await unawardOwn(baseUrl, token, projectId, mrIid, botUsername)
|
|
64
|
+
await unawardOwn(baseUrl, token, projectId, mrIid, botUsername, MARKER_NAMES)
|
|
45
65
|
await award(baseUrl, token, projectId, mrIid, 'eyes')
|
|
46
|
-
} catch {
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Signalling must never break the review, but a swallowed failure
|
|
68
|
+
// here is exactly what leaves a stale "eyes" marker stuck on the MR
|
|
69
|
+
// forever (blocking the push-gate's 👀-running check for both the
|
|
70
|
+
// webhook and CI flows) with zero trace of why — log it.
|
|
71
|
+
console.error(`review-signals: failed to set the running marker on MR !${mrIid}`, err)
|
|
72
|
+
}
|
|
47
73
|
},
|
|
48
74
|
async finish(outcome) {
|
|
49
75
|
try {
|
|
50
|
-
await unawardOwn(baseUrl, token, projectId, mrIid, botUsername)
|
|
76
|
+
await unawardOwn(baseUrl, token, projectId, mrIid, botUsername, MARKER_NAMES)
|
|
51
77
|
await award(baseUrl, token, projectId, mrIid, outcome === 'completed' ? 'white_check_mark' : 'warning')
|
|
52
|
-
} catch {
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`review-signals: failed to clear the running marker / award the final marker on MR !${mrIid}`, err)
|
|
80
|
+
}
|
|
53
81
|
},
|
|
54
82
|
}
|
|
55
83
|
}
|
|
@@ -3,7 +3,7 @@ stages: [review]
|
|
|
3
3
|
|
|
4
4
|
variables:
|
|
5
5
|
# Image registry — pin the SHA for reproducibility
|
|
6
|
-
REVIEWER_IMAGE: "ddtcorex/maestro-reviewer:0.
|
|
6
|
+
REVIEWER_IMAGE: "ddtcorex/maestro-reviewer:0.6.2"
|
|
7
7
|
# Or use $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA when building the image in the same project
|
|
8
8
|
|
|
9
9
|
review:
|
|
@@ -4,6 +4,13 @@ stages: [review]
|
|
|
4
4
|
# Only runs when there is an MR (fork MRs run fine too — this job has no secrets)
|
|
5
5
|
maestro:trigger-review:
|
|
6
6
|
stage: review
|
|
7
|
+
# The job only fails on a reviewer-infra problem (LLM/API error, timeout,
|
|
8
|
+
# failed comment post) — it never fails because findings were reported.
|
|
9
|
+
# Non-blocking so an outage of the shared reviewer project (serving many
|
|
10
|
+
# source projects) can't block this project's merge/deploy; check the MR
|
|
11
|
+
# comment for the actual review result. Drop this line to hard-block merge
|
|
12
|
+
# on any reviewer-infra failure instead.
|
|
13
|
+
allow_failure: true
|
|
7
14
|
rules:
|
|
8
15
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
9
16
|
# No image needed, no secrets needed
|