mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
1302 lines
43 KiB
TypeScript
1302 lines
43 KiB
TypeScript
/* eslint-disable max-lines -- Why: local and SSH generation share cancellation,
|
|
spawn failure handling, and output normalization; keeping them together
|
|
prevents those paths from drifting. */
|
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import type { GlobalSettings } from '../../shared/global-settings-types'
|
|
import type { Repo } from '../../shared/repo-types'
|
|
import type { TuiAgent } from '../../shared/tui-agent'
|
|
import {
|
|
buildCommitMessagePrompt,
|
|
splitGeneratedCommitMessage,
|
|
type CommitMessageDraftContext,
|
|
type GeneratedCommitMessage
|
|
} from '../../shared/commit-message-generation'
|
|
import {
|
|
buildPullRequestFieldsPrompt,
|
|
parseGeneratedPullRequestFields,
|
|
type GeneratedPullRequestFields,
|
|
type PullRequestDraftContext
|
|
} from '../../shared/pull-request-generation'
|
|
import {
|
|
cleanGeneratedCommitMessage,
|
|
excerptAgentFailureOutput
|
|
} from '../../shared/commit-message-prompt'
|
|
import {
|
|
captureAgentGenerationFailureOutput,
|
|
type AgentGenerationFailureOutput
|
|
} from './agent-failure-output'
|
|
import {
|
|
buildBranchNamePrompt,
|
|
sanitizeBranchSlug,
|
|
type BranchNameWorkContext
|
|
} from '../../shared/branch-name-from-work'
|
|
import type {
|
|
CommitMessageAgentCapability,
|
|
CommitMessageModelCapability
|
|
} from '../../shared/commit-message-agent-spec'
|
|
import {
|
|
getAgentModelProbeSpec,
|
|
type AgentModelProbeSpec
|
|
} from '../../shared/agent-model-probe-spec'
|
|
import { PI_COMPATIBILITY_MODEL, PI_DEFAULT_MODEL_ID } from '../../shared/commit-message-agent-spec'
|
|
import {
|
|
planAgentBinary,
|
|
planCommitMessageGeneration,
|
|
type CommitMessagePlan
|
|
} from '../../shared/commit-message-plan'
|
|
import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt'
|
|
import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key'
|
|
import {
|
|
resolveSourceControlAiForOperation,
|
|
type ResolvedSourceControlAiGenerationParams
|
|
} from '../../shared/source-control-ai'
|
|
import type { SourceControlAiOperation } from '../../shared/source-control-ai-types'
|
|
import { formatLinkedIssueTemplateValue } from '../../shared/source-control-ai-action-variables'
|
|
import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions'
|
|
import { resolveCliCommand } from '../codex-cli/command'
|
|
import {
|
|
resolveCodexHomeProcessLockKeyForSpawnEnv,
|
|
withCodexHomeProcessLock
|
|
} from '../codex-cli/codex-home-process-lock'
|
|
import {
|
|
getSpawnArgsForWindows,
|
|
UnsafeWindowsBatchArgumentsError,
|
|
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR
|
|
} from '../win32-utils'
|
|
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
|
|
import { wslAwareSpawn } from '../git/runner'
|
|
import { terminateWindowsProcessTree } from '../windows-process-tree-kill'
|
|
import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer'
|
|
|
|
const GENERATION_TIMEOUT_MS = 60_000
|
|
const MAX_AGENT_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
|
|
export type GenerateCommitMessageParams = ResolvedSourceControlAiGenerationParams
|
|
|
|
export type GenerateCommitMessageResult =
|
|
| { success: true; message: string; agentLabel?: string }
|
|
| { success: false; error: string; canceled?: boolean }
|
|
|
|
export type DiscoverCommitMessageModelsResult =
|
|
| {
|
|
success: true
|
|
capability: CommitMessageAgentCapability
|
|
models: CommitMessageModelCapability[]
|
|
defaultModelId: string
|
|
catalogOrigin: 'probe' | 'spec'
|
|
}
|
|
| { success: false; error: string }
|
|
|
|
export type GeneratePullRequestFieldsResult =
|
|
| {
|
|
success: true
|
|
fields: GeneratedPullRequestFields
|
|
agentLabel?: string
|
|
branchChangedByPreparation?: boolean
|
|
}
|
|
| { success: false; error: string; canceled?: boolean; branchChangedByPreparation?: boolean }
|
|
|
|
export type RemoteCommitMessageExecResult = {
|
|
stdout: string
|
|
stderr: string
|
|
exitCode: number | null
|
|
timedOut: boolean
|
|
canceled?: boolean
|
|
spawnError?: string
|
|
}
|
|
|
|
export type TextGenerationOperation = 'commit-message' | 'pull-request-fields' | 'branch-name'
|
|
|
|
export type CommitMessageGenerationTarget =
|
|
| { kind: 'local'; cwd: string; env?: NodeJS.ProcessEnv; wslDistro?: string }
|
|
| {
|
|
kind: 'remote'
|
|
cwd: string
|
|
execute: (
|
|
plan: CommitMessagePlan,
|
|
cwd: string,
|
|
timeoutMs: number,
|
|
operation: TextGenerationOperation
|
|
) => Promise<RemoteCommitMessageExecResult>
|
|
missingBinaryLocation: string
|
|
}
|
|
|
|
type ResolveCommitMessageSettingsResult =
|
|
| { ok: true; params: GenerateCommitMessageParams }
|
|
| { ok: false; error: string }
|
|
|
|
type InternalTextGenerationResult =
|
|
| { success: true; rawOutput: string; agentLabel?: string }
|
|
| {
|
|
success: false
|
|
error: string
|
|
canceled?: boolean
|
|
/** Bounded full CLI output for on-demand local display. Stripped from
|
|
* every renderer-bound result so it never crosses IPC wholesale. */
|
|
failureOutput?: AgentGenerationFailureOutput
|
|
}
|
|
|
|
type LocalProcessExecution<T> = {
|
|
result: Promise<T>
|
|
processClosed: Promise<void>
|
|
}
|
|
|
|
export type CommitMessageModelDiscoveryLocalOptions = {
|
|
cwd?: string
|
|
wslDistro?: string
|
|
}
|
|
|
|
export function trimGeneratedCommitMessage(message: string): string {
|
|
return message.replace(/\s+$/, '')
|
|
}
|
|
|
|
export function resolveCommitMessageSettings(
|
|
settings: GlobalSettings,
|
|
discoveryHostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY,
|
|
operation: SourceControlAiOperation = 'commitMessage',
|
|
repo?: Pick<Repo, 'sourceControlAi'> | null
|
|
): ResolveCommitMessageSettingsResult {
|
|
const resolved = resolveSourceControlAiForOperation({
|
|
settings,
|
|
repo,
|
|
operation,
|
|
discoveryHostKey
|
|
})
|
|
return resolved.ok ? { ok: true, params: resolved.value.params } : resolved
|
|
}
|
|
|
|
export function resolveTextGenerationParams(
|
|
settings: GlobalSettings,
|
|
discoveryHostKey = LOCAL_COMMIT_MESSAGE_HOST_KEY,
|
|
operation: SourceControlAiOperation = 'commitMessage',
|
|
repo?: Pick<Repo, 'sourceControlAi'> | null
|
|
): ResolveCommitMessageSettingsResult {
|
|
return resolveCommitMessageSettings(settings, discoveryHostKey, operation, repo)
|
|
}
|
|
|
|
function formatAgentCliFailureMessage(
|
|
label: string,
|
|
stdout: string,
|
|
stderr: string,
|
|
exitCode: number | null,
|
|
options?: { includeLocalMacDnsHint?: boolean; includeStdoutDetail?: boolean }
|
|
): string {
|
|
const detail = sanitizeAgentFailureDetail(
|
|
excerptAgentFailureOutput(options?.includeStdoutDetail === false ? '' : stdout, stderr)
|
|
)
|
|
const message =
|
|
exitCode === null
|
|
? detail
|
|
? `${label} CLI command was terminated before exiting: ${detail}`
|
|
: `${label} CLI command was terminated before exiting.`
|
|
: detail
|
|
? `${label} CLI command failed with code ${exitCode}: ${detail}`
|
|
: `${label} CLI command failed with code ${exitCode}.`
|
|
return options?.includeLocalMacDnsHint === false
|
|
? message
|
|
: withMacTailscaleDnsHint(message, detail)
|
|
}
|
|
|
|
function sanitizeAgentFailureDetail(detail: string | null): string | null {
|
|
// Cf covers bidi overrides (U+202E etc.) that could visually reorder the
|
|
// persisted, client-synced detail.
|
|
const trimmed = detail
|
|
?.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
// Why: agent stderr often includes local or SSH repo paths. Persisting those
|
|
// into worktree metadata leaks environment details into synced renderer state.
|
|
const redacted = trimmed
|
|
.replace(
|
|
/\\\\[^\s"'`<>\\]+\\(?:[^\s"'`<>\\]+(?:\s+[^\s"'`<>\\]+)*(?=\\)\\)*[^\s"'`<>\\]+/g,
|
|
'[path]'
|
|
)
|
|
// Only backslashes may repeat: JSON provider bodies double them
|
|
// (`C:\\Users\\name\\…`), while a URL's `://` must stay single so remedy
|
|
// links like `https://…` survive redaction.
|
|
.replace(
|
|
/[A-Za-z]:(?:\\+|\/)(?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])(?:\\+|\/))*[^\s"'`<>\\/|:*?]+/g,
|
|
'[path]'
|
|
)
|
|
// Why: require ≥2 segments (one internal `/`) so provider remedy tokens like
|
|
// `/login` survive while multi-segment paths (`/Users/name/repo`) still redact.
|
|
// `=:,` prefixes catch key=/path value:/path list,/path shapes in provider bodies.
|
|
.replace(
|
|
/(^|[\s"'`(=:,])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)+[^\s"'`<>/]+/g,
|
|
'$1[path]'
|
|
)
|
|
return redacted.length > 240 ? `${redacted.slice(0, 240).trimEnd()}...` : redacted
|
|
}
|
|
|
|
function userFacingUnsafeWindowsBatchArgs(label: string): string {
|
|
return `${label} cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.`
|
|
}
|
|
|
|
function toModelDiscoveryCapability(
|
|
spec: AgentModelProbeSpec,
|
|
models = spec.models,
|
|
defaultModelId = spec.defaultModelId,
|
|
catalogOrigin: 'probe' | 'spec' = 'spec'
|
|
): Extract<DiscoverCommitMessageModelsResult, { success: true }> {
|
|
return {
|
|
success: true,
|
|
capability: {
|
|
id: spec.id,
|
|
label: spec.label,
|
|
modelSource: spec.modelSource,
|
|
defaultModelId,
|
|
models
|
|
},
|
|
models,
|
|
defaultModelId,
|
|
catalogOrigin
|
|
}
|
|
}
|
|
|
|
function finalizeModelDiscoveryOutput(
|
|
spec: AgentModelProbeSpec,
|
|
stdout: string,
|
|
stderr: string,
|
|
code: number | null
|
|
): DiscoverCommitMessageModelsResult {
|
|
if (code !== 0) {
|
|
console.error('[commit-message] Model discovery failed:', {
|
|
label: spec.label,
|
|
exitCode: code,
|
|
stdout,
|
|
stderr
|
|
})
|
|
return {
|
|
success: false,
|
|
error: formatAgentCliFailureMessage(spec.label, stdout, stderr, code)
|
|
}
|
|
}
|
|
let models = spec.modelDiscovery?.parse(stdout) ?? []
|
|
if (models.length === 0 && stderr.trim()) {
|
|
// Why: Pi currently writes its successful `--list-models` table to stderr,
|
|
// so exit code 0 must still allow stderr-backed discovery.
|
|
models = spec.modelDiscovery?.parse(stderr) ?? []
|
|
}
|
|
if (models.length === 0) {
|
|
if (spec.id === 'pi') {
|
|
return toModelDiscoveryCapability(spec, [PI_COMPATIBILITY_MODEL], PI_COMPATIBILITY_MODEL.id)
|
|
}
|
|
if (spec.models.length > 0) {
|
|
console.warn('[commit-message] Model discovery returned no models; using static fallback:', {
|
|
label: spec.label
|
|
})
|
|
return toModelDiscoveryCapability(spec, spec.models, spec.defaultModelId)
|
|
}
|
|
return { success: false, error: `${spec.label} returned no available models.` }
|
|
}
|
|
models = models.filter((model) => model.id !== PI_DEFAULT_MODEL_ID)
|
|
const cliDefault = spec.models.find(
|
|
(model) => model.id === spec.defaultModelId && model.isDefault
|
|
)
|
|
if (spec.id !== 'pi' && cliDefault && !models.some((model) => model.id === cliDefault.id)) {
|
|
models = [cliDefault, ...models]
|
|
}
|
|
const defaultModelId = models.some((model) => model.id === spec.defaultModelId)
|
|
? spec.defaultModelId
|
|
: models[0].id
|
|
return toModelDiscoveryCapability(spec, models, defaultModelId, 'probe')
|
|
}
|
|
|
|
function planModelDiscovery(
|
|
spec: AgentModelProbeSpec,
|
|
agentCommandOverride?: string,
|
|
backslash: CommandTemplateBackslash = 'escape'
|
|
): { ok: true; plan: CommitMessagePlan } | { ok: false; error: string } {
|
|
const modelDiscovery = spec.modelDiscovery
|
|
if (!modelDiscovery) {
|
|
return { ok: false, error: `${spec.label} does not support dynamic model discovery.` }
|
|
}
|
|
const command = planAgentBinary(modelDiscovery.binary, agentCommandOverride, backslash)
|
|
if (!command.ok) {
|
|
return command
|
|
}
|
|
return {
|
|
ok: true,
|
|
plan: {
|
|
binary: command.binary,
|
|
args: [...command.prefixArgs, ...modelDiscovery.args],
|
|
stdinPayload: modelDiscovery.stdinPayload ?? null,
|
|
label: spec.label
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function discoverCommitMessageModelsLocal(
|
|
agentId: TuiAgent,
|
|
env: NodeJS.ProcessEnv | undefined,
|
|
agentCommandOverride?: string,
|
|
options: CommitMessageModelDiscoveryLocalOptions = {}
|
|
): Promise<DiscoverCommitMessageModelsResult> {
|
|
const spec = getAgentModelProbeSpec(agentId)
|
|
if (!spec) {
|
|
return { success: false, error: `Agent "${agentId}" does not support model discovery.` }
|
|
}
|
|
|
|
if (spec.modelSource === 'static' || !spec.modelDiscovery) {
|
|
return toModelDiscoveryCapability(spec)
|
|
}
|
|
|
|
const startDiscovery = (): LocalProcessExecution<DiscoverCommitMessageModelsResult> => {
|
|
let markProcessClosed!: () => void
|
|
const processClosed = new Promise<void>((resolve) => {
|
|
markProcessClosed = resolve
|
|
})
|
|
const result = new Promise<DiscoverCommitMessageModelsResult>((resolve) => {
|
|
let child: ChildProcess
|
|
const spawnEnv = env ?? process.env
|
|
let discoveryStdin: string | null = null
|
|
try {
|
|
const planned = planModelDiscovery(
|
|
spec,
|
|
agentCommandOverride,
|
|
commandBackslashMode({
|
|
kind: 'local',
|
|
cwd: options.cwd ?? '',
|
|
wslDistro: options.wslDistro
|
|
})
|
|
)
|
|
if (!planned.ok) {
|
|
markProcessClosed()
|
|
resolve({ success: false, error: planned.error })
|
|
return
|
|
}
|
|
discoveryStdin = planned.plan.stdinPayload
|
|
const stdinMode = discoveryStdin === null ? 'ignore' : 'pipe'
|
|
if (process.platform === 'win32' && options.wslDistro) {
|
|
child = wslAwareSpawn(planned.plan.binary, planned.plan.args, {
|
|
cwd: options.cwd,
|
|
env: buildWslLauncherEnv(env),
|
|
stdio: [stdinMode, 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
wslDistro: options.wslDistro,
|
|
useWslLoginShell: true
|
|
})
|
|
} else {
|
|
const resolvedBinary =
|
|
process.platform === 'win32'
|
|
? resolveCliCommand(planned.plan.binary, {
|
|
pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null
|
|
})
|
|
: planned.plan.binary
|
|
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args)
|
|
child = spawn(spawnCmd, spawnArgs, {
|
|
env: spawnEnv,
|
|
stdio: [stdinMode, 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
}
|
|
if (discoveryStdin !== null) {
|
|
// Why: a CLI that rejects the args exits before reading stdin; the
|
|
// resulting EPIPE must surface as exit-code fallback, not a crash.
|
|
child.stdin?.on?.('error', () => {})
|
|
child.stdin?.end(discoveryStdin)
|
|
}
|
|
} catch (error) {
|
|
markProcessClosed()
|
|
console.error('[commit-message] Failed to spawn model discovery:', error)
|
|
resolve({
|
|
success: false,
|
|
error: `${spec.label} model discovery could not be started. Check the agent CLI configuration and try again.`
|
|
})
|
|
return
|
|
}
|
|
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let outputLimitExceeded = false
|
|
let settled = false
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let terminationComplete: Promise<void> | null = null
|
|
let detachChildListeners = (): void => {}
|
|
const startTermination = (): void => {
|
|
terminationComplete ??= killProcessTree(child)
|
|
}
|
|
const markClosedAfterTermination = (): void => {
|
|
void (terminationComplete ?? Promise.resolve()).then(markProcessClosed)
|
|
}
|
|
const finish = (result: DiscoverCommitMessageModelsResult): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
detachChildListeners()
|
|
if (agentId !== 'codex') {
|
|
markProcessClosed()
|
|
}
|
|
resolve(result)
|
|
}
|
|
timer = setTimeout(() => {
|
|
startTermination()
|
|
finish({
|
|
success: false,
|
|
error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
|
|
})
|
|
}, GENERATION_TIMEOUT_MS)
|
|
|
|
const onData = (chunk: Buffer, append: (text: string) => void): void => {
|
|
if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) {
|
|
outputLimitExceeded = true
|
|
startTermination()
|
|
finish({ success: false, error: `${spec.label} returned too much model data.` })
|
|
return
|
|
}
|
|
append(chunk.toString('utf-8'))
|
|
}
|
|
|
|
const onStdoutData = (chunk: Buffer): void => onData(chunk, (text) => (stdout += text))
|
|
const onStderrData = (chunk: Buffer): void => onData(chunk, (text) => (stderr += text))
|
|
const onError = (error: Error): void => {
|
|
if (!child.pid) {
|
|
markProcessClosed()
|
|
}
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
finish({
|
|
success: false,
|
|
error: `${spec.modelDiscovery?.binary ?? spec.binary} not found on PATH. Install ${spec.label} to discover models.`
|
|
})
|
|
return
|
|
}
|
|
finish({
|
|
success: false,
|
|
error: `${spec.label} model discovery failed to start. Check the agent CLI configuration and try again.`
|
|
})
|
|
}
|
|
const onClose = (code: number | null): void => {
|
|
markClosedAfterTermination()
|
|
if (outputLimitExceeded) {
|
|
finish({ success: false, error: `${spec.label} returned too much model data.` })
|
|
return
|
|
}
|
|
if (code !== 0) {
|
|
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
|
|
return
|
|
}
|
|
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
|
|
}
|
|
|
|
child.stdout?.on('data', onStdoutData)
|
|
child.stderr?.on('data', onStderrData)
|
|
if (agentId === 'codex') {
|
|
// Result publication stays prompt while the home lock follows the
|
|
// process lifetime after asynchronous timeout/output-limit kills.
|
|
// Why: 'close' also waits on descendants that inherited this child's
|
|
// stdio, so a surviving MCP helper would hold the home forever; at
|
|
// 'exit' the codex process is gone and can no longer rotate auth.json.
|
|
child.once('exit', markClosedAfterTermination)
|
|
child.once('close', markClosedAfterTermination)
|
|
}
|
|
child.on('error', onError)
|
|
child.on('close', onClose)
|
|
detachChildListeners = () => {
|
|
child.stdout?.off?.('data', onStdoutData)
|
|
child.stderr?.off?.('data', onStderrData)
|
|
child.off?.('error', onError)
|
|
child.off?.('close', onClose)
|
|
}
|
|
})
|
|
return { result, processClosed }
|
|
}
|
|
|
|
if (agentId === 'codex') {
|
|
// Why: discovery spawns a real codex process in the selected home; keep it
|
|
// off an auth.json a quota probe may be refreshing at the same time.
|
|
return runCodexProcessWithHomeLock(
|
|
resolveCodexHomeProcessLockKeyForSpawnEnv(env, options.wslDistro),
|
|
startDiscovery
|
|
)
|
|
}
|
|
return startDiscovery().result
|
|
}
|
|
|
|
export async function discoverCommitMessageModelsRemote(
|
|
agentId: TuiAgent,
|
|
cwd: string,
|
|
execute: (
|
|
plan: CommitMessagePlan,
|
|
cwd: string,
|
|
timeoutMs: number
|
|
) => Promise<RemoteCommitMessageExecResult>,
|
|
agentCommandOverride?: string
|
|
): Promise<DiscoverCommitMessageModelsResult> {
|
|
const spec = getAgentModelProbeSpec(agentId)
|
|
if (!spec) {
|
|
return { success: false, error: `Agent "${agentId}" does not support model discovery.` }
|
|
}
|
|
if (spec.modelSource === 'static' || !spec.modelDiscovery) {
|
|
return toModelDiscoveryCapability(spec)
|
|
}
|
|
const planned = planModelDiscovery(spec, agentCommandOverride)
|
|
if (!planned.ok) {
|
|
return { success: false, error: planned.error }
|
|
}
|
|
let result: RemoteCommitMessageExecResult
|
|
try {
|
|
result = await execute(planned.plan, cwd, GENERATION_TIMEOUT_MS)
|
|
} catch (error) {
|
|
console.error('[commit-message] Remote model discovery request failed:', error)
|
|
if (isSshMuxRequestTimeoutError(error)) {
|
|
return {
|
|
success: false,
|
|
error: `${spec.label} model discovery took longer than ${GENERATION_TIMEOUT_MS / 1000}s and may still be running on the remote host.`
|
|
}
|
|
}
|
|
return {
|
|
success: false,
|
|
error: `${spec.label} model discovery could not be reached on the remote PATH. Try again after the SSH connection recovers.`
|
|
}
|
|
}
|
|
if (result.spawnError) {
|
|
if (result.spawnError === WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) {
|
|
return { success: false, error: userFacingUnsafeWindowsBatchArgs(spec.label) }
|
|
}
|
|
if (/ENOENT/i.test(result.spawnError)) {
|
|
return {
|
|
success: false,
|
|
error: `${planned.plan.binary} not found on the remote PATH. Install ${spec.label} there.`
|
|
}
|
|
}
|
|
console.error('[commit-message] Remote model discovery spawn failed:', result.spawnError)
|
|
return {
|
|
success: false,
|
|
error: `${spec.label} model discovery could not be started on the remote PATH. Check the agent command there and try again.`
|
|
}
|
|
}
|
|
if (result.canceled) {
|
|
return { success: false, error: 'Model discovery canceled.' }
|
|
}
|
|
if (result.timedOut) {
|
|
return {
|
|
success: false,
|
|
error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
|
|
}
|
|
}
|
|
return finalizeModelDiscoveryOutput(spec, result.stdout, result.stderr, result.exitCode)
|
|
}
|
|
|
|
// Why: on Windows, npm-installed CLIs like `claude` and `codex` are usually
|
|
// `.cmd` shims. We route those through cmd.exe so Node can launch them, and
|
|
// `child.kill()` would only terminate the wrapper. `taskkill /T /F` walks the
|
|
// process tree from the wrapper PID and force-kills every descendant, which is
|
|
// what users expect when they hit "stop generating".
|
|
function killProcessTree(child: ChildProcess): Promise<void> {
|
|
const pid = child.pid
|
|
if (!pid) {
|
|
return Promise.resolve()
|
|
}
|
|
if (process.platform === 'win32') {
|
|
return terminateWindowsProcessTree(pid)
|
|
}
|
|
try {
|
|
child.kill('SIGKILL')
|
|
} catch {
|
|
// The child may have already exited between the in-flight check and the
|
|
// kill - that race is benign and can be ignored.
|
|
}
|
|
return Promise.resolve()
|
|
}
|
|
|
|
// Keying by operation plus `local:${cwd}` keeps local cancellation independent
|
|
// from SSH worktrees and from other generation features in the same worktree.
|
|
const cancelTokensByLane = new Map<string, () => void>()
|
|
const WSL_LAUNCHER_ENV_KEYS = [
|
|
'ComSpec',
|
|
'COMSPEC',
|
|
'Path',
|
|
'PATH',
|
|
'PATHEXT',
|
|
'SystemRoot',
|
|
'TEMP',
|
|
'TMP',
|
|
'WINDIR'
|
|
] as const
|
|
|
|
function localLaneKey(operation: TextGenerationOperation, cwd: string): string {
|
|
return `${operation}:local:${cwd}`
|
|
}
|
|
|
|
export function cancelGenerateCommitMessageLocal(cwd: string): void {
|
|
cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.()
|
|
}
|
|
|
|
function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {}
|
|
for (const key of WSL_LAUNCHER_ENV_KEYS) {
|
|
const value = process.env[key]
|
|
if (value !== undefined) {
|
|
env[key] = value
|
|
}
|
|
}
|
|
for (const [key, value] of Object.entries(explicitEnv ?? {})) {
|
|
if (value !== undefined && value !== process.env[key]) {
|
|
env[key] = value
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
function runLocalPlan(
|
|
plan: CommitMessagePlan,
|
|
cwd: string,
|
|
env: NodeJS.ProcessEnv | undefined,
|
|
emptyResultName = 'message',
|
|
operation: TextGenerationOperation = 'commit-message',
|
|
wslDistro?: string,
|
|
holdHomeLockUntilExit = false
|
|
): LocalProcessExecution<InternalTextGenerationResult> {
|
|
const { binary, args, stdinPayload, label } = plan
|
|
let markProcessClosed!: () => void
|
|
const processClosed = new Promise<void>((resolve) => {
|
|
markProcessClosed = resolve
|
|
})
|
|
const result = new Promise<InternalTextGenerationResult>((resolve) => {
|
|
let child: ChildProcess
|
|
try {
|
|
const spawnEnv = env ?? process.env
|
|
if (process.platform === 'win32' && wslDistro) {
|
|
child = wslAwareSpawn(binary, args, {
|
|
cwd,
|
|
env: buildWslLauncherEnv(env),
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
wslDistro,
|
|
useWslLoginShell: true
|
|
})
|
|
} else {
|
|
const resolvedBinary =
|
|
process.platform === 'win32'
|
|
? resolveCliCommand(binary, { pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null })
|
|
: binary
|
|
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, args)
|
|
child = spawn(spawnCmd, spawnArgs, {
|
|
cwd,
|
|
env: spawnEnv,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
}
|
|
} catch (error) {
|
|
markProcessClosed()
|
|
if (error instanceof UnsafeWindowsBatchArgumentsError) {
|
|
resolve({
|
|
success: false,
|
|
error: userFacingUnsafeWindowsBatchArgs(label)
|
|
})
|
|
return
|
|
}
|
|
console.error('[commit-message] Failed to spawn local generator:', error)
|
|
resolve({
|
|
success: false,
|
|
error: `${label} could not be started. Check the agent command in Settings and try again.`
|
|
})
|
|
return
|
|
}
|
|
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let stdoutBytes = 0
|
|
let stderrBytes = 0
|
|
let outputLimitExceeded = false
|
|
let settled = false
|
|
let canceledByUser = false
|
|
const laneKey = localLaneKey(operation, cwd)
|
|
let cancelToken: (() => void) | null = null
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let terminationComplete: Promise<void> | null = null
|
|
let detachChildListeners = (): void => {}
|
|
const startTermination = (): void => {
|
|
terminationComplete ??= killProcessTree(child)
|
|
}
|
|
const markClosedAfterTermination = (): void => {
|
|
void (terminationComplete ?? Promise.resolve()).then(markProcessClosed)
|
|
}
|
|
const finalize = (result: InternalTextGenerationResult): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
detachChildListeners()
|
|
if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) {
|
|
cancelTokensByLane.delete(laneKey)
|
|
}
|
|
if (!holdHomeLockUntilExit) {
|
|
markProcessClosed()
|
|
}
|
|
resolve(result)
|
|
}
|
|
|
|
cancelToken = () => {
|
|
canceledByUser = true
|
|
startTermination()
|
|
// Why: cancellation is a user-visible UI command; do not wait for a
|
|
// wedged agent CLI to emit `close` before the request leaves loading.
|
|
finalize({ success: false, error: 'Generation canceled.', canceled: true })
|
|
}
|
|
cancelTokensByLane.set(laneKey, cancelToken)
|
|
|
|
timer = setTimeout(() => {
|
|
startTermination()
|
|
finalize({
|
|
success: false,
|
|
error: `Generation timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
|
|
})
|
|
}, GENERATION_TIMEOUT_MS)
|
|
|
|
const onStdoutData = (chunk: Buffer): void => {
|
|
stdoutBytes += chunk.byteLength
|
|
if (stdoutBytes > MAX_AGENT_OUTPUT_BYTES) {
|
|
outputLimitExceeded = true
|
|
startTermination()
|
|
return
|
|
}
|
|
stdout += chunk.toString('utf-8')
|
|
}
|
|
const onStderrData = (chunk: Buffer): void => {
|
|
stderrBytes += chunk.byteLength
|
|
if (stderrBytes > MAX_AGENT_OUTPUT_BYTES) {
|
|
outputLimitExceeded = true
|
|
startTermination()
|
|
return
|
|
}
|
|
stderr += chunk.toString('utf-8')
|
|
}
|
|
const onError = (error: Error): void => {
|
|
if (!child.pid) {
|
|
markProcessClosed()
|
|
}
|
|
const code = (error as NodeJS.ErrnoException).code
|
|
if (code === 'ENOENT') {
|
|
finalize({
|
|
success: false,
|
|
error: `${binary} not found on PATH. Install ${label} to use AI commit messages.`
|
|
})
|
|
return
|
|
}
|
|
console.error('[commit-message] Local generator failed after spawn:', error)
|
|
finalize({
|
|
success: false,
|
|
error: `${label} failed to start. Check the agent command in Settings and try again.`
|
|
})
|
|
}
|
|
const onClose = (code: number | null): void => {
|
|
markClosedAfterTermination()
|
|
if (canceledByUser) {
|
|
finalize({ success: false, error: 'Generation canceled.', canceled: true })
|
|
return
|
|
}
|
|
if (outputLimitExceeded) {
|
|
finalize({
|
|
success: false,
|
|
error: `${label} CLI command produced too much output. Check the agent CLI configuration and try again.`
|
|
})
|
|
return
|
|
}
|
|
finalizeFromAgentOutput({
|
|
code,
|
|
stdout,
|
|
stderr,
|
|
label,
|
|
emptyResultName,
|
|
finalize,
|
|
includeStdoutDetail: operation !== 'branch-name'
|
|
})
|
|
}
|
|
child.stdout?.on('data', onStdoutData)
|
|
child.stderr?.on('data', onStderrData)
|
|
if (holdHomeLockUntilExit) {
|
|
// Why: 'close' also waits on descendants that inherited this child's
|
|
// stdio, so a surviving MCP helper would hold the home forever; at 'exit'
|
|
// the codex process is gone and can no longer rotate auth.json.
|
|
child.once('exit', markClosedAfterTermination)
|
|
child.once('close', markClosedAfterTermination)
|
|
}
|
|
child.on('error', onError)
|
|
child.on('close', onClose)
|
|
detachChildListeners = () => {
|
|
child.stdout?.off?.('data', onStdoutData)
|
|
child.stderr?.off?.('data', onStderrData)
|
|
child.off?.('error', onError)
|
|
child.off?.('close', onClose)
|
|
}
|
|
|
|
try {
|
|
child.stdin?.end(stdinPayload ?? undefined)
|
|
} catch (error) {
|
|
startTermination()
|
|
onError(error instanceof Error ? error : new Error(String(error)))
|
|
}
|
|
})
|
|
return { result, processClosed }
|
|
}
|
|
|
|
/**
|
|
* How the user's command override should read `\`.
|
|
*
|
|
* `'literal'` only when the command provably runs on native Windows: a LOCAL
|
|
* target, on win32, with no WSL distro. A WSL target runs a Linux binary inside
|
|
* the distro, and a remote target runs on a host whose platform this process
|
|
* cannot see — POSIX escaping stays the default for both.
|
|
*/
|
|
export function commandBackslashMode(
|
|
target: CommitMessageGenerationTarget,
|
|
platform: NodeJS.Platform = process.platform
|
|
): CommandTemplateBackslash {
|
|
return platform === 'win32' && target.kind === 'local' && !target.wslDistro ? 'literal' : 'escape'
|
|
}
|
|
|
|
type LocalGenerationTarget = Extract<CommitMessageGenerationTarget, { kind: 'local' }>
|
|
|
|
function runLocalPlanForAgent(
|
|
agentId: string,
|
|
plan: CommitMessagePlan,
|
|
target: LocalGenerationTarget,
|
|
emptyResultName: string,
|
|
operation: TextGenerationOperation
|
|
): Promise<InternalTextGenerationResult> {
|
|
const start = (
|
|
holdHomeLockUntilExit = false
|
|
): LocalProcessExecution<InternalTextGenerationResult> =>
|
|
runLocalPlan(
|
|
plan,
|
|
target.cwd,
|
|
target.env,
|
|
emptyResultName,
|
|
operation,
|
|
target.wslDistro,
|
|
holdHomeLockUntilExit
|
|
)
|
|
if (agentId !== 'codex') {
|
|
// Why: no extra promise hops here — cancellation timing for non-codex
|
|
// agents must stay byte-identical to a direct runLocalPlan call.
|
|
return start().result
|
|
}
|
|
return runCodexLocalPlanUnderHomeLock(() => start(true), target, operation)
|
|
}
|
|
|
|
// Why: codex rewrites rotating OAuth tokens in its home's auth.json; the
|
|
// per-home lock keeps this run from racing Orca's own quota probes there.
|
|
function runCodexLocalPlanUnderHomeLock(
|
|
start: () => LocalProcessExecution<InternalTextGenerationResult>,
|
|
target: LocalGenerationTarget,
|
|
operation: TextGenerationOperation
|
|
): Promise<InternalTextGenerationResult> {
|
|
const laneKey = localLaneKey(operation, target.cwd)
|
|
let canceledWhileQueued = false
|
|
let publishResult!: (result: InternalTextGenerationResult) => void
|
|
let rejectResult!: (error: unknown) => void
|
|
let resultPublished = false
|
|
const result = new Promise<InternalTextGenerationResult>((resolve, reject) => {
|
|
publishResult = (value) => {
|
|
if (!resultPublished) {
|
|
resultPublished = true
|
|
resolve(value)
|
|
}
|
|
}
|
|
rejectResult = reject
|
|
})
|
|
const queuedCancelToken = (): void => {
|
|
canceledWhileQueued = true
|
|
publishResult({ success: false, error: 'Generation canceled.', canceled: true })
|
|
}
|
|
// Why: Stop must work while this run waits behind a probe holding the lock.
|
|
cancelTokensByLane.set(laneKey, queuedCancelToken)
|
|
void withCodexHomeProcessLock(
|
|
resolveCodexHomeProcessLockKeyForSpawnEnv(target.env, target.wslDistro),
|
|
async () => {
|
|
if (canceledWhileQueued) {
|
|
publishResult({ success: false, error: 'Generation canceled.', canceled: true })
|
|
return
|
|
}
|
|
const execution = start()
|
|
try {
|
|
publishResult(await execution.result)
|
|
} catch (error) {
|
|
if (!resultPublished) {
|
|
rejectResult(error)
|
|
}
|
|
} finally {
|
|
await execution.processClosed
|
|
}
|
|
}
|
|
)
|
|
.catch((error: unknown) => {
|
|
if (!resultPublished) {
|
|
rejectResult(error)
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (cancelTokensByLane.get(laneKey) === queuedCancelToken) {
|
|
cancelTokensByLane.delete(laneKey)
|
|
}
|
|
})
|
|
return result
|
|
}
|
|
|
|
function runCodexProcessWithHomeLock<T>(
|
|
lockKey: string,
|
|
start: () => LocalProcessExecution<T>
|
|
): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
void withCodexHomeProcessLock(lockKey, async () => {
|
|
const execution = start()
|
|
try {
|
|
resolve(await execution.result)
|
|
} catch (error) {
|
|
reject(error)
|
|
} finally {
|
|
await execution.processClosed
|
|
}
|
|
}).catch(reject)
|
|
})
|
|
}
|
|
|
|
function finalizeFromAgentOutput(args: {
|
|
code: number | null
|
|
stdout: string
|
|
stderr: string
|
|
label: string
|
|
emptyResultName: string
|
|
finalize: (result: InternalTextGenerationResult) => void
|
|
includeLocalMacDnsHint?: boolean
|
|
includeStdoutDetail?: boolean
|
|
}): void {
|
|
const {
|
|
code,
|
|
stdout,
|
|
stderr,
|
|
label,
|
|
emptyResultName,
|
|
finalize,
|
|
includeLocalMacDnsHint,
|
|
includeStdoutDetail
|
|
} = args
|
|
if (code !== 0) {
|
|
console.error('[commit-message] Generator failed:', {
|
|
label,
|
|
exitCode: code,
|
|
stdout,
|
|
stderr
|
|
})
|
|
finalize({
|
|
success: false,
|
|
error: formatAgentCliFailureMessage(label, stdout, stderr, code, {
|
|
includeLocalMacDnsHint,
|
|
includeStdoutDetail
|
|
}),
|
|
failureOutput: captureAgentGenerationFailureOutput(label, code, stdout, stderr) ?? undefined
|
|
})
|
|
return
|
|
}
|
|
const cleaned = cleanGeneratedCommitMessage(stdout)
|
|
if (!cleaned) {
|
|
// stdout is the (empty) result here, not diagnostics, so only stderr is
|
|
// excerpted. The run exited 0, so this stays "returned an empty result"
|
|
// rather than misreporting a command failure.
|
|
const detail = sanitizeAgentFailureDetail(excerptAgentFailureOutput('', stderr))
|
|
if (detail) {
|
|
console.error('[commit-message] Generator returned no stdout but wrote to stderr:', {
|
|
label,
|
|
exitCode: code,
|
|
stdout,
|
|
stderr
|
|
})
|
|
}
|
|
finalize({
|
|
success: false,
|
|
error: detail
|
|
? `${label} returned an empty ${emptyResultName}. CLI output: ${detail}`
|
|
: `${label} returned an empty ${emptyResultName}.`,
|
|
failureOutput: captureAgentGenerationFailureOutput(label, code, stdout, stderr) ?? undefined
|
|
})
|
|
return
|
|
}
|
|
finalize({
|
|
success: true,
|
|
rawOutput: cleaned,
|
|
agentLabel: label
|
|
})
|
|
}
|
|
|
|
async function runRemotePlan(
|
|
plan: CommitMessagePlan,
|
|
target: Extract<CommitMessageGenerationTarget, { kind: 'remote' }>,
|
|
emptyResultName = 'message',
|
|
operation: TextGenerationOperation = 'commit-message'
|
|
): Promise<InternalTextGenerationResult> {
|
|
const { binary, label } = plan
|
|
let result: RemoteCommitMessageExecResult
|
|
try {
|
|
result = await target.execute(plan, target.cwd, GENERATION_TIMEOUT_MS, operation)
|
|
} catch (error) {
|
|
console.error('[commit-message] Remote generator request failed:', error)
|
|
if (isSshMuxRequestTimeoutError(error)) {
|
|
return {
|
|
success: false,
|
|
error: `${label} took longer than ${GENERATION_TIMEOUT_MS / 1000}s to respond and may still be running on the remote host.`
|
|
}
|
|
}
|
|
return {
|
|
success: false,
|
|
error: `${label} could not be reached on the ${target.missingBinaryLocation}. Try again after the SSH connection recovers.`
|
|
}
|
|
}
|
|
if (result.spawnError) {
|
|
if (result.spawnError === WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) {
|
|
return {
|
|
success: false,
|
|
error: userFacingUnsafeWindowsBatchArgs(label)
|
|
}
|
|
}
|
|
if (/ENOENT/i.test(result.spawnError)) {
|
|
return {
|
|
success: false,
|
|
error: `${binary} not found on the ${target.missingBinaryLocation}. Install ${label} there.`
|
|
}
|
|
}
|
|
console.error('[commit-message] Remote generator spawn failed:', result.spawnError)
|
|
return {
|
|
success: false,
|
|
error: `${label} could not be started on the ${target.missingBinaryLocation}. Check the agent command there and try again.`
|
|
}
|
|
}
|
|
if (result.canceled) {
|
|
return { success: false, error: 'Generation canceled.', canceled: true }
|
|
}
|
|
if (result.timedOut) {
|
|
return {
|
|
success: false,
|
|
error: `Generation timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
|
|
}
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
finalizeFromAgentOutput({
|
|
code: result.exitCode,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
label,
|
|
emptyResultName,
|
|
finalize: resolve,
|
|
// Why: remote agent output reflects the SSH target, not this Mac's DNS.
|
|
includeLocalMacDnsHint: false,
|
|
// Branch failures persist into synced metadata; stdout may echo the prompt.
|
|
includeStdoutDetail: operation !== 'branch-name'
|
|
})
|
|
})
|
|
}
|
|
|
|
function formatCommitMessageGenerationResult(
|
|
result: InternalTextGenerationResult
|
|
): GenerateCommitMessageResult {
|
|
if (!result.success) {
|
|
// Keep the bulky local-only capture off the renderer-bound payload.
|
|
return { success: false, error: result.error, canceled: result.canceled }
|
|
}
|
|
let commitMessage: GeneratedCommitMessage
|
|
try {
|
|
commitMessage = splitGeneratedCommitMessage(result.rawOutput)
|
|
} catch {
|
|
return { success: false, error: 'Generated commit message could not be parsed.' }
|
|
}
|
|
return {
|
|
success: true,
|
|
message: trimGeneratedCommitMessage(commitMessage.message),
|
|
agentLabel: result.agentLabel
|
|
}
|
|
}
|
|
|
|
export async function generateCommitMessageFromContext(
|
|
context: CommitMessageDraftContext,
|
|
params: GenerateCommitMessageParams,
|
|
target: CommitMessageGenerationTarget
|
|
): Promise<GenerateCommitMessageResult> {
|
|
const basePrompt = buildCommitMessagePrompt(context, '')
|
|
const prompt =
|
|
params.commandInputTemplate !== undefined
|
|
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
|
|
basePrompt,
|
|
branch: context.branch ?? '(detached)',
|
|
stagedFiles: context.stagedSummary,
|
|
stagedPatch: context.stagedPatch,
|
|
// Why: always pass the key so `{linkedIssue}` never survives as a literal token.
|
|
linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue)
|
|
})
|
|
: buildCommitMessagePrompt(context, params.customPrompt ?? '')
|
|
const planned = planCommitMessageGeneration(
|
|
{ ...params, backslash: commandBackslashMode(target) },
|
|
prompt
|
|
)
|
|
if (!planned.ok) {
|
|
return { success: false, error: planned.error }
|
|
}
|
|
|
|
const internalResult =
|
|
target.kind === 'remote'
|
|
? await runRemotePlan(planned.plan, target)
|
|
: await runLocalPlanForAgent(
|
|
params.agentId,
|
|
planned.plan,
|
|
target,
|
|
'message',
|
|
'commit-message'
|
|
)
|
|
return formatCommitMessageGenerationResult(internalResult)
|
|
}
|
|
|
|
export function cancelGeneratePullRequestFieldsLocal(cwd: string): void {
|
|
cancelTokensByLane.get(localLaneKey('pull-request-fields', cwd))?.()
|
|
}
|
|
|
|
function formatPullRequestFieldsGenerationResult(
|
|
result: InternalTextGenerationResult,
|
|
context: PullRequestDraftContext
|
|
): GeneratePullRequestFieldsResult {
|
|
if (!result.success) {
|
|
// Keep the bulky local-only capture off the renderer-bound payload.
|
|
return {
|
|
success: false,
|
|
error: result.error,
|
|
canceled: result.canceled,
|
|
branchChangedByPreparation: context.branchChangedByPreparation
|
|
}
|
|
}
|
|
try {
|
|
return {
|
|
success: true,
|
|
fields: parseGeneratedPullRequestFields(result.rawOutput, context),
|
|
agentLabel: result.agentLabel,
|
|
branchChangedByPreparation: context.branchChangedByPreparation
|
|
}
|
|
} catch {
|
|
return {
|
|
success: false,
|
|
error: 'Generated pull request details could not be parsed.',
|
|
branchChangedByPreparation: context.branchChangedByPreparation
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function generatePullRequestFieldsFromContext(
|
|
context: PullRequestDraftContext,
|
|
params: GenerateCommitMessageParams,
|
|
target: CommitMessageGenerationTarget
|
|
): Promise<GeneratePullRequestFieldsResult> {
|
|
const basePrompt = buildPullRequestFieldsPrompt(context, '')
|
|
const prompt =
|
|
params.commandInputTemplate !== undefined
|
|
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
|
|
basePrompt,
|
|
branch: context.branch ?? '(detached)',
|
|
baseBranch: context.base,
|
|
currentTitle: context.currentTitle,
|
|
currentBody: context.currentBody,
|
|
commitSummary: context.commitSummary,
|
|
changedFiles: context.changeSummary,
|
|
patch: context.patch,
|
|
// Why: always pass the key so `{linkedIssue}` never survives as a literal token.
|
|
linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue)
|
|
})
|
|
: buildPullRequestFieldsPrompt(context, params.customPrompt ?? '')
|
|
const planned = planCommitMessageGeneration(
|
|
{ ...params, backslash: commandBackslashMode(target) },
|
|
prompt
|
|
)
|
|
if (!planned.ok) {
|
|
return {
|
|
success: false,
|
|
error: planned.error,
|
|
branchChangedByPreparation: context.branchChangedByPreparation
|
|
}
|
|
}
|
|
|
|
const internalResult =
|
|
target.kind === 'remote'
|
|
? await runRemotePlan(planned.plan, target, 'details', 'pull-request-fields')
|
|
: await runLocalPlanForAgent(
|
|
params.agentId,
|
|
planned.plan,
|
|
target,
|
|
'details',
|
|
'pull-request-fields'
|
|
)
|
|
return formatPullRequestFieldsGenerationResult(internalResult, context)
|
|
}
|
|
|
|
export type GenerateBranchNameResult =
|
|
| { success: true; slug: string; agentLabel?: string }
|
|
| {
|
|
success: false
|
|
error: string
|
|
canceled?: boolean
|
|
failureOutput?: AgentGenerationFailureOutput
|
|
}
|
|
|
|
/**
|
|
* Generate a short kebab-case branch name from the work the agent is starting.
|
|
* Reuses the commit-message generation plan + spawn machinery; only the prompt
|
|
* and the post-processing (slug sanitization) differ.
|
|
*/
|
|
export async function generateBranchNameFromContext(
|
|
context: BranchNameWorkContext,
|
|
params: GenerateCommitMessageParams,
|
|
target: CommitMessageGenerationTarget
|
|
): Promise<GenerateBranchNameResult> {
|
|
const basePrompt = buildBranchNamePrompt(context)
|
|
const prompt =
|
|
params.commandInputTemplate !== undefined
|
|
? renderSourceControlActionCommandTemplate(params.commandInputTemplate, {
|
|
basePrompt,
|
|
firstPrompt: context.firstPrompt,
|
|
assistantMessage: context.assistantMessage ?? ''
|
|
})
|
|
: buildBranchNamePrompt(context, params.customPrompt ?? '')
|
|
const planned = planCommitMessageGeneration(
|
|
{ ...params, backslash: commandBackslashMode(target) },
|
|
prompt
|
|
)
|
|
if (!planned.ok) {
|
|
return { success: false, error: planned.error }
|
|
}
|
|
|
|
const internalResult =
|
|
target.kind === 'remote'
|
|
? await runRemotePlan(planned.plan, target, 'branch name', 'branch-name')
|
|
: await runLocalPlanForAgent(
|
|
params.agentId,
|
|
planned.plan,
|
|
target,
|
|
'branch name',
|
|
'branch-name'
|
|
)
|
|
if (!internalResult.success) {
|
|
return internalResult
|
|
}
|
|
const slug = sanitizeBranchSlug(internalResult.rawOutput)
|
|
if (!slug) {
|
|
return {
|
|
success: false,
|
|
error: 'Generated branch name was empty after sanitization.',
|
|
// What the model actually returned is the whole diagnosis here.
|
|
failureOutput:
|
|
captureAgentGenerationFailureOutput(planned.plan.label, 0, internalResult.rawOutput, '') ??
|
|
undefined
|
|
}
|
|
}
|
|
return { success: true, slug, agentLabel: internalResult.agentLabel }
|
|
}
|