317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
/**
|
||
* Teleported /ultrareview execution. Creates a CCR session with the current repo,
|
||
* sends the review prompt as the initial message, and registers a
|
||
* RemoteAgentTask so the polling loop pipes results back into the local
|
||
* session via task-notification. Mirrors the /ultraplan → CCR flow.
|
||
*
|
||
* TODO(#22051): pass useBundleMode once landed so local-only / uncommitted
|
||
* repo state is captured. The GitHub-clone path (current) only works for
|
||
* pushed branches on repos with the Claude GitHub app installed.
|
||
*/
|
||
|
||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'
|
||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||
import {
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
logEvent,
|
||
} from '../../services/analytics/index.js'
|
||
import { fetchUltrareviewQuota } from '../../services/api/ultrareviewQuota.js'
|
||
import { fetchUtilization } from '../../services/api/usage.js'
|
||
import type { ToolUseContext } from '../../Tool.js'
|
||
import {
|
||
checkRemoteAgentEligibility,
|
||
formatPreconditionError,
|
||
getRemoteTaskSessionUrl,
|
||
registerRemoteAgentTask,
|
||
} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'
|
||
import { isEnterpriseSubscriber, isTeamSubscriber } from '../../utils/auth.js'
|
||
import { detectCurrentRepositoryWithHost } from '../../utils/detectRepository.js'
|
||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||
import { getDefaultBranch, gitExe } from '../../utils/git.js'
|
||
import { teleportToRemote } from '../../utils/teleport.js'
|
||
|
||
// One-time session flag: once the user confirms overage billing via the
|
||
// dialog, all subsequent /ultrareview invocations in this session proceed
|
||
// without re-prompting.
|
||
let sessionOverageConfirmed = false
|
||
|
||
export function confirmOverage(): void {
|
||
sessionOverageConfirmed = true
|
||
}
|
||
|
||
export type OverageGate =
|
||
| { kind: 'proceed'; billingNote: string }
|
||
| { kind: 'not-enabled' }
|
||
| { kind: 'low-balance'; available: number }
|
||
| { kind: 'needs-confirm' }
|
||
|
||
/**
|
||
* Determine whether the user can launch an ultrareview and under what
|
||
* billing terms. Fetches quota and utilization in parallel.
|
||
*/
|
||
export async function checkOverageGate(): Promise<OverageGate> {
|
||
// Team and Enterprise plans include ultrareview — no free-review quota
|
||
// or Extra Usage dialog. The quota endpoint is scoped to consumer plans
|
||
// (pro/max); hitting it on team/ent would surface a confusing dialog.
|
||
if (isTeamSubscriber() || isEnterpriseSubscriber()) {
|
||
return { kind: 'proceed', billingNote: '' }
|
||
}
|
||
|
||
const [quota, utilization] = await Promise.all([
|
||
fetchUltrareviewQuota(),
|
||
fetchUtilization().catch(() => null),
|
||
])
|
||
|
||
// No quota info (non-subscriber or endpoint down) — let it through,
|
||
// server-side billing will handle it.
|
||
if (!quota) {
|
||
return { kind: 'proceed', billingNote: '' }
|
||
}
|
||
|
||
if (quota.reviews_remaining > 0) {
|
||
return {
|
||
kind: 'proceed',
|
||
billingNote: ` This is free ultrareview ${quota.reviews_used + 1} of ${quota.reviews_limit}.`,
|
||
}
|
||
}
|
||
|
||
// Utilization fetch failed (transient network error, timeout, etc.) —
|
||
// let it through, same rationale as the quota fallback above.
|
||
if (!utilization) {
|
||
return { kind: 'proceed', billingNote: '' }
|
||
}
|
||
|
||
// Free reviews exhausted — check Extra Usage setup.
|
||
const extraUsage = utilization.extra_usage
|
||
if (!extraUsage?.is_enabled) {
|
||
logEvent('tengu_review_overage_not_enabled', {})
|
||
return { kind: 'not-enabled' }
|
||
}
|
||
|
||
// Check available balance (null monthly_limit = unlimited).
|
||
const monthlyLimit = extraUsage.monthly_limit
|
||
const usedCredits = extraUsage.used_credits ?? 0
|
||
const available =
|
||
monthlyLimit === null || monthlyLimit === undefined
|
||
? Infinity
|
||
: monthlyLimit - usedCredits
|
||
|
||
if (available < 10) {
|
||
logEvent('tengu_review_overage_low_balance', { available })
|
||
return { kind: 'low-balance', available }
|
||
}
|
||
|
||
if (!sessionOverageConfirmed) {
|
||
logEvent('tengu_review_overage_dialog_shown', {})
|
||
return { kind: 'needs-confirm' }
|
||
}
|
||
|
||
return {
|
||
kind: 'proceed',
|
||
billingNote: ' This review bills as Extra Usage.',
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Launch a teleported review session. Returns ContentBlockParam[] describing
|
||
* the launch outcome for injection into the local conversation (model is then
|
||
* queried with this content, so it can narrate the launch to the user).
|
||
*
|
||
* Returns ContentBlockParam[] with user-facing error messages on recoverable
|
||
* failures (missing merge-base, empty diff, bundle too large), or null on
|
||
* other failures so the caller falls through to the local-review prompt.
|
||
* Reason is captured in analytics.
|
||
*
|
||
* Caller must run checkOverageGate() BEFORE calling this function
|
||
* (ultrareviewCommand.tsx handles the dialog).
|
||
*/
|
||
export async function launchRemoteReview(
|
||
args: string,
|
||
context: ToolUseContext,
|
||
billingNote?: string,
|
||
): Promise<ContentBlockParam[] | null> {
|
||
const eligibility = await checkRemoteAgentEligibility()
|
||
// Synthetic DEFAULT_CODE_REVIEW_ENVIRONMENT_ID works without per-org CCR
|
||
// setup, so no_remote_environment isn't a blocker. Server-side quota
|
||
// consume at session creation routes billing: first N zero-rate, then
|
||
// anthropic:cccr org-service-key (overage-only).
|
||
if (!eligibility.eligible) {
|
||
const blockers = eligibility.errors.filter(
|
||
e => e.type !== 'no_remote_environment',
|
||
)
|
||
if (blockers.length > 0) {
|
||
logEvent('tengu_review_remote_precondition_failed', {
|
||
precondition_errors: blockers
|
||
.map(e => e.type)
|
||
.join(
|
||
',',
|
||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
const reasons = blockers.map(formatPreconditionError).join('\n')
|
||
return [
|
||
{
|
||
type: 'text',
|
||
text: `Ultrareview cannot launch:\n${reasons}`,
|
||
},
|
||
]
|
||
}
|
||
}
|
||
|
||
const resolvedBillingNote = billingNote ?? ''
|
||
|
||
const prNumber = args.trim()
|
||
const isPrNumber = /^\d+$/.test(prNumber)
|
||
// Synthetic code_review env. Go taggedid.FromUUID(TagEnvironment,
|
||
// UUID{...,0x02}) encodes with version prefix '01' — NOT Python's
|
||
// legacy tagged_id() format. Verified in prod.
|
||
const CODE_REVIEW_ENV_ID = 'env_011111111111111111111113'
|
||
// Lite-review bypasses bughunter.go entirely, so it doesn't see the
|
||
// webhook's bug_hunter_config (different GB project). These env vars are
|
||
// the only tuning surface — without them, run_hunt.sh's bash defaults
|
||
// apply (60min, 120s agent timeout), and 120s kills verifiers mid-run
|
||
// which causes infinite respawn.
|
||
//
|
||
// total_wallclock must stay below RemoteAgentTask's 30min poll timeout
|
||
// with headroom for finalization (~3min synthesis). Per-field guards
|
||
// match autoDream.ts — GB cache can return stale wrong-type values.
|
||
const raw = getFeatureValue_CACHED_MAY_BE_STALE<Record<
|
||
string,
|
||
unknown
|
||
> | null>('tengu_review_bughunter_config', null)
|
||
const posInt = (v: unknown, fallback: number, max?: number): number => {
|
||
if (typeof v !== 'number' || !Number.isFinite(v)) return fallback
|
||
const n = Math.floor(v)
|
||
if (n <= 0) return fallback
|
||
return max !== undefined && n > max ? fallback : n
|
||
}
|
||
// Upper bounds: 27min on wallclock leaves ~3min for finalization under
|
||
// RemoteAgentTask's 30min poll timeout. If GB is set above that, the
|
||
// hang we're fixing comes back — fall to the safe default instead.
|
||
const commonEnvVars = {
|
||
BUGHUNTER_DRY_RUN: '1',
|
||
BUGHUNTER_FLEET_SIZE: String(posInt(raw?.fleet_size, 5, 20)),
|
||
BUGHUNTER_MAX_DURATION: String(posInt(raw?.max_duration_minutes, 10, 25)),
|
||
BUGHUNTER_AGENT_TIMEOUT: String(
|
||
posInt(raw?.agent_timeout_seconds, 600, 1800),
|
||
),
|
||
BUGHUNTER_TOTAL_WALLCLOCK: String(
|
||
posInt(raw?.total_wallclock_minutes, 22, 27),
|
||
),
|
||
...(process.env.BUGHUNTER_DEV_BUNDLE_B64 && {
|
||
BUGHUNTER_DEV_BUNDLE_B64: process.env.BUGHUNTER_DEV_BUNDLE_B64,
|
||
}),
|
||
}
|
||
|
||
let session
|
||
let command
|
||
let target
|
||
if (isPrNumber) {
|
||
// PR mode: refs/pull/N/head via github.com. Orchestrator --pr N.
|
||
const repo = await detectCurrentRepositoryWithHost()
|
||
if (!repo || repo.host !== 'github.com') {
|
||
logEvent('tengu_review_remote_precondition_failed', {})
|
||
return null
|
||
}
|
||
session = await teleportToRemote({
|
||
initialMessage: null,
|
||
description: `ultrareview: ${repo.owner}/${repo.name}#${prNumber}`,
|
||
signal: context.abortController.signal,
|
||
branchName: `refs/pull/${prNumber}/head`,
|
||
environmentId: CODE_REVIEW_ENV_ID,
|
||
environmentVariables: {
|
||
BUGHUNTER_PR_NUMBER: prNumber,
|
||
BUGHUNTER_REPOSITORY: `${repo.owner}/${repo.name}`,
|
||
...commonEnvVars,
|
||
},
|
||
})
|
||
command = `/ultrareview ${prNumber}`
|
||
target = `${repo.owner}/${repo.name}#${prNumber}`
|
||
} else {
|
||
// Branch mode: bundle the working tree, orchestrator diffs against
|
||
// the fork point. No PR, no existing comments, no dedup.
|
||
const baseBranch = (await getDefaultBranch()) || 'main'
|
||
// Env-manager's `git remote remove origin` after bundle-clone
|
||
// deletes refs/remotes/origin/* — the base branch name won't resolve
|
||
// in the container. Pass the merge-base SHA instead: it's reachable
|
||
// from HEAD's history so `git diff <sha>` works without a named ref.
|
||
const { stdout: mbOut, code: mbCode } = await execFileNoThrow(
|
||
gitExe(),
|
||
['merge-base', baseBranch, 'HEAD'],
|
||
{ preserveOutputOnError: false },
|
||
)
|
||
const mergeBaseSha = mbOut.trim()
|
||
if (mbCode !== 0 || !mergeBaseSha) {
|
||
logEvent('tengu_review_remote_precondition_failed', {})
|
||
return [
|
||
{
|
||
type: 'text',
|
||
text: `Could not find merge-base with ${baseBranch}. Make sure you're in a git repo with a ${baseBranch} branch.`,
|
||
},
|
||
]
|
||
}
|
||
|
||
// Bail early on empty diffs instead of launching a container that
|
||
// will just echo "no changes".
|
||
const { stdout: diffStat, code: diffCode } = await execFileNoThrow(
|
||
gitExe(),
|
||
['diff', '--shortstat', mergeBaseSha],
|
||
{ preserveOutputOnError: false },
|
||
)
|
||
if (diffCode === 0 && !diffStat.trim()) {
|
||
logEvent('tengu_review_remote_precondition_failed', {})
|
||
return [
|
||
{
|
||
type: 'text',
|
||
text: `No changes against the ${baseBranch} fork point. Make some commits or stage files first.`,
|
||
},
|
||
]
|
||
}
|
||
|
||
session = await teleportToRemote({
|
||
initialMessage: null,
|
||
description: `ultrareview: ${baseBranch}`,
|
||
signal: context.abortController.signal,
|
||
useBundle: true,
|
||
environmentId: CODE_REVIEW_ENV_ID,
|
||
environmentVariables: {
|
||
BUGHUNTER_BASE_BRANCH: mergeBaseSha,
|
||
...commonEnvVars,
|
||
},
|
||
})
|
||
if (!session) {
|
||
logEvent('tengu_review_remote_teleport_failed', {})
|
||
return [
|
||
{
|
||
type: 'text',
|
||
text: 'Repo is too large. Push a PR and use `/ultrareview <PR#>` instead.',
|
||
},
|
||
]
|
||
}
|
||
command = '/ultrareview'
|
||
target = baseBranch
|
||
}
|
||
|
||
if (!session) {
|
||
logEvent('tengu_review_remote_teleport_failed', {})
|
||
return null
|
||
}
|
||
registerRemoteAgentTask({
|
||
remoteTaskType: 'ultrareview',
|
||
session,
|
||
command,
|
||
context,
|
||
isRemoteReview: true,
|
||
})
|
||
logEvent('tengu_review_remote_launched', {})
|
||
const sessionUrl = getRemoteTaskSessionUrl(session.id)
|
||
// Concise — the tool-output block is visible to the user, so the model
|
||
// shouldn't echo the same info. Just enough for Claude to acknowledge the
|
||
// launch without restating the target/URL (both already printed above).
|
||
return [
|
||
{
|
||
type: 'text',
|
||
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.`,
|
||
},
|
||
]
|
||
}
|