@doist/doistbot-cli 1.0.4 → 1.0.6

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.
Files changed (82) hide show
  1. package/dist/actions/auth.js +62 -28
  2. package/dist/actions/doctor.js +16 -11
  3. package/dist/actions/review.js +77 -11
  4. package/dist/auth.js +53 -8
  5. package/dist/config.js +40 -6
  6. package/dist/env.js +1 -1
  7. package/dist/file-store.js +1 -1
  8. package/dist/runtime.js +1 -1
  9. package/dist/terminal.js +2 -7
  10. package/package.json +7 -7
  11. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +5 -23
  12. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +9 -85
  13. package/sandbox/dist/core/bot-logins.js +0 -13
  14. package/sandbox/dist/core/check-run.js +16 -0
  15. package/sandbox/dist/core/datadog-metrics.js +6 -54
  16. package/sandbox/dist/core/logger-span-processor.js +78 -0
  17. package/sandbox/dist/core/pi.js +270 -186
  18. package/sandbox/dist/core/tracing-setup.js +15 -0
  19. package/sandbox/dist/core/tracing.js +71 -0
  20. package/sandbox/dist/main.js +4 -1
  21. package/sandbox/dist/providers/config.js +1 -1
  22. package/sandbox/dist/providers/credentials.js +40 -0
  23. package/sandbox/dist/providers/gemini.js +8 -10
  24. package/sandbox/dist/providers/helpers.js +18 -38
  25. package/sandbox/dist/providers/index.js +4 -3
  26. package/sandbox/dist/providers/openai.js +1 -8
  27. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  28. package/sandbox/dist/providers/pi-review.js +28 -55
  29. package/sandbox/dist/providers/schemas.js +0 -39
  30. package/sandbox/dist/tasks/chat/chat.js +162 -101
  31. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
  32. package/sandbox/dist/tasks/issue-summarize/context.js +1 -1
  33. package/sandbox/dist/tasks/issue-summarize/model.js +4 -2
  34. package/sandbox/dist/tasks/issue-summarize/summarize.js +1 -1
  35. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  36. package/sandbox/dist/tasks/issue-triage/context.js +25 -2
  37. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +31 -8
  38. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
  39. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
  40. package/sandbox/dist/tasks/issue-triage/model.js +120 -36
  41. package/sandbox/dist/tasks/issue-triage/output.js +27 -0
  42. package/sandbox/dist/tasks/issue-triage/pr-creator.js +3 -2
  43. package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
  44. package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
  45. package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
  46. package/sandbox/dist/tasks/issue-triage/triage.js +288 -418
  47. package/sandbox/dist/tasks/persist-logs/dd-proxy.js +2 -2
  48. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +1 -1
  49. package/sandbox/dist/tasks/persist-logs/persist-logs.js +1 -1
  50. package/sandbox/dist/tasks/review/check-run.js +2 -1
  51. package/sandbox/dist/tasks/review/conversation-context.js +15 -8
  52. package/sandbox/dist/tasks/review/engines/dedupe.js +19 -9
  53. package/sandbox/dist/tasks/review/engines/multi-focus.js +62 -36
  54. package/sandbox/dist/tasks/review/engines/shared.js +15 -61
  55. package/sandbox/dist/tasks/review/github-comment-format.js +64 -10
  56. package/sandbox/dist/tasks/review/local.js +2 -29
  57. package/sandbox/dist/tasks/review/prompt.js +2 -49
  58. package/sandbox/dist/tasks/review/review-summary.js +120 -0
  59. package/sandbox/dist/tasks/review/review.js +49 -69
  60. package/sandbox/dist/tasks/review/runner.js +57 -52
  61. package/sandbox/dist/tasks/review/summary-model.js +172 -16
  62. package/sandbox/dist/tasks/review/thinking-level.js +4 -2
  63. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +5 -23
  64. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +9 -85
  65. package/sandbox/_node_modules/doistbot-repo-config/dist/review-engine.d.ts +0 -11
  66. package/sandbox/_node_modules/doistbot-repo-config/dist/review-engine.js +0 -41
  67. package/sandbox/dist/providers/anthropic.js +0 -30
  68. package/sandbox/dist/tasks/review/author-format.js +0 -14
  69. package/sandbox/dist/tasks/review/author-profile.js +0 -67
  70. package/sandbox/dist/tasks/review/council/normalizer.js +0 -104
  71. package/sandbox/dist/tasks/review/council/orchestrator.js +0 -194
  72. package/sandbox/dist/tasks/review/council/provider-health.js +0 -31
  73. package/sandbox/dist/tasks/review/council/summary.js +0 -192
  74. package/sandbox/dist/tasks/review/council/types.js +0 -4
  75. package/sandbox/dist/tasks/review/council/voter.js +0 -384
  76. package/sandbox/dist/tasks/review/engines/council.js +0 -126
  77. package/sandbox/dist/tasks/review/engines/index.js +0 -12
  78. package/sandbox/dist/tasks/review/review-engine.js +0 -5
  79. package/sandbox/node_modules/doistbot-repo-config/dist/review-engine.d.ts +0 -11
  80. package/sandbox/node_modules/doistbot-repo-config/dist/review-engine.js +0 -41
  81. package/sandbox/review-prompt.md +0 -209
  82. package/sandbox/vote-prompt.md +0 -69
