@ddtcorex/dsh-maestro-review 0.5.1 → 0.6.1
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 +21 -0
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +36 -0
- package/lib/orchestrator.js.map +1 -1
- package/lib/review-signals.d.ts.map +1 -1
- package/lib/review-signals.js +25 -6
- package/lib/review-signals.js.map +1 -1
- package/package.json +11 -10
- package/profiles/reviewer-ci/cordis.patch.yml +12 -1
- package/profiles/reviewer-ci/package.json +5 -4
- package/profiles/reviewer-ci/pnpm-lock.yaml +7279 -22
- package/profiles/reviewer-ci/pnpm-workspace.yaml +11 -0
- package/src/host/orchestrator.ts +37 -0
- package/src/host/review-signals.ts +24 -6
- package/templates/reviewer-project.gitlab-ci.yml +15 -12
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
# printed "Scope: all 2 workspace projects" and resolved relative to `../..`
|
|
8
8
|
# without this file present).
|
|
9
9
|
packages: []
|
|
10
|
+
# Postinstall scripts required at runtime (node-pty/subprocess-local spawn
|
|
11
|
+
# agents, koffi/protobufjs/genai serve model IO). Exact versions pinned in
|
|
12
|
+
# pnpm-lock.yaml; review on every profile dep bump.
|
|
13
|
+
allowBuilds:
|
|
14
|
+
'@deepseek-ai/dsh-subprocess-local': true
|
|
15
|
+
'@google/genai': true
|
|
16
|
+
koffi: true
|
|
17
|
+
node-pty: true
|
|
18
|
+
protobufjs: true
|
|
10
19
|
minimumReleaseAgeExclude:
|
|
11
20
|
- '@ddtcorex/dsh-maestro-review@0.3.1'
|
|
12
21
|
- '@ddtcorex/maestro-skills@2.11.0'
|
|
22
|
+
- '@ddtcorex/dsh-maestro-review@0.5.1'
|
|
23
|
+
- '@ddtcorex/maestro-skills@2.11.1'
|
package/src/host/orchestrator.ts
CHANGED
|
@@ -234,6 +234,41 @@ export function getTurnErrorMessage(handle: unknown): string | undefined {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* A turn can go idle without ever calling a tool when the model provider
|
|
239
|
+
* itself rejects the request (auth/billing/rate-limit) — `whenIdleWithTimeout`
|
|
240
|
+
* only guards against a hang, not this. Left unchecked, the reviewer's
|
|
241
|
+
* missing-skill-profile check and the auditor's empty-output path both treat
|
|
242
|
+
* this the same as "the agent just didn't call anything", misreporting a
|
|
243
|
+
* provider outage as a maestro-skills installation problem (or, for the
|
|
244
|
+
* auditor, as a silent clean pass). Surface the real turn error immediately.
|
|
245
|
+
*/
|
|
246
|
+
export function assertTurnSucceeded(handle: unknown): void {
|
|
247
|
+
const turnMsg = getTurnErrorMessage(handle)
|
|
248
|
+
if (turnMsg !== undefined) {
|
|
249
|
+
throw new Error(`Review turn failed before completing: ${turnMsg}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Like assertTurnSucceeded, but only throws when there is nothing left to
|
|
255
|
+
* salvage. A turn can error AFTER already producing real output — findings
|
|
256
|
+
* reported via the tool, or audit text written — when only the turn's own
|
|
257
|
+
* closing step fails afterward (a trailing rate-limit/auth hiccup). Throwing
|
|
258
|
+
* unconditionally there discarded already-produced, real work for no
|
|
259
|
+
* benefit; this only fails closed when the turn errored AND there is
|
|
260
|
+
* nothing usable to fall back on. Either way the turn error is never
|
|
261
|
+
* silent: logged as a warning when salvaging, thrown when not.
|
|
262
|
+
*/
|
|
263
|
+
export function assertTurnSucceededOrSalvage(handle: unknown, hasSalvageableOutput: boolean, label: string): void {
|
|
264
|
+
const turnMsg = getTurnErrorMessage(handle)
|
|
265
|
+
if (turnMsg === undefined) return
|
|
266
|
+
if (!hasSalvageableOutput) {
|
|
267
|
+
throw new Error(`Review turn failed before completing: ${turnMsg}`)
|
|
268
|
+
}
|
|
269
|
+
console.warn(`maestro-orchestrator: ${label} turn ended with an error after already producing usable output — using it anyway: ${turnMsg}`)
|
|
270
|
+
}
|
|
271
|
+
|
|
237
272
|
/**
|
|
238
273
|
* Resolve the model to use for an automated review. Priority: per-project
|
|
239
274
|
* override > global reviewModel (Maestro Settings) > row-config reviewModel
|
|
@@ -1081,6 +1116,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1081
1116
|
source: { kind: 'user' },
|
|
1082
1117
|
}))
|
|
1083
1118
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
1119
|
+
assertTurnSucceededOrSalvage(handle, capturedFindings.length > 0, 'reviewer')
|
|
1084
1120
|
if (reviewProfile !== undefined && (reviewerContext === undefined || loadedReviewProfile(reviewerContext) !== reviewProfile)) {
|
|
1085
1121
|
throw new Error(`reviewer did not successfully load the required ${reviewProfile} review skill profile; no findings were posted`)
|
|
1086
1122
|
}
|
|
@@ -1180,6 +1216,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1180
1216
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
1181
1217
|
const output = auditorOutputFromSession(handle.agent.session)
|
|
1182
1218
|
const text = output.map(block => ('text' in block ? block.text : '')).join('')
|
|
1219
|
+
assertTurnSucceededOrSalvage(handle, text.trim() !== '', 'auditor')
|
|
1183
1220
|
return `## Maestro Performance Audit\n\n${text}`
|
|
1184
1221
|
} finally {
|
|
1185
1222
|
await handle.dispose()
|
|
@@ -13,11 +13,15 @@ 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
27
|
/** Remove only this bot's stale running markers; other users' awards stay untouched. */
|
|
@@ -25,14 +29,20 @@ async function unawardOwn(baseUrl: string, token: string, projectId: number, mrI
|
|
|
25
29
|
const response = await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
26
30
|
headers: gitlabAuthHeaders(token),
|
|
27
31
|
})
|
|
28
|
-
if (!response.ok)
|
|
32
|
+
if (!response.ok) throw new Error(`GitLab API error ${response.status} listing award emoji: ${await response.text()}`)
|
|
29
33
|
const awards = (await response.json()) as Array<{ id?: number; name?: string; user?: { username?: string } }>
|
|
30
34
|
for (const awardItem of Array.isArray(awards) ? awards : []) {
|
|
31
35
|
if (awardItem.name !== 'eyes' || awardItem.user?.username !== botUsername || typeof awardItem.id !== 'number') continue
|
|
32
|
-
await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji/${awardItem.id}`, {
|
|
36
|
+
const deleteResponse = await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji/${awardItem.id}`, {
|
|
33
37
|
method: 'DELETE',
|
|
34
38
|
headers: gitlabAuthHeaders(token),
|
|
35
|
-
}).catch(() => {
|
|
39
|
+
}).catch((err: unknown) => {
|
|
40
|
+
console.error(`review-signals: failed to delete stale eyes marker ${awardItem.id} on MR !${mrIid}`, err)
|
|
41
|
+
return undefined
|
|
42
|
+
})
|
|
43
|
+
if (deleteResponse !== undefined && !deleteResponse.ok) {
|
|
44
|
+
console.error(`review-signals: GitLab API error ${deleteResponse.status} deleting stale eyes marker ${awardItem.id} on MR !${mrIid}`)
|
|
45
|
+
}
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
|
|
@@ -43,13 +53,21 @@ export function createReviewSignals(options: { baseUrl: string; token: string; p
|
|
|
43
53
|
try {
|
|
44
54
|
await unawardOwn(baseUrl, token, projectId, mrIid, botUsername)
|
|
45
55
|
await award(baseUrl, token, projectId, mrIid, 'eyes')
|
|
46
|
-
} catch {
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Signalling must never break the review, but a swallowed failure
|
|
58
|
+
// here is exactly what leaves a stale "eyes" marker stuck on the MR
|
|
59
|
+
// forever (blocking the push-gate's 👀-running check for both the
|
|
60
|
+
// webhook and CI flows) with zero trace of why — log it.
|
|
61
|
+
console.error(`review-signals: failed to set the running marker on MR !${mrIid}`, err)
|
|
62
|
+
}
|
|
47
63
|
},
|
|
48
64
|
async finish(outcome) {
|
|
49
65
|
try {
|
|
50
66
|
await unawardOwn(baseUrl, token, projectId, mrIid, botUsername)
|
|
51
67
|
await award(baseUrl, token, projectId, mrIid, outcome === 'completed' ? 'white_check_mark' : 'warning')
|
|
52
|
-
} catch {
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`review-signals: failed to clear the running marker / award the final marker on MR !${mrIid}`, err)
|
|
70
|
+
}
|
|
53
71
|
},
|
|
54
72
|
}
|
|
55
73
|
}
|
|
@@ -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:
|
|
6
|
+
REVIEWER_IMAGE: "ddtcorex/maestro-reviewer:0.6.1"
|
|
7
7
|
# Or use $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA when building the image in the same project
|
|
8
8
|
|
|
9
9
|
review:
|
|
@@ -42,18 +42,21 @@ review:
|
|
|
42
42
|
# the run would "complete" without ever posting. A human-tied PAT needs yearly
|
|
43
43
|
# rotation; prefer a bot/group token where the instance allows creating one.
|
|
44
44
|
# MAESTRO_GITLAB_TOKEN (api scope) — required
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
45
|
+
# Model route selection — deepseek is the sole baked default; set
|
|
46
|
+
# REVIEW_LLM_API_KEY to overlay any OpenAI-compatible endpoint instead:
|
|
47
|
+
# DEEPSEEK_API_KEY (required unless using the route below) — deepseek-official
|
|
48
|
+
# straight from api.deepseek.com.
|
|
49
|
+
# REVIEW_LLM_API_KEY / REVIEW_LLM_BASE_URL / REVIEW_LLM_MODEL — bring-your-own
|
|
50
|
+
# OpenAI-compatible endpoint (self-hosted, Azure OpenAI, OpenRouter, a
|
|
51
|
+
# private gateway, ...). All three required together; the job fails closed
|
|
52
|
+
# if REVIEW_LLM_API_KEY is set without the other two. Optional
|
|
53
|
+
# REVIEW_LLM_API selects the wire protocol (openai-completions [default]
|
|
54
|
+
# or openai-responses).
|
|
50
55
|
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID (optional)
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# REVIEW_MODEL_PROVIDER: "opencode-go" # or "deepseek-official"
|
|
56
|
-
# REVIEW_MODEL: "muse-spark-1.3-contributor" # e.g. deepseek-v4-flash (deepseek-official via zen)
|
|
56
|
+
# Model pin (optional, as a pair — unset both for the active route's default
|
|
57
|
+
# model, baked in docker/ci-settings.*.yaml):
|
|
58
|
+
# REVIEW_MODEL_PROVIDER: "deepseek-official" # or "custom-openai"
|
|
59
|
+
# REVIEW_MODEL: "deepseek-v4-flash" # the active route's model id
|
|
57
60
|
# Only the provider's key is needed — no settings mount required. ID without
|
|
58
61
|
# provider fails the review with a provider error (not silently the wrong
|
|
59
62
|
# model); provider without ID is ignored.
|