// NOTE: subscribePR (KAIROS_GITHUB_WEBHOOKS feature) is omitted here. // The kairos client is not fully available in this repo. The feature-gated // call is a nice-to-have and safe to skip — teleport + registerRemoteAgentTask // is sufficient for the core autofix flow. import React from 'react' import { feature } from 'bun:bundle' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../../services/analytics/index.js' import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerCompletionChecker, registerCompletionHook, registerContentExtractor, registerRemoteAgentTask, type AutofixPrRemoteTaskMetadata, type BackgroundRemoteSessionPrecondition, } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' import type { LocalJSXCommandCall } from '../../types/command.js' import { detectCurrentRepositoryWithHost } from '../../utils/detectRepository.js' import { teleportToRemote } from '../../utils/teleport.js' import { AutofixProgress } from './AutofixProgress.js' import { createAutofixTeammate } from './inProcessAgent.js' import { clearActiveMonitor, getActiveMonitor, isMonitoring, trySetActiveMonitor, updateActiveMonitor, } from './monitorState.js' import { extractAutofixResultFromLog } from './extractAutofixResult.js' import { parseAutofixArgs } from './parseArgs.js' import { checkPrAutofixOutcome, fetchPrHeadSha } from './prFetch.js' import { detectAutofixSkills, formatSkillsHint } from './skillDetect.js' // Throttle map for the completionChecker: gh CLI is called at most once per // PR per CHECK_INTERVAL_MS, regardless of the framework's 1s poll cadence. // Key is `${owner}/${repo}#${prNumber}`. Cleared when the completion hook // fires so a re-launched monitor starts with a fresh budget. const lastCheckAt = new Map() const CHECK_INTERVAL_MS = 5_000 function throttleKey(meta: AutofixPrRemoteTaskMetadata): string { return `${meta.owner}/${meta.repo}#${meta.prNumber}` } // Register the completionChecker once at module load. The framework calls it // on every poll tick for tasks with remoteTaskType==='autofix-pr'; throttle // inside so we don't fire gh CLI 60×/min. Returns the summary string on // completion (becomes the task-notification body) or null to keep polling. registerCompletionChecker('autofix-pr', async metadata => { const meta = metadata as AutofixPrRemoteTaskMetadata | undefined if (!meta) return null const key = throttleKey(meta) const now = Date.now() if (now - (lastCheckAt.get(key) ?? 0) < CHECK_INTERVAL_MS) return null lastCheckAt.set(key, now) const result = await checkPrAutofixOutcome({ owner: meta.owner, repo: meta.repo, prNumber: meta.prNumber, initialHeadSha: meta.initialHeadSha, }) return result.completed ? result.summary : null }) // Release the singleton monitor lock when the framework transitions the // autofix task to a terminal state. Without this, the lock — keyed by the // framework-assigned taskId (after callAutofixPr's updateActiveMonitor swap) // — would dangle past natural completion, blocking subsequent /autofix-pr // invocations until the process restarts. Registered at module load; the // framework's runCompletionHook invokes it once per terminal transition. // Also clear the per-PR throttle entry so a re-launch starts fresh. registerCompletionHook('autofix-pr', (taskId, metadata) => { clearActiveMonitor(taskId) const meta = metadata as AutofixPrRemoteTaskMetadata | undefined if (meta) lastCheckAt.delete(throttleKey(meta)) }) // Phase 3 content return: extract the tag from the session // log so the local model sees the agent's structured outcome (commits // pushed, files changed, CI status) inline in the completion task- // notification — instead of just a file-path pointer. The framework falls // back to the generic notification if extraction returns null. registerContentExtractor('autofix-pr', log => extractAutofixResultFromLog(log)) function makeErrorText(message: string, code: string): string { logEvent('tengu_autofix_pr_result', { result: 'failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, error_code: code as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return `Autofix PR failed: ${message}` } export const callAutofixPr: LocalJSXCommandCall = async ( onDone, context, args, ) => { try { const parsed = parseAutofixArgs(args) // 1. stop sub-command if (parsed.action === 'stop') { const m = getActiveMonitor() if (!m) { onDone('No active autofix monitor.', { display: 'system' }) return null } clearActiveMonitor() // Honest message: the local lock is released and any in-flight // teleport request is aborted, but a CCR session that has already // started running on the cloud will continue until it completes or is // cancelled from claude.ai/code. onDone( `Stopped local monitoring of ${m.repo}#${m.prNumber}. Any already-running remote session continues until it finishes or is cancelled from claude.ai/code.`, { display: 'system' }, ) return null } // 2. invalid if (parsed.action === 'invalid') { onDone( `Invalid args: ${parsed.reason}. Use /autofix-pr | stop | /#`, { display: 'system', }, ) return null } // 3. freeform — not yet supported if (parsed.action === 'freeform') { onDone( 'Freeform prompt mode not yet supported. Use /autofix-pr .', { display: 'system', }, ) return null } // 4. start. has_repo_path tracks whether the user supplied an explicit // owner/repo via cross-repo syntax (vs relying on directory detection). logEvent('tengu_autofix_pr_started', { action: 'start' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, has_pr_number: 'true' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, has_repo_path: String( !!(parsed.owner && parsed.repo), ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) // 4.1 resolve owner/repo. Always detect cwd repo first because teleport // takes the git source from the working directory; cross-repo args that // don't match cwd would silently work on the wrong repo. let detected: { host: string; owner: string; name: string } | null try { detected = await detectCurrentRepositoryWithHost() } catch { onDone( makeErrorText( 'Cannot detect GitHub repo from current directory.', 'session_create_failed', ), { display: 'system' }, ) return null } if (!detected || detected.host !== 'github.com') { onDone( makeErrorText( 'Cannot detect GitHub repo from current directory.', 'session_create_failed', ), { display: 'system' }, ) return null } // Cross-repo args (owner/repo#n) must match the current working directory; // teleport's git source is taken from cwd, so a mismatch would create a // session against the wrong repo. Accept both as a safety check rather // than as a real cross-repo capability — true cross-repo support requires // a separate clone path not yet implemented here. if ( (parsed.owner && parsed.owner !== detected.owner) || (parsed.repo && parsed.repo !== detected.name) ) { onDone( makeErrorText( `Cross-repo autofix is not supported from this directory. Run from ${detected.owner}/${detected.name} or pass only the PR number.`, 'repo_mismatch', ), { display: 'system' }, ) return null } const owner = detected.owner const repo = detected.name const { prNumber } = parsed // 4.2 singleton lock — already monitoring this exact PR if (isMonitoring(owner, repo, prNumber)) { logEvent('tengu_autofix_pr_result', { result: 'success_rc' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) onDone(`Already monitoring ${repo}#${prNumber} in background.`, { display: 'system', }) return null } // 4.2b note: the existing-different-PR check is folded into the // trySetActiveMonitor call below. Doing the check + set atomically there // avoids a TOCTOU window between the read and the write under concurrent // invocations. // 4.3 eligibility check (tolerate no_remote_environment, surface real reasons). // skipBundle:true matches the teleport call below — autofix needs to push // back to GitHub, which a git bundle cannot do. const eligibility = await checkRemoteAgentEligibility({ skipBundle: true }) if (!eligibility.eligible) { // Discriminated union: TypeScript narrows `eligibility` here, no cast needed. const blockers = eligibility.errors.filter( (e: BackgroundRemoteSessionPrecondition) => e.type !== 'no_remote_environment', ) if (blockers.length > 0) { const reasons = blockers.map(formatPreconditionError).join('\n') onDone( makeErrorText( `Remote agent not available:\n${reasons}`, 'session_create_failed', ), { display: 'system' }, ) return null } } // 4.4 detect skills const skills = detectAutofixSkills(process.cwd()) const skillsHint = formatSkillsHint(skills) // 4.5 compose message const target = `${owner}/${repo}#${prNumber}` const branchName = `refs/pull/${prNumber}/head` const initialMessage = `Auto-fix failing CI checks on PR #${prNumber} in ${owner}/${repo}.${skillsHint} When you finish (or hit a blocker you can't recover from), output the following XML tag as your final message so the local user gets a structured summary: ${prNumber} commit message N changes green | red | pending | unknown One-sentence summary of what was fixed or why it could not be fixed. If no fix was needed, omit and and explain in . If you only attempted partial work, list the commits you did push and explain the remainder in .` // 4.6 in-process teammate const teammate = createAutofixTeammate(initialMessage, target) // 4.7 acquire lock atomically BEFORE doing any awaits. This closes the // TOCTOU race where two concurrent invocations both see active=null and // both try to create remote sessions. const lockAcquired = trySetActiveMonitor({ taskId: teammate.taskId, owner, repo, prNumber, abortController: teammate.abortController, startedAt: Date.now(), }) if (!lockAcquired) { const existing = getActiveMonitor() onDone( makeErrorText( `already monitoring ${existing?.repo}#${existing?.prNumber}. Run /autofix-pr stop first.`, 'rc_already_monitoring_other', ), { display: 'system' }, ) return null } // 4.8 teleport — wire BOTH onBundleFail and onCreateFail so HTTP-layer // failures (4xx/5xx, expired token, invalid PR ref) reach the user with // the upstream message instead of the generic fallback. skipBundle:true // is required for autofix: the remote container must push back to GitHub, // which a bundle-cloned source cannot do (teleport.tsx documents this). // Note: refs/pull//head is not a pushable ref. We do NOT pass // reuseOutcomeBranch — the orchestrator generates a claude/* branch and // the user pushes/PRs from claude.ai/code. let teleportFailMsg: string | undefined const captureFailMsg = (msg: string) => { teleportFailMsg = msg } let session: { id: string; title: string } | null = null try { session = await teleportToRemote({ initialMessage, source: 'autofix_pr', branchName, skipBundle: true, title: `Autofix PR: ${target}`, useDefaultEnvironment: true, signal: teammate.abortController.signal, githubPr: { owner, repo, number: prNumber }, onBundleFail: captureFailMsg, onCreateFail: captureFailMsg, }) } catch (teleErr: unknown) { clearActiveMonitor(teammate.taskId) const teleMsg = teleErr instanceof Error ? teleErr.message : String(teleErr) onDone(makeErrorText(`teleport failed: ${teleMsg}`, 'teleport_failed'), { display: 'system', }) return null } if (!session) { clearActiveMonitor(teammate.taskId) onDone( makeErrorText( teleportFailMsg ?? 'remote session creation failed.', 'session_create_failed', ), { display: 'system' }, ) return null } // 4.8b capture PR head SHA before registering so the completionChecker // can detect when the agent has pushed new commits. Best-effort — if gh // is unavailable or the call fails, leave initialHeadSha undefined and // the checker falls back to terminal-state-only completion (closed / // merged). Don't block on this; teleport succeeded already. const initialHeadSha = (await fetchPrHeadSha(owner, repo, prNumber).catch(() => null)) ?? undefined // 4.9 register task. If this throws, release the lock so the user can // retry — the remote CCR session is already created so we surface a // dedicated error code. // // After registration succeeds, swap the lock's taskId from the tentative // teammate UUID (used to acquire the lock atomically before teleport) to // the framework-assigned taskId. Without this swap, the framework's own // cleanup path (clearActiveMonitor(frameworkTaskId) on natural completion) // would no-op against a lock keyed by teammate.taskId, leaving the // singleton lock dangling and blocking future /autofix-pr invocations. try { const { taskId: frameworkTaskId } = registerRemoteAgentTask({ remoteTaskType: 'autofix-pr', session, command: `/autofix-pr ${prNumber}`, context, isLongRunning: true, remoteTaskMetadata: { owner, repo, prNumber, initialHeadSha }, }) updateActiveMonitor({ taskId: frameworkTaskId }) } catch (regErr: unknown) { clearActiveMonitor(teammate.taskId) const regMsg = regErr instanceof Error ? regErr.message : String(regErr) onDone( makeErrorText( `task registration failed: ${regMsg}`, 'registration_failed', ), { display: 'system' }, ) return null } // 4.10 PR webhook subscription (feature-gated, non-fatal) if (feature('KAIROS_GITHUB_WEBHOOKS')) { // kairos client not available in this repo — skip silently } // 4.11 success const sessionUrl = getRemoteTaskSessionUrl(session.id) logEvent('tengu_autofix_pr_result', { result: 'success_rc' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) // Also call onDone so callers that listen to the callback get notified. onDone(`Autofix launched for ${target}. Track: ${sessionUrl}`, { display: 'system', }) // Return a React progress UI showing the completed pipeline. // The REPL renders the returned React element inline alongside the text. return React.createElement(AutofixProgress, { phase: 'done', target, sessionUrl, }) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) logEvent('tengu_autofix_pr_result', { result: 'failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, error_code: 'exception' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) onDone(`Autofix PR failed: ${msg}`, { display: 'system' }) return null } }