@@ -164,8 +164,20 @@ export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessm
164
164
  `The PR targets \`${targetRepoFullName}\`. Please review it carefully before merging.`,
165
165
  ].join('\n');
166
166
  }
167
- function isNonRetryableFailure(reason) {
168
- return reason.includes(CODEX_CANNOT_FIX_REASON);
167
+ // Detect the "## Cannot Fix" verdict and pull Codex's own explanation in one pass,
168
+ // so the failure comment can tell the human *why* it bailed, not just that it did.
169
+ // Returns null when the marker is absent (not a cannot-fix verdict), the explanation
170
+ // string when present, or '' when the model gave the verdict with no explanation.
171
+ // The full tail is intentional — the prompt makes "## Cannot Fix" the terminal output.
172
+ // ponytail: 2000-char cap keeps the comment readable; raise it if reasons get truncated.
173
+ export function extractCannotFixDetail(output) {
174
+ const idx = output.indexOf(CANNOT_FIX_MARKER);
175
+ if (idx === -1)
176
+ return null;
177
+ return output
178
+ .slice(idx + CANNOT_FIX_MARKER.length)
179
+ .trim()
180
+ .slice(0, 2000);
169
181
  }
170
182
  async function findSourceIssueCommentByMarker({ octokit, ctx, marker, }) {
171
183
  const comments = await octokit.paginate(octokit.rest.issues.listComments, {
@@ -187,8 +199,6 @@ export async function shouldPostAutoFixFailureComment(fixResult, { dryRun, octok
187
199
  // a "didn't produce a fix PR" comment would be misleading.
188
200
  if (fixResult.prNumber !== undefined)
189
201
  return false;
190
- if (isNonRetryableFailure(fixResult.reason))
191
- return false;
192
202
  if (fixResult.reason.startsWith(FIX_LOOP_FAILED_REASON_PREFIX)) {
193
203
  try {
194
204
  const hasFixLoopStartComment = await sourceIssueHasFixLoopStartComment({ octokit, ctx });
@@ -231,9 +241,11 @@ export function buildAutoFixFailureCommentBody({ reason, targetRepoFullName, })
231
241
  '',
232
242
  '**Reason:**',
233
243
  '',
234
- '```',
244
+ // Tilde fence, not backticks: the reason often carries Codex's own
245
+ // backtick-fenced output (logs, CLI), which would close a ``` block early.
246
+ '~~~',
235
247
  reason,
236
- '```',
248
+ '~~~',
237
249
  '',
238
250
  'You can retry by commenting `@doistbot /fix`.',
239
251
  ].join('\n');
@@ -365,6 +377,9 @@ export async function runFixCore(input) {
365
377
  ? SPECULATIVE_CODEX_TIMEOUT_MS
366
378
  : AUTO_FIX_CODEX_TIMEOUT_MS;
367
379
  const codexResult = await invokeTriageModel({
380
+ // Distinct phase name: this is fix generation, not triage analysis.
381
+ // Callers (e.g. issue-fix-retry) override to keep it in their namespace.
382
+ phaseName: input.modelPhaseName ?? 'triage.fix-attempt.model',
368
383
  prompt,
369
384
  apiKey: ctx.openaiApiKey,
370
385
  cwd: targetRepo.localPath,
@@ -391,13 +406,19 @@ export async function runFixCore(input) {
391
406
  outputChars: fixDescription.length,
392
407
  });
393
408
  // Check if Codex reported it cannot fix
394
- if (fixDescription.includes(CANNOT_FIX_MARKER)) {
409
+ const cannotFixDetail = extractCannotFixDetail(fixDescription);
410
+ if (cannotFixDetail !== null) {
395
411
  logger.info('Fix Codex reported cannot fix', {
396
412
  task: 'issue-triage',
397
413
  issueNumber: ctx.issueNumber,
398
414
  output: fixDescription.slice(0, 500),
399
415
  });
400
- return { ok: false, reason: CODEX_CANNOT_FIX_REASON };
416
+ return {
417
+ ok: false,
418
+ reason: cannotFixDetail
419
+ ? `${CODEX_CANNOT_FIX_REASON}\n\n${cannotFixDetail}`
420
+ : CODEX_CANNOT_FIX_REASON,
421
+ };
401
422
  }
402
423
  // Capture the diff
403
424
  const diff = captureGitDiff(targetRepo.localPath);
@@ -499,6 +520,7 @@ export async function attemptFix(input) {
499
520
  issueUrl: input.issueUrl,
500
521
  ghToken: input.ghToken,
501
522
  force,
523
+ modelPhaseName: input.modelPhaseName,
502
524
  });
503
525
  if (!coreResult.ok) {
504
526
  return {
@@ -520,6 +542,7 @@ export async function attemptFix(input) {
520
542
  repository: targetRepo.repository.fullName,
521
543
  prCreated: false,
522
544
  reason: 'Dry run — PR not created.',
545
+ dryRun: true,
523
546
  };
524
547
  }
525
548
  let openedPr;
@@ -1,4 +1,6 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
1
2
  import { logger } from '../../core/logging.js';
3
+ import { withPhase } from '../../core/tracing.js';
2
4
  import { DefaultLoopEffects } from './fix-loop/effects.js';
3
5
  import { runFixLoop } from './fix-loop/runners.js';
4
6
  import { attemptFix, buildFixAssessmentSkipReason, shouldAttemptFix } from './fix-attempt.js';
@@ -79,112 +81,163 @@ function prUrlFor(targetRepo, prNumber) {
79
81
  return `https://github.com/${targetRepo.repository.fullName}/pull/${prNumber}`;
80
82
  }
81
83
  export async function runConfiguredFix(input) {
82
- const target = resolveFixTarget(input);
83
- if (!target.ok) {
84
- return { attempted: false, repository: target.repository, reason: target.reason };
85
- }
86
- const targetRepo = target.targetRepo;
87
- let repoConfig;
88
- try {
89
- repoConfig = await input.loadRepoConfig(targetRepo.repository);
90
- }
91
- catch (error) {
92
- logger.warn('Fix loop config load failed', {
93
- task: 'fix-loop',
94
- repository: targetRepo.repository.fullName,
95
- error: errorReason(error),
96
- });
97
- return {
98
- attempted: true,
99
- repository: targetRepo.repository.fullName,
100
- prCreated: false,
101
- reason: `Fix loop config load failed: ${errorReason(error)}`,
102
- };
103
- }
104
- const autoFixMode = getAutoFixMode(repoConfig, input.ctx.enableAutoFixOverride);
105
- if (autoFixMode === 'disabled') {
106
- return {
107
- attempted: false,
108
- repository: targetRepo.repository.fullName,
109
- reason: 'Auto fix disabled by repository config.',
110
- };
111
- }
112
- if (autoFixMode === 'one-pass') {
113
- return attemptFix(toAttemptFixInput(input));
114
- }
115
- if (!shouldAttemptFix(input.fixAssessment)) {
116
- return {
117
- attempted: false,
118
- repository: targetRepo.repository.fullName,
119
- reason: buildFixAssessmentSkipReason(input.fixAssessment),
120
- };
121
- }
122
- const session = {
123
- kind: 'fix-loop',
124
- ctx: input.ctx,
125
- octokit: input.octokit,
126
- triageOutput: input.triageOutput,
127
- fixAssessment: input.fixAssessment,
128
- targetRepo,
129
- issueTitle: input.issueTitle,
130
- issueUrl: input.issueUrl,
131
- ghToken: input.ghToken,
132
- pushToken: input.ctx.githubToken,
133
- apiKeys: {
134
- openai: input.ctx.openaiApiKey,
135
- ...(input.ctx.geminiApiKey !== undefined && { gemini: input.ctx.geminiApiKey }),
136
- ...(input.ctx.anthropicApiKey !== undefined && {
137
- anthropic: input.ctx.anthropicApiKey,
138
- }),
139
- },
140
- };
141
- let finalState;
142
- try {
143
- finalState = await runFixLoop({
144
- effects: new DefaultLoopEffects(session, { dryRun: input.ctx.dryRun }),
145
- ...(input.ctx.dryRun ? { onTransition: logDryRunTransition } : {}),
146
- });
147
- }
148
- catch (error) {
149
- return {
150
- attempted: true,
151
- repository: targetRepo.repository.fullName,
152
- prCreated: false,
153
- reason: `${FIX_LOOP_FAILED_REASON_PREFIX} ${errorReason(error)}`,
84
+ return withPhase('triage.fix-attempt', { 'gen_ai.operation.name': 'invoke_agent', 'gen_ai.agent.name': 'codex.fix' }, async (span) => {
85
+ function finish(result) {
86
+ const prCreated = 'prCreated' in result ? result.prCreated : false;
87
+ // Only a real PR number counts as PR-evidence — dry-run loop
88
+ // states can carry a placeholder prNumber of 0.
89
+ const prNumber = 'prNumber' in result && result.prNumber !== undefined && result.prNumber > 0
90
+ ? result.prNumber
91
+ : undefined;
92
+ // Dry-run skips are expected outcomes, not failures — signaled
93
+ // by the stable dryRun flag on the result, never by parsing
94
+ // the human-readable reason copy.
95
+ const isDryRunSkip = 'dryRun' in result && result.dryRun === true;
96
+ let outcome;
97
+ if (prCreated) {
98
+ outcome = 'pr-created';
99
+ }
100
+ else if (isDryRunSkip) {
101
+ outcome = 'dry-run';
102
+ }
103
+ else if (prNumber !== undefined) {
104
+ // The loop opened a PR earlier (REVIEWING/ITERATING) but
105
+ // ended needing a human: a PR exists, the autonomous
106
+ // attempt did not finish.
107
+ outcome = 'pr-opened-needs-human';
108
+ }
109
+ else if (result.attempted) {
110
+ outcome = 'attempted-no-pr';
111
+ }
112
+ else {
113
+ outcome = 'not-attempted';
114
+ }
115
+ span.setAttributes({
116
+ 'fix.attempted': result.attempted,
117
+ 'fix.repository': result.repository,
118
+ 'fix.pr_created': prCreated,
119
+ ...(prNumber !== undefined ? { 'fix.pr_number': prNumber } : {}),
120
+ 'fix.outcome': outcome,
121
+ });
122
+ // A real attempt that did not deliver a ready PR is a failure
123
+ // (including pr-opened-needs-human — someone must take over);
124
+ // a legitimate skip or a dry-run is not. withPhase does not
125
+ // force OK on a normal return, so this ERROR survives to
126
+ // phase.end.
127
+ if (result.attempted && !prCreated && !isDryRunSkip && 'reason' in result) {
128
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.reason });
129
+ }
130
+ return result;
131
+ }
132
+ const target = resolveFixTarget(input);
133
+ if (!target.ok) {
134
+ return finish({
135
+ attempted: false,
136
+ repository: target.repository,
137
+ reason: target.reason,
138
+ });
139
+ }
140
+ const targetRepo = target.targetRepo;
141
+ let repoConfig;
142
+ try {
143
+ repoConfig = await input.loadRepoConfig(targetRepo.repository);
144
+ }
145
+ catch (error) {
146
+ logger.warn('Fix loop config load failed', {
147
+ task: 'fix-loop',
148
+ repository: targetRepo.repository.fullName,
149
+ error: errorReason(error),
150
+ });
151
+ return finish({
152
+ attempted: true,
153
+ repository: targetRepo.repository.fullName,
154
+ prCreated: false,
155
+ reason: `Fix loop config load failed: ${errorReason(error)}`,
156
+ });
157
+ }
158
+ const autoFixMode = getAutoFixMode(repoConfig, input.ctx.enableAutoFixOverride);
159
+ if (autoFixMode === 'disabled') {
160
+ return finish({
161
+ attempted: false,
162
+ repository: targetRepo.repository.fullName,
163
+ reason: 'Auto fix disabled by repository config.',
164
+ });
165
+ }
166
+ if (autoFixMode === 'one-pass') {
167
+ return finish(await attemptFix(toAttemptFixInput(input)));
168
+ }
169
+ if (!shouldAttemptFix(input.fixAssessment)) {
170
+ return finish({
171
+ attempted: false,
172
+ repository: targetRepo.repository.fullName,
173
+ reason: buildFixAssessmentSkipReason(input.fixAssessment),
174
+ });
175
+ }
176
+ const session = {
177
+ kind: 'fix-loop',
178
+ ctx: input.ctx,
179
+ octokit: input.octokit,
180
+ triageOutput: input.triageOutput,
181
+ fixAssessment: input.fixAssessment,
182
+ targetRepo,
183
+ issueTitle: input.issueTitle,
184
+ issueUrl: input.issueUrl,
185
+ ghToken: input.ghToken,
186
+ pushToken: input.ctx.githubToken,
187
+ apiKeys: {
188
+ openai: input.ctx.openaiApiKey,
189
+ ...(input.ctx.geminiApiKey !== undefined && { gemini: input.ctx.geminiApiKey }),
190
+ },
154
191
  };
155
- }
156
- if (finalState.kind === 'PUSHED_AWAITING_CI') {
157
- if (input.ctx.dryRun) {
158
- return {
192
+ let finalState;
193
+ try {
194
+ finalState = await runFixLoop({
195
+ effects: new DefaultLoopEffects(session, { dryRun: input.ctx.dryRun }),
196
+ ...(input.ctx.dryRun ? { onTransition: logDryRunTransition } : {}),
197
+ });
198
+ }
199
+ catch (error) {
200
+ return finish({
159
201
  attempted: true,
160
202
  repository: targetRepo.repository.fullName,
161
203
  prCreated: false,
162
- reason: 'Dry run: PR not created.',
163
- };
204
+ reason: `${FIX_LOOP_FAILED_REASON_PREFIX} ${errorReason(error)}`,
205
+ });
164
206
  }
165
- const prNumber = finalState.prNumber;
166
- const prUrl = prUrlFor(targetRepo, prNumber);
167
- return {
207
+ if (finalState.kind === 'PUSHED_AWAITING_CI') {
208
+ if (input.ctx.dryRun) {
209
+ return finish({
210
+ attempted: true,
211
+ repository: targetRepo.repository.fullName,
212
+ prCreated: false,
213
+ reason: 'Dry run: PR not created.',
214
+ dryRun: true,
215
+ });
216
+ }
217
+ const prNumber = finalState.prNumber;
218
+ const prUrl = prUrlFor(targetRepo, prNumber);
219
+ return finish({
220
+ attempted: true,
221
+ repository: targetRepo.repository.fullName,
222
+ prCreated: true,
223
+ prUrl,
224
+ prNumber,
225
+ });
226
+ }
227
+ const reason = finalState.kind === 'DONE_NEEDS_HUMAN'
228
+ ? finalState.reason
229
+ : `Fix loop ended in ${finalState.kind}`;
230
+ return finish({
168
231
  attempted: true,
169
232
  repository: targetRepo.repository.fullName,
170
- prCreated: true,
171
- prUrl,
172
- prNumber,
173
- };
174
- }
175
- const reason = finalState.kind === 'DONE_NEEDS_HUMAN'
176
- ? finalState.reason
177
- : `Fix loop ended in ${finalState.kind}`;
178
- return {
179
- attempted: true,
180
- repository: targetRepo.repository.fullName,
181
- prCreated: false,
182
- reason,
183
- // If a PR was opened earlier in the loop (REVIEWING/ITERATING reached
184
- // before DONE_NEEDS_HUMAN), surface it so callers can distinguish a
185
- // silent failure from an in-progress PR.
186
- ...('prNumber' in finalState && finalState.prNumber !== undefined
187
- ? { prNumber: finalState.prNumber }
188
- : {}),
189
- };
233
+ prCreated: false,
234
+ reason,
235
+ // If a PR was opened earlier in the loop (REVIEWING/ITERATING reached
236
+ // before DONE_NEEDS_HUMAN), surface it so callers can distinguish a
237
+ // silent failure from an in-progress PR.
238
+ ...('prNumber' in finalState && finalState.prNumber !== undefined
239
+ ? { prNumber: finalState.prNumber }
240
+ : {}),
241
+ });
242
+ });
190
243
  }
@@ -1,3 +1,4 @@
1
+ import { iterationLabel, PROMOTION_COMMENT_MARKER } from 'doistbot-shared-contracts';
1
2
  import { logger } from '../../../core/logging.js';
2
3
  import { isFixLoopTerminal, isTerminal, next, } from './state-machine.js';
3
4
  const CI_FAILED_LABEL = 'doistbot:ci-failed';
@@ -6,10 +7,13 @@ const AWAITING_CI_LABEL = 'doistbot:awaiting-ci';
6
7
  // Stamped on terminal comments so postIssueComment can find-or-update an
7
8
  // existing comment instead of double-posting when an earlier dispatch
8
9
  // succeeded on GitHub but the response timed out.
9
- const PROMOTION_COMMENT_MARKER = '<!--doistbot-promotion-comment-->';
10
- function iterationLabel(iteration) {
11
- return `doistbot:iter-${iteration}`;
12
- }
10
+ //
11
+ // The success and human-escalation paths use DISTINCT markers: merge tracking
12
+ // reads 👍/👎 reactions on the promotion (success) comment to derive the thumbs
13
+ // metric, so it must be uniquely identifiable. A shared marker would let a
14
+ // CI-failed-then-human-merged bot PR expose its failure comment as the reaction
15
+ // target instead.
16
+ const PROMOTION_NEEDS_HUMAN_COMMENT_MARKER = '<!--doistbot-promotion-needs-human-comment-->';
13
17
  function formatFailedChecks(checks) {
14
18
  return checks.map((c) => (c.url ? `- [${c.name}](${c.url})` : `- ${c.name}`)).join('\n');
15
19
  }
@@ -176,7 +180,12 @@ export async function runPromotionFlow(effects, input) {
176
180
  await effects.postIssueComment({
177
181
  prNumber: input.prNumber,
178
182
  marker: PROMOTION_COMMENT_MARKER,
179
- body: 'CI passed — promoting this PR out of draft and requesting hero review.',
183
+ // This comment doubles as the thumbs-feedback prompt: merge
184
+ // tracking reads 👍/👎 reactions on this exact comment (located
185
+ // via the marker above) when the PR merges. Keep the reaction
186
+ // ask on the comment people are invited to react to.
187
+ body: 'CI passed — promoting this PR out of draft and requesting hero review.\n\n' +
188
+ '<sub>🤖🫵 Please, react 👍 or 👎 on this comment to rate this bot-authored PR.</sub>',
180
189
  });
181
190
  await effects.promoteToReady(input.prNumber);
182
191
  return state;
@@ -193,7 +202,7 @@ export async function runPromotionFlow(effects, input) {
193
202
  : 'This PR needs human review.';
194
203
  await effects.postIssueComment({
195
204
  prNumber: input.prNumber,
196
- marker: PROMOTION_COMMENT_MARKER,
205
+ marker: PROMOTION_NEEDS_HUMAN_COMMENT_MARKER,
197
206
  body,
198
207
  });
199
208
  return state;
@@ -1,39 +1,123 @@
1
- import { invokePiPrompt } from '../../core/pi.js';
1
+ import { SpanStatusCode } from '@opentelemetry/api';
2
+ import { DEFAULT_PI_MODELS, invokePiPrompt } from '../../core/pi.js';
3
+ import { recordEvent, startToolSpan, withPhase } from '../../core/tracing.js';
2
4
  const ISSUE_TRIAGE_TIMEOUT_MS = 12 * 60 * 1000;
3
- export async function invokeTriageModel({ prompt, apiKey, cwd, timeoutMs = ISSUE_TRIAGE_TIMEOUT_MS, extraEnv, thinkingLevel, onProgress, }) {
4
- const startedAt = Date.now();
5
- const result = await invokePiPrompt({
6
- provider: 'openai',
7
- apiKey,
8
- prompt,
9
- cwd,
10
- timeoutMs,
11
- extraEnv,
12
- capabilityPreset: 'shell-focused',
13
- ...(thinkingLevel ? { thinkingLevel } : {}),
14
- ...(onProgress ? { onProgress } : {}),
5
+ const TRIAGE_PROVIDER = 'openai';
6
+ export async function invokeTriageModel({ prompt, apiKey, cwd, timeoutMs = ISSUE_TRIAGE_TIMEOUT_MS, extraEnv, thinkingLevel, modelName = DEFAULT_PI_MODELS[TRIAGE_PROVIDER],
7
+ // Callers on other paths (e.g. fix generation) pass their own phase name
8
+ // so model telemetry stays separable per flow.
9
+ phaseName = 'triage.model', }) {
10
+ return withPhase(phaseName, {
11
+ 'gen_ai.operation.name': 'chat',
12
+ 'gen_ai.provider.name': TRIAGE_PROVIDER,
13
+ 'gen_ai.request.model': modelName,
14
+ }, async (span) => {
15
+ // pi emits tool start/end across separate onProgress callbacks, so
16
+ // tool spans are managed manually (the documented exception to
17
+ // "withPhase everywhere"). startToolSpan captures context.active()
18
+ // at call time, parenting each tool span under THIS model span.
19
+ const inFlightTools = new Map();
20
+ function toolKey(event) {
21
+ return event.toolCallId ?? event.toolName ?? '';
22
+ }
23
+ function onProgress(event) {
24
+ switch (event.type) {
25
+ case 'tool_start': {
26
+ const toolCallId = event.toolCallId;
27
+ const key = toolKey(event);
28
+ // Overlapping same-name tools without a toolCallId
29
+ // collide on the key; close the displaced span so it
30
+ // is never leaked un-ended.
31
+ const displaced = inFlightTools.get(key);
32
+ if (displaced) {
33
+ displaced.setStatus({
34
+ code: SpanStatusCode.ERROR,
35
+ message: 'replaced by overlapping same-name tool without toolCallId',
36
+ });
37
+ displaced.end();
38
+ }
39
+ const toolSpan = startToolSpan(event.toolName, {
40
+ 'gen_ai.operation.name': 'execute_tool',
41
+ 'gen_ai.tool.name': event.toolName,
42
+ ...(toolCallId ? { 'gen_ai.tool.call.id': toolCallId } : {}),
43
+ });
44
+ inFlightTools.set(key, toolSpan);
45
+ return;
46
+ }
47
+ case 'tool_end': {
48
+ const key = toolKey(event);
49
+ const toolSpan = inFlightTools.get(key);
50
+ if (toolSpan) {
51
+ if (event.isError) {
52
+ toolSpan.setStatus({
53
+ code: SpanStatusCode.ERROR,
54
+ message: 'tool reported error',
55
+ });
56
+ }
57
+ toolSpan.end();
58
+ inFlightTools.delete(key);
59
+ }
60
+ return;
61
+ }
62
+ case 'thinking':
63
+ case 'responding': {
64
+ // Liveness signal for long model phases: pi already
65
+ // throttles these to one per 30s. Emitted as point-in-time
66
+ // events (correlated to this model span), NOT spans —
67
+ // they are heartbeats, not boundaries.
68
+ recordEvent('model.heartbeat', { state: event.type });
69
+ }
70
+ // agent_start/agent_end duplicate this phase span's own
71
+ // boundaries and tool_update is covered by the open tool
72
+ // span — intentionally no emission for those.
73
+ }
74
+ }
75
+ const startedAt = Date.now();
76
+ const result = await invokePiPrompt({
77
+ provider: TRIAGE_PROVIDER,
78
+ apiKey,
79
+ prompt,
80
+ cwd,
81
+ timeoutMs,
82
+ extraEnv,
83
+ capabilityPreset: 'shell-focused',
84
+ ...(thinkingLevel ? { thinkingLevel } : {}),
85
+ ...(modelName ? { modelName } : {}),
86
+ onProgress,
87
+ });
88
+ // Force-end any tool spans pi never closed (defensive — pi's stream
89
+ // is ordered, but if a tool_end was lost we don't want hanging spans).
90
+ for (const [, toolSpan] of inFlightTools) {
91
+ toolSpan.setStatus({
92
+ code: SpanStatusCode.ERROR,
93
+ message: 'tool span did not receive matching end',
94
+ });
95
+ toolSpan.end();
96
+ }
97
+ inFlightTools.clear();
98
+ const durationMs = Date.now() - startedAt;
99
+ if (!result.ok) {
100
+ // Mark the model phase as failed. withPhase does NOT force OK on
101
+ // success, so this ERROR status survives to the phase.end line.
102
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.error });
103
+ return {
104
+ ok: false,
105
+ error: result.error,
106
+ durationMs,
107
+ timedOut: result.timedOut,
108
+ };
109
+ }
110
+ const output = result.output.trim();
111
+ if (!output) {
112
+ const message = 'Pi triage model did not produce an assistant response';
113
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
114
+ return {
115
+ ok: false,
116
+ error: message,
117
+ durationMs,
118
+ timedOut: false,
119
+ };
120
+ }
121
+ return { ok: true, output, durationMs };
15
122
  });
16
- const durationMs = Date.now() - startedAt;
17
- if (!result.ok) {
18
- return {
19
- ok: false,
20
- error: result.error,
21
- durationMs,
22
- timedOut: result.timedOut,
23
- };
24
- }
25
- const output = result.output.trim();
26
- if (!output) {
27
- return {
28
- ok: false,
29
- error: 'Pi triage model did not produce an assistant response',
30
- durationMs,
31
- timedOut: false,
32
- };
33
- }
34
- return {
35
- ok: true,
36
- output,
37
- durationMs,
38
- };
39
123
  }
@@ -1,4 +1,6 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
1
2
  import { fillPromptTemplate } from '../../core/prompt-template.js';
3
+ import { withPhase } from '../../core/tracing.js';
2
4
  const REQUIRED_SECTIONS = [
3
5
  'Summary',
4
6
  'Findings',
@@ -121,3 +123,28 @@ export function parseFixAssessment(triageOutput) {
121
123
  };
122
124
  }
123
125
  export { REQUIRED_HEADINGS };
126
+ export function validateTriageOutput(params) {
127
+ return withPhase('triage.output', { 'output.length': params.rawOutput.length }, async (span) => {
128
+ const normalized = ensureStructuredTriageOutput(params.rawOutput);
129
+ span.setAttributes({
130
+ 'output.missing_sections.count': normalized.missingSections.length,
131
+ 'output.quality_check.passed': normalized.missingSections.length === 0,
132
+ });
133
+ if (normalized.missingSections.length > 0) {
134
+ // Mirrors the failure path in current triage.ts (missingSections > 0).
135
+ const error = `Triage model returned incomplete output. Missing sections: ${normalized.missingSections.join(', ')}`;
136
+ // ensureStructuredTriageOutput does not throw; mark the span ERROR
137
+ // explicitly. withPhase does not force OK on a normal return, so this
138
+ // survives to phase.end.
139
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error });
140
+ return { ok: false, error };
141
+ }
142
+ const assessment = parseFixAssessment(normalized.output);
143
+ return {
144
+ ok: true,
145
+ commentOutput: normalized.commentOutput,
146
+ output: normalized.output,
147
+ assessment,
148
+ };
149
+ });
150
+ }
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import { DOISTBOT_AUTO_FIX_LABEL, DOISTBOT_SPECULATIVE_FIX_LABEL } from 'doistbot-shared-contracts';
4
5
  import { logger } from '../../core/logging.js';
5
6
  import { fillPromptTemplate, loadPromptTemplateFile } from '../../core/prompt-template.js';
6
7
  const PR_TEMPLATE_PATHS = [
@@ -299,8 +300,8 @@ export async function openDraftFixPR(input) {
299
300
  });
300
301
  try {
301
302
  const prLabel = input.fixAssessment?.classification === 'speculative'
302
- ? 'doistbot-speculative-fix'
303
- : 'doistbot-auto-fix';
303
+ ? DOISTBOT_SPECULATIVE_FIX_LABEL
304
+ : DOISTBOT_AUTO_FIX_LABEL;
304
305
  await input.octokit.rest.issues.addLabels({
305
306
  owner: input.repoOwner,
306
307
  repo: input.repoName,
@@ -0,0 +1,26 @@
1
+ import { resolveIssueRepositories, } from '../../core/repository-resolver.js';
2
+ import { recordDecision, withPhase } from '../../core/tracing.js';
3
+ export async function resolveTriageScope(params) {
4
+ return withPhase('triage.resolution', { 'labels.count': params.labelNames.length }, async (span) => {
5
+ const result = resolveIssueRepositories(params.labelNames);
6
+ recordDecision('triage.resolution.outcome', {
7
+ trigger: params.triggerType,
8
+ status: result.status,
9
+ confidence: result.confidence,
10
+ reason: result.reason,
11
+ decision_path: result.decisionPath,
12
+ parsed_products: result.parsedLabels.productLabels,
13
+ parsed_apps: result.parsedLabels.appLabels,
14
+ parsed_teams: result.parsedLabels.teamLabels,
15
+ unknown_product_labels: result.parsedLabels.unknownProductLabels,
16
+ unknown_app_labels: result.parsedLabels.unknownAppLabels,
17
+ unknown_team_labels: result.parsedLabels.unknownTeamLabels,
18
+ });
19
+ span.setAttributes({
20
+ 'resolution.status': result.status,
21
+ 'resolution.confidence': result.confidence,
22
+ 'resolution.repos.count': result.status === 'resolved' ? result.repositories.length : 0,
23
+ });
24
+ return result;
25
+ });
26
+ }