@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.
@@ -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'
@@ -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) return
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 { /* signalling must never break the review */ }
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 { /* signalling must never break the review */ }
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:latest"
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
- # Route selection is key-based (entrypoint.sh swaps the baked settings variant):
46
- # OPENCODE_GO_API_KEY → opencode profile (default, host mirror; serves opencode-go
47
- # AND deepseek-official-via-zen). Set alone, or with DEEPSEEK_API_KEY (opencode wins).
48
- # DEEPSEEK_API_KEY → deepseek profile ONLY when OPENCODE_GO_API_KEY is absent
49
- # (deepseek-official straight from api.deepseek.com — needs a LIVE deepseek.com key).
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
- # Opencode model (optional, one variable — default muse-spark-1.3-contributor):
52
- # OPENCODE_MODEL: "muse-spark-1.3-contributor"
53
- # Model pin (optional, as a pair — unset both for the host-mirrored default
54
- # = opencode-go/muse-spark-1.3-contributor, baked in docker/ci-settings.yaml):
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.