/ commands / review / reviewRemote.ts
reviewRemote.ts
  1  /**
  2   * Teleported /ultrareview execution. Creates a CCR session with the current repo,
  3   * sends the review prompt as the initial message, and registers a
  4   * RemoteAgentTask so the polling loop pipes results back into the local
  5   * session via task-notification. Mirrors the /ultraplan → CCR flow.
  6   *
  7   * TODO(#22051): pass useBundleMode once landed so local-only / uncommitted
  8   * repo state is captured. The GitHub-clone path (current) only works for
  9   * pushed branches on repos with the Claude GitHub app installed.
 10   */
 11  
 12  import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'
 13  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
 14  import {
 15    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 16    logEvent,
 17  } from '../../services/analytics/index.js'
 18  import { fetchUltrareviewQuota } from '../../services/api/ultrareviewQuota.js'
 19  import { fetchUtilization } from '../../services/api/usage.js'
 20  import type { ToolUseContext } from '../../Tool.js'
 21  import {
 22    checkRemoteAgentEligibility,
 23    formatPreconditionError,
 24    getRemoteTaskSessionUrl,
 25    registerRemoteAgentTask,
 26  } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'
 27  import { isEnterpriseSubscriber, isTeamSubscriber } from '../../utils/auth.js'
 28  import { detectCurrentRepositoryWithHost } from '../../utils/detectRepository.js'
 29  import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
 30  import { getDefaultBranch, gitExe } from '../../utils/git.js'
 31  import { teleportToRemote } from '../../utils/teleport.js'
 32  
 33  // One-time session flag: once the user confirms overage billing via the
 34  // dialog, all subsequent /ultrareview invocations in this session proceed
 35  // without re-prompting.
 36  let sessionOverageConfirmed = false
 37  
 38  export function confirmOverage(): void {
 39    sessionOverageConfirmed = true
 40  }
 41  
 42  export type OverageGate =
 43    | { kind: 'proceed'; billingNote: string }
 44    | { kind: 'not-enabled' }
 45    | { kind: 'low-balance'; available: number }
 46    | { kind: 'needs-confirm' }
 47  
 48  /**
 49   * Determine whether the user can launch an ultrareview and under what
 50   * billing terms. Fetches quota and utilization in parallel.
 51   */
 52  export async function checkOverageGate(): Promise<OverageGate> {
 53    // Team and Enterprise plans include ultrareview — no free-review quota
 54    // or Extra Usage dialog. The quota endpoint is scoped to consumer plans
 55    // (pro/max); hitting it on team/ent would surface a confusing dialog.
 56    if (isTeamSubscriber() || isEnterpriseSubscriber()) {
 57      return { kind: 'proceed', billingNote: '' }
 58    }
 59  
 60    const [quota, utilization] = await Promise.all([
 61      fetchUltrareviewQuota(),
 62      fetchUtilization().catch(() => null),
 63    ])
 64  
 65    // No quota info (non-subscriber or endpoint down) — let it through,
 66    // server-side billing will handle it.
 67    if (!quota) {
 68      return { kind: 'proceed', billingNote: '' }
 69    }
 70  
 71    if (quota.reviews_remaining > 0) {
 72      return {
 73        kind: 'proceed',
 74        billingNote: ` This is free ultrareview ${quota.reviews_used + 1} of ${quota.reviews_limit}.`,
 75      }
 76    }
 77  
 78    // Utilization fetch failed (transient network error, timeout, etc.) —
 79    // let it through, same rationale as the quota fallback above.
 80    if (!utilization) {
 81      return { kind: 'proceed', billingNote: '' }
 82    }
 83  
 84    // Free reviews exhausted — check Extra Usage setup.
 85    const extraUsage = utilization.extra_usage
 86    if (!extraUsage?.is_enabled) {
 87      logEvent('tengu_review_overage_not_enabled', {})
 88      return { kind: 'not-enabled' }
 89    }
 90  
 91    // Check available balance (null monthly_limit = unlimited).
 92    const monthlyLimit = extraUsage.monthly_limit
 93    const usedCredits = extraUsage.used_credits ?? 0
 94    const available =
 95      monthlyLimit === null || monthlyLimit === undefined
 96        ? Infinity
 97        : monthlyLimit - usedCredits
 98  
 99    if (available < 10) {
100      logEvent('tengu_review_overage_low_balance', { available })
101      return { kind: 'low-balance', available }
102    }
103  
104    if (!sessionOverageConfirmed) {
105      logEvent('tengu_review_overage_dialog_shown', {})
106      return { kind: 'needs-confirm' }
107    }
108  
109    return {
110      kind: 'proceed',
111      billingNote: ' This review bills as Extra Usage.',
112    }
113  }
114  
115  /**
116   * Launch a teleported review session. Returns ContentBlockParam[] describing
117   * the launch outcome for injection into the local conversation (model is then
118   * queried with this content, so it can narrate the launch to the user).
119   *
120   * Returns ContentBlockParam[] with user-facing error messages on recoverable
121   * failures (missing merge-base, empty diff, bundle too large), or null on
122   * other failures so the caller falls through to the local-review prompt.
123   * Reason is captured in analytics.
124   *
125   * Caller must run checkOverageGate() BEFORE calling this function
126   * (ultrareviewCommand.tsx handles the dialog).
127   */
128  export async function launchRemoteReview(
129    args: string,
130    context: ToolUseContext,
131    billingNote?: string,
132  ): Promise<ContentBlockParam[] | null> {
133    const eligibility = await checkRemoteAgentEligibility()
134    // Synthetic DEFAULT_CODE_REVIEW_ENVIRONMENT_ID works without per-org CCR
135    // setup, so no_remote_environment isn't a blocker. Server-side quota
136    // consume at session creation routes billing: first N zero-rate, then
137    // anthropic:cccr org-service-key (overage-only).
138    if (!eligibility.eligible) {
139      const blockers = eligibility.errors.filter(
140        e => e.type !== 'no_remote_environment',
141      )
142      if (blockers.length > 0) {
143        logEvent('tengu_review_remote_precondition_failed', {
144          precondition_errors: blockers
145            .map(e => e.type)
146            .join(
147              ',',
148            ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
149        })
150        const reasons = blockers.map(formatPreconditionError).join('\n')
151        return [
152          {
153            type: 'text',
154            text: `Ultrareview cannot launch:\n${reasons}`,
155          },
156        ]
157      }
158    }
159  
160    const resolvedBillingNote = billingNote ?? ''
161  
162    const prNumber = args.trim()
163    const isPrNumber = /^\d+$/.test(prNumber)
164    // Synthetic code_review env. Go taggedid.FromUUID(TagEnvironment,
165    // UUID{...,0x02}) encodes with version prefix '01' — NOT Python's
166    // legacy tagged_id() format. Verified in prod.
167    const CODE_REVIEW_ENV_ID = 'env_011111111111111111111113'
168    // Lite-review bypasses bughunter.go entirely, so it doesn't see the
169    // webhook's bug_hunter_config (different GB project). These env vars are
170    // the only tuning surface — without them, run_hunt.sh's bash defaults
171    // apply (60min, 120s agent timeout), and 120s kills verifiers mid-run
172    // which causes infinite respawn.
173    //
174    // total_wallclock must stay below RemoteAgentTask's 30min poll timeout
175    // with headroom for finalization (~3min synthesis). Per-field guards
176    // match autoDream.ts — GB cache can return stale wrong-type values.
177    const raw = getFeatureValue_CACHED_MAY_BE_STALE<Record<
178      string,
179      unknown
180    > | null>('tengu_review_bughunter_config', null)
181    const posInt = (v: unknown, fallback: number, max?: number): number => {
182      if (typeof v !== 'number' || !Number.isFinite(v)) return fallback
183      const n = Math.floor(v)
184      if (n <= 0) return fallback
185      return max !== undefined && n > max ? fallback : n
186    }
187    // Upper bounds: 27min on wallclock leaves ~3min for finalization under
188    // RemoteAgentTask's 30min poll timeout. If GB is set above that, the
189    // hang we're fixing comes back — fall to the safe default instead.
190    const commonEnvVars = {
191      BUGHUNTER_DRY_RUN: '1',
192      BUGHUNTER_FLEET_SIZE: String(posInt(raw?.fleet_size, 5, 20)),
193      BUGHUNTER_MAX_DURATION: String(posInt(raw?.max_duration_minutes, 10, 25)),
194      BUGHUNTER_AGENT_TIMEOUT: String(
195        posInt(raw?.agent_timeout_seconds, 600, 1800),
196      ),
197      BUGHUNTER_TOTAL_WALLCLOCK: String(
198        posInt(raw?.total_wallclock_minutes, 22, 27),
199      ),
200      ...(process.env.BUGHUNTER_DEV_BUNDLE_B64 && {
201        BUGHUNTER_DEV_BUNDLE_B64: process.env.BUGHUNTER_DEV_BUNDLE_B64,
202      }),
203    }
204  
205    let session
206    let command
207    let target
208    if (isPrNumber) {
209      // PR mode: refs/pull/N/head via github.com. Orchestrator --pr N.
210      const repo = await detectCurrentRepositoryWithHost()
211      if (!repo || repo.host !== 'github.com') {
212        logEvent('tengu_review_remote_precondition_failed', {})
213        return null
214      }
215      session = await teleportToRemote({
216        initialMessage: null,
217        description: `ultrareview: ${repo.owner}/${repo.name}#${prNumber}`,
218        signal: context.abortController.signal,
219        branchName: `refs/pull/${prNumber}/head`,
220        environmentId: CODE_REVIEW_ENV_ID,
221        environmentVariables: {
222          BUGHUNTER_PR_NUMBER: prNumber,
223          BUGHUNTER_REPOSITORY: `${repo.owner}/${repo.name}`,
224          ...commonEnvVars,
225        },
226      })
227      command = `/ultrareview ${prNumber}`
228      target = `${repo.owner}/${repo.name}#${prNumber}`
229    } else {
230      // Branch mode: bundle the working tree, orchestrator diffs against
231      // the fork point. No PR, no existing comments, no dedup.
232      const baseBranch = (await getDefaultBranch()) || 'main'
233      // Env-manager's `git remote remove origin` after bundle-clone
234      // deletes refs/remotes/origin/* — the base branch name won't resolve
235      // in the container. Pass the merge-base SHA instead: it's reachable
236      // from HEAD's history so `git diff <sha>` works without a named ref.
237      const { stdout: mbOut, code: mbCode } = await execFileNoThrow(
238        gitExe(),
239        ['merge-base', baseBranch, 'HEAD'],
240        { preserveOutputOnError: false },
241      )
242      const mergeBaseSha = mbOut.trim()
243      if (mbCode !== 0 || !mergeBaseSha) {
244        logEvent('tengu_review_remote_precondition_failed', {})
245        return [
246          {
247            type: 'text',
248            text: `Could not find merge-base with ${baseBranch}. Make sure you're in a git repo with a ${baseBranch} branch.`,
249          },
250        ]
251      }
252  
253      // Bail early on empty diffs instead of launching a container that
254      // will just echo "no changes".
255      const { stdout: diffStat, code: diffCode } = await execFileNoThrow(
256        gitExe(),
257        ['diff', '--shortstat', mergeBaseSha],
258        { preserveOutputOnError: false },
259      )
260      if (diffCode === 0 && !diffStat.trim()) {
261        logEvent('tengu_review_remote_precondition_failed', {})
262        return [
263          {
264            type: 'text',
265            text: `No changes against the ${baseBranch} fork point. Make some commits or stage files first.`,
266          },
267        ]
268      }
269  
270      session = await teleportToRemote({
271        initialMessage: null,
272        description: `ultrareview: ${baseBranch}`,
273        signal: context.abortController.signal,
274        useBundle: true,
275        environmentId: CODE_REVIEW_ENV_ID,
276        environmentVariables: {
277          BUGHUNTER_BASE_BRANCH: mergeBaseSha,
278          ...commonEnvVars,
279        },
280      })
281      if (!session) {
282        logEvent('tengu_review_remote_teleport_failed', {})
283        return [
284          {
285            type: 'text',
286            text: 'Repo is too large. Push a PR and use `/ultrareview <PR#>` instead.',
287          },
288        ]
289      }
290      command = '/ultrareview'
291      target = baseBranch
292    }
293  
294    if (!session) {
295      logEvent('tengu_review_remote_teleport_failed', {})
296      return null
297    }
298    registerRemoteAgentTask({
299      remoteTaskType: 'ultrareview',
300      session,
301      command,
302      context,
303      isRemoteReview: true,
304    })
305    logEvent('tengu_review_remote_launched', {})
306    const sessionUrl = getRemoteTaskSessionUrl(session.id)
307    // Concise — the tool-output block is visible to the user, so the model
308    // shouldn't echo the same info. Just enough for Claude to acknowledge the
309    // launch without restating the target/URL (both already printed above).
310    return [
311      {
312        type: 'text',
313        text: `Ultrareview launched for ${target} (~10–20 min, runs in the cloud). Track: ${sessionUrl}${resolvedBillingNote} Findings arrive via task-notification. Briefly acknowledge the launch to the user without repeating the target or URL — both are already visible in the tool output above.`,
314      },
315    ]
316  }