@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.
@@ -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
- 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.`
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?: { events?: unknown[] } } })?.agent?.session?.events
219
- if (!Array.isArray(events)) return undefined
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 across
573
- * host/session API skew: hosts built from the harness checkout expose
574
- * `ownEvents()`/`snapshotEvents()` with no `.events` getter, while older
575
- * packaged `@deepseek-ai/dsh-session` builds expose only the `.events`
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 candidate = session as {
582
- ownEvents?: unknown
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
- assertTurnSucceeded(handle)
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
- /** Remove only this bot's stale running markers; other users' awards stay untouched. */
24
- async function unawardOwn(baseUrl: string, token: string, projectId: number, mrIid: number, botUsername: string): Promise<void> {
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) return
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 !== '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}`, {
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 { /* signalling must never break the review */ }
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 { /* signalling must never break the review */ }
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.1.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