Files
claude-code/src/utils/processUserInput/processUserInput.ts
unraid f2e9af4927 feat: harden autonomy lifecycle, OOM bounds, and provider-boundary finalization
This PR consolidates a coordinated batch of fixes around autonomy run/flow lifecycle, scheduled task deduplication, provider-boundary state finalization, and matching memory-bound treatments for adjacent long-running subsystems (REPL fullscreen scrollback, skill-search/skill-learning runtime activation). All changes were developed and reviewed together because they touched the same lifecycle invariants and were uncovered by the same long-running session reproductions.

## Lifecycle correctness

- Queued autonomy prompts are not injected unless the persisted run was successfully claimed; queued run claiming is now terminal-safe so a once-consumed/cancelled/failed run can not slip back into `queued`.
- Autonomy run/flow finalization happens on completion, provider error, generator close, and cancellation — not just the happy path. New `src/__tests__/queryAutonomyProviderBoundary.test.ts` covers these provider-boundary transitions.
- `requestManagedAutonomyFlowCancel` and `resumeManagedAutonomyFlowPrompt` carry `rootDir` and `currentDir` explicitly across detached async boundaries (proactive-tick, cron, daemon restart) instead of inferring from process state.
- Active runs/flows are protected from janitor pruning so a running step can not be garbage-collected mid-flight (`src/utils/autonomyAuthority.ts`).
- Heartbeat parser now ignores fenced code blocks; the two-phase commit window for autonomy state transitions is documented in `docs/internals/autonomy-jira.md`.

## Ownership and dedup

- `src/utils/autonomyRuns.ts`: ownership stamping (run id + rootDir carried end-to-end), source-based dedup against active runs.
- `src/hooks/useScheduledTasks.ts`: scheduled ticks deduplicate against runs already active on the same source label.
- `src/utils/processUserInput/processSlashCommand.tsx`: forked slash commands now thread the autonomy `runId` so completion finalizers can find the originating run for deferred completion.
- New `src/utils/autonomyQueueLifecycle.ts` and tests collect the queue-side lifecycle invariants in one place.

## Memory bounds (related, same review pass)

- `src/screens/REPL.tsx`: caps fullscreen scrollback after the compact boundary and updates trailing progress rows in place. Long-running fullscreen sessions could otherwise retain thousands of post-compaction messages and duplicate progress rows, keeping Ink trees alive long after their useful context had moved on.
- `src/services/skillSearch/*` and `src/services/skillLearning/*`: runtime activation is strictly opt-in via existing env toggles; session caches are capped so long-running processes can not grow them forever. Build presence is preserved so operators can still discover and opt into the slash commands.

## CI / test contract

- `tests/integration/dependency-overrides.test.ts`: smoke test no longer drives Mermaid's browser renderer; it validates the package-resolution contract directly so CI does not regress on unrelated browser timing.
- New `tests/integration/autonomy-lifecycle-user-flow.test.ts`: end-to-end CLI subprocess flow exercising `status --deep`, `flows`, `flow <id>`, `flow resume`, `flow cancel` against persisted state.
- `src/entrypoints/cli.tsx`: `claude autonomy …` routes through an entrypoint fast path that reuses the slash-command formatter without booting the full interactive CLI. Stdout is flushed before forced exit so coverage subprocesses do not terminate with empty stdout.
- `packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts`: stabilized to prevent audit flake under coverage.

## Tests added

- `src/__tests__/queryAutonomyProviderBoundary.test.ts`
- `src/hooks/__tests__/useScheduledTasks.test.ts`
- `src/utils/__tests__/autonomyAuthority.test.ts`
- `src/utils/__tests__/autonomyFlows.test.ts` (extended)
- `src/utils/__tests__/autonomyPersistence.test.ts` (extended)
- `src/utils/__tests__/autonomyQueueLifecycle.test.ts`
- `src/utils/__tests__/autonomyRuns.test.ts` (extended)
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`
- `tests/integration/autonomy-lifecycle-user-flow.test.ts`

## Docs

- `docs/agent/sur-loop-scheduled-oom.md`: System Understanding Report covering the scheduled/loop OOM problem, the call graphs investigated, and the lifecycle invariants this PR establishes.
- `docs/agent/sur-skill-overflow-bugs.md`: SUR for the related skill-overflow context.
- `docs/internals/autonomy-jira.md`: documents the two-phase commit window and ownership stamping invariants.
- `docs/memory-leak-audit.md`: audit notes covering the REPL/scrollback and skill-search bounds.

## Invariants this PR establishes

1. Queued autonomy prompts are not injected unless the persisted run was successfully claimed.
2. Terminal run/flow states are terminal — completion, failure, and cancellation all finalize state regardless of which provider/error path triggered them.
3. Autonomy run/flow `rootDir` is carried explicitly across detached async boundaries instead of inferred from a shared singleton.
4. State-only CLI subcommands (`autonomy status|runs|flows|flow …`) bypass full interactive bootstrap so they do not hold unrelated handles open.
5. REPL fullscreen scrollback and skill-search/skill-learning session caches are explicitly bounded.

## Validation

```bash
bun run typecheck
CI=true GITHUB_ACTIONS=true bun test            # 3996 pass / 0 fail across 305 files
bun test src/__tests__/queryAutonomyProviderBoundary.test.ts \
         src/hooks/__tests__/useScheduledTasks.test.ts \
         src/utils/__tests__/autonomy{Runs,Flows,Authority,QueueLifecycle,Persistence}.test.ts \
         src/utils/processUserInput/__tests__/processSlashCommand.test.ts \
         tests/integration/autonomy-lifecycle-user-flow.test.ts
```

## Origin

This PR is the consolidated, upstream-targeted version of two fork-side review PRs (fix/loop-scheduled-autonomy-oom and fix/autonomy-lifecycle). The fork-side review history is preserved at https://github.com/amDosion/claude-code-bast/pull/7 . The fork's own internal `chore: keep fork current with upstream` sync commits and the `docs: update contributors` automation are intentionally not included in this PR.

The autonomy CLI handler `rootDir` threading that the fork added (78f64d8a, 98d04ddb) is intentionally omitted here because upstream `a2cfaf91` (fix: 修复 RemoteTriggerTool 和 autonomy 测试的全量运行失败) already performed the equivalent change with an additional `currentDir` option. Keeping the upstream version avoids regressing that improvement.
2026-04-29 14:04:27 +08:00

619 lines
20 KiB
TypeScript

import { feature } from 'bun:bundle'
import type {
Base64ImageSource,
ContentBlockParam,
ImageBlockParam,
} from '@anthropic-ai/sdk/resources/messages.mjs'
import { randomUUID } from 'crypto'
import type { QuerySource } from 'src/constants/querySource.js'
import { logEvent } from 'src/services/analytics/index.js'
import { getContentText } from 'src/utils/messages.js'
import {
findCommand,
getBridgeCommandSafety,
getCommandName,
type LocalJSXCommandContext,
} from '../../commands.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import type { IDESelection } from '../../hooks/useIdeSelection.js'
import type { SetToolJSXFn, ToolUseContext } from '../../Tool.js'
import type {
AssistantMessage,
AttachmentMessage,
Message,
ProgressMessage,
SystemMessage,
UserMessage,
} from '../../types/message.js'
import type { PermissionMode } from '../../types/permissions.js'
import {
isValidImagePaste,
type QueuedCommand,
type PromptInputMode,
} from '../../types/textInputTypes.js'
import {
type AgentMentionAttachment,
createAttachmentMessage,
getAttachmentMessages,
} from '../attachments.js'
import type { PastedContent } from '../config.js'
import type { EffortValue } from '../effort.js'
import { toArray } from '../generators.js'
import {
executeUserPromptSubmitHooks,
getUserPromptSubmitHookBlockingMessage,
} from '../hooks.js'
import {
createImageMetadataText,
maybeResizeAndDownsampleImageBlock,
} from '../imageResizer.js'
import { storeImages } from '../imageStore.js'
import {
createCommandInputMessage,
createSystemMessage,
createUserMessage,
} from '../messages.js'
import { queryCheckpoint } from '../queryProfiler.js'
import { parseSlashCommand } from '../slashCommandParsing.js'
import {
hasUltraplanKeyword,
replaceUltraplanKeyword,
} from '../ultraplan/keyword.js'
import { processTextPrompt } from './processTextPrompt.js'
export type ProcessUserInputContext = ToolUseContext & LocalJSXCommandContext
export type ProcessUserInputBaseResult = {
messages: (
| UserMessage
| AssistantMessage
| AttachmentMessage
| SystemMessage
| ProgressMessage
)[]
shouldQuery: boolean
allowedTools?: string[]
model?: string
effort?: EffortValue
// Output text for non-interactive mode (e.g., forked commands)
// When set, this is used as the result in -p mode instead of empty string
resultText?: string
// When set, prefills or submits the next input after command completes
// Used by /discover to chain into the selected feature's command
nextInput?: string
submitNextInput?: boolean
// When true, the command started detached work that will finalize its
// autonomy run after the background work completes.
deferAutonomyCompletion?: boolean
}
export async function processUserInput({
input,
preExpansionInput,
mode,
setToolJSX,
context,
pastedContents,
ideSelection,
messages,
setUserInputOnProcessing,
uuid,
isAlreadyProcessing,
querySource,
canUseTool,
skipSlashCommands,
bridgeOrigin,
isMeta,
skipAttachments,
autonomy,
}: {
input: string | Array<ContentBlockParam>
/**
* Input before [Pasted text #N] expansion. Used for ultraplan keyword
* detection so pasted content containing the word cannot trigger. Falls
* back to the string `input` when unset.
*/
preExpansionInput?: string
mode: PromptInputMode
setToolJSX: SetToolJSXFn
context: ProcessUserInputContext
pastedContents?: Record<number, PastedContent>
ideSelection?: IDESelection
messages?: Message[]
setUserInputOnProcessing?: (prompt?: string) => void
uuid?: string
isAlreadyProcessing?: boolean
querySource?: QuerySource
canUseTool?: CanUseToolFn
/**
* When true, input starting with `/` is treated as plain text.
* Used for remotely-received messages (bridge/CCR) that should not
* trigger local slash commands or skills.
*/
skipSlashCommands?: boolean
/**
* When true, slash commands matching isBridgeSafeCommand() execute even
* though skipSlashCommands is set. See QueuedCommand.bridgeOrigin.
*/
bridgeOrigin?: boolean
/**
* When true, the resulting UserMessage gets `isMeta: true` (user-hidden,
* model-visible). Propagated from `QueuedCommand.isMeta` for queued
* system-generated prompts.
*/
isMeta?: boolean
skipAttachments?: boolean
autonomy?: QueuedCommand['autonomy']
}): Promise<ProcessUserInputBaseResult> {
const inputString = typeof input === 'string' ? input : null
// Immediately show the user input prompt while we are still processing the input.
// Skip for isMeta (system-generated prompts like scheduled tasks) — those
// should run invisibly.
if (mode === 'prompt' && inputString !== null && !isMeta) {
setUserInputOnProcessing?.(inputString)
}
queryCheckpoint('query_process_user_input_base_start')
const appState = context.getAppState()
const result = await processUserInputBase(
input,
mode,
setToolJSX,
context,
pastedContents,
ideSelection,
messages,
uuid,
isAlreadyProcessing,
querySource,
canUseTool,
appState.toolPermissionContext.mode,
skipSlashCommands,
bridgeOrigin,
isMeta,
skipAttachments,
preExpansionInput,
autonomy,
)
queryCheckpoint('query_process_user_input_base_end')
if (!result.shouldQuery) {
return result
}
// Execute UserPromptSubmit hooks and handle blocking
queryCheckpoint('query_hooks_start')
const inputMessage = getContentText(input) || ''
for await (const hookResult of executeUserPromptSubmitHooks(
inputMessage,
appState.toolPermissionContext.mode,
context,
context.requestPrompt,
)) {
// We only care about the result
if (hookResult.message?.type === 'progress') {
continue
}
// Return only a system-level error message, erasing the original user input
if (hookResult.blockingError) {
const blockingMessage = getUserPromptSubmitHookBlockingMessage(
hookResult.blockingError,
)
return {
messages: [
// TODO: Make this an attachment message
createSystemMessage(
`${blockingMessage}\n\nOriginal prompt: ${input}`,
'warning',
),
],
shouldQuery: false,
allowedTools: result.allowedTools,
}
}
// If preventContinuation is set, stop processing but keep the original
// prompt in context.
if (hookResult.preventContinuation) {
const message = hookResult.stopReason
? `Operation stopped by hook: ${hookResult.stopReason}`
: 'Operation stopped by hook'
result.messages.push(
createUserMessage({
content: message,
}),
)
result.shouldQuery = false
return result
}
// Collect additional contexts
if (
hookResult.additionalContexts &&
hookResult.additionalContexts.length > 0
) {
result.messages.push(
createAttachmentMessage({
type: 'hook_additional_context',
content: hookResult.additionalContexts.map(applyTruncation),
hookName: 'UserPromptSubmit',
toolUseID: `hook-${randomUUID()}`,
hookEvent: 'UserPromptSubmit',
}),
)
}
// TODO: Clean this up
if (hookResult.message) {
switch (hookResult.message.attachment!.type) {
case 'hook_success':
if (!hookResult.message.attachment!.content) {
// Skip if there is no content
break
}
result.messages.push({
...hookResult.message,
attachment: {
...hookResult.message.attachment!,
content: applyTruncation(hookResult.message.attachment!.content as string),
},
} as AttachmentMessage)
break
default:
result.messages.push(hookResult.message as AttachmentMessage)
break
}
}
}
queryCheckpoint('query_hooks_end')
// Happy path: onQuery will clear userInputOnProcessing via startTransition
// so it resolves in the same frame as deferredMessages (no flicker gap).
// Error paths are handled by handlePromptSubmit's finally block.
return result
}
const MAX_HOOK_OUTPUT_LENGTH = 10000
function applyTruncation(content: string): string {
if (content.length > MAX_HOOK_OUTPUT_LENGTH) {
return `${content.substring(0, MAX_HOOK_OUTPUT_LENGTH)}… [output truncated - exceeded ${MAX_HOOK_OUTPUT_LENGTH} characters]`
}
return content
}
async function processUserInputBase(
input: string | Array<ContentBlockParam>,
mode: PromptInputMode,
setToolJSX: SetToolJSXFn,
context: ProcessUserInputContext,
pastedContents?: Record<number, PastedContent>,
ideSelection?: IDESelection,
messages?: Message[],
uuid?: string,
isAlreadyProcessing?: boolean,
querySource?: QuerySource,
canUseTool?: CanUseToolFn,
permissionMode?: PermissionMode,
skipSlashCommands?: boolean,
bridgeOrigin?: boolean,
isMeta?: boolean,
skipAttachments?: boolean,
preExpansionInput?: string,
autonomy?: QueuedCommand['autonomy'],
): Promise<ProcessUserInputBaseResult> {
let inputString: string | null = null
let precedingInputBlocks: ContentBlockParam[] = []
// Collect image metadata texts for isMeta message
const imageMetadataTexts: string[] = []
// Normalized view of `input` with image blocks resized. For string input
// this is just `input`; for array input it's the processed blocks. We pass
// this (not raw `input`) to processTextPrompt so resized/normalized image
// blocks actually reach the API — otherwise the resize work above is
// discarded for the regular prompt path. Also normalizes bridge inputs
// where iOS may send `mediaType` instead of `media_type` (mobile-apps#5825).
let normalizedInput: string | ContentBlockParam[] = input
if (typeof input === 'string') {
inputString = input
} else if (input.length > 0) {
queryCheckpoint('query_image_processing_start')
const processedBlocks: ContentBlockParam[] = []
for (const block of input) {
if (block.type === 'image') {
const resized = await maybeResizeAndDownsampleImageBlock(block)
// Collect image metadata for isMeta message
if (resized.dimensions) {
const metadataText = createImageMetadataText(resized.dimensions)
if (metadataText) {
imageMetadataTexts.push(metadataText)
}
}
processedBlocks.push(resized.block)
} else {
processedBlocks.push(block)
}
}
normalizedInput = processedBlocks
queryCheckpoint('query_image_processing_end')
// Extract the input string from the last content block if it is text,
// and keep track of the preceding content blocks
const lastBlock = processedBlocks[processedBlocks.length - 1]
if (lastBlock?.type === 'text') {
inputString = lastBlock.text
precedingInputBlocks = processedBlocks.slice(0, -1)
} else {
precedingInputBlocks = processedBlocks
}
}
if (inputString === null && mode !== 'prompt') {
throw new Error(`Mode: ${mode} requires a string input.`)
}
// Extract and convert image content to content blocks early
// Keep track of IDs in order for message storage
const imageContents = pastedContents
? Object.values(pastedContents).filter(isValidImagePaste)
: []
const imagePasteIds = imageContents.map(img => img.id)
// Store images to disk so Claude can reference the path in context
// (for manipulation with CLI tools, uploading to PRs, etc.)
const storedImagePaths = pastedContents
? await storeImages(pastedContents)
: new Map<number, string>()
// Resize pasted images to ensure they fit within API limits (parallel processing)
queryCheckpoint('query_pasted_image_processing_start')
const imageProcessingResults = await Promise.all(
imageContents.map(async pastedImage => {
const imageBlock: ImageBlockParam = {
type: 'image',
source: {
type: 'base64',
media_type: (pastedImage.mediaType ||
'image/png') as Base64ImageSource['media_type'],
data: pastedImage.content,
},
}
logEvent('tengu_pasted_image_resize_attempt', {
original_size_bytes: pastedImage.content.length,
})
const resized = await maybeResizeAndDownsampleImageBlock(imageBlock)
return {
resized,
originalDimensions: pastedImage.dimensions,
sourcePath:
pastedImage.sourcePath ?? storedImagePaths.get(pastedImage.id),
}
}),
)
// Collect results preserving order
const imageContentBlocks: ContentBlockParam[] = []
for (const {
resized,
originalDimensions,
sourcePath,
} of imageProcessingResults) {
// Collect image metadata for isMeta message (prefer resized dimensions)
if (resized.dimensions) {
const metadataText = createImageMetadataText(
resized.dimensions,
sourcePath,
)
if (metadataText) {
imageMetadataTexts.push(metadataText)
}
} else if (originalDimensions) {
// Fall back to original dimensions if resize didn't provide them
const metadataText = createImageMetadataText(
originalDimensions,
sourcePath,
)
if (metadataText) {
imageMetadataTexts.push(metadataText)
}
} else if (sourcePath) {
// If we have a source path but no dimensions, still add source info
imageMetadataTexts.push(`[Image source: ${sourcePath}]`)
}
imageContentBlocks.push(resized.block)
}
queryCheckpoint('query_pasted_image_processing_end')
// Bridge-safe slash command override: mobile/web clients set bridgeOrigin
// with skipSlashCommands still true (defense-in-depth against exit words and
// immediate-command fast paths). Resolve the command here — if it passes
// isBridgeSafeCommand, clear the skip so the gate below opens. If it's a
// known-but-unsafe command (local-jsx UI or terminal-only), short-circuit
// with a helpful message rather than letting the model see raw "/config".
let effectiveSkipSlash = skipSlashCommands
if (bridgeOrigin && inputString !== null && inputString.startsWith('/')) {
const parsed = parseSlashCommand(inputString)
const cmd = parsed
? findCommand(parsed.commandName, context.options.commands)
: undefined
if (cmd) {
const safety = getBridgeCommandSafety(cmd, parsed?.args ?? '')
if (safety.ok) {
effectiveSkipSlash = false
} else {
const msg =
safety.reason ??
`/${getCommandName(cmd)} isn't available over Remote Control.`
return {
messages: [
createUserMessage({ content: inputString, uuid }),
createCommandInputMessage(
`<local-command-stdout>${msg}</local-command-stdout>`,
),
],
shouldQuery: false,
resultText: msg,
}
}
}
// Unknown /foo or unparseable — fall through to plain text, same as
// pre-#19134. A mobile user typing "/shrug" shouldn't see "Unknown skill".
}
// Ultraplan keyword — route through /ultraplan. Detect on the
// pre-expansion input so pasted content containing the word cannot
// trigger a CCR session; replace with "plan" in the expanded input so
// the CCR prompt receives paste contents and stays grammatical. See
// keyword.ts for the quote/path exclusions. Interactive prompt mode +
// non-slash-prefixed only:
// headless/print mode filters local-jsx commands out of context.options,
// so routing to /ultraplan there yields "Unknown skill" — and there's no
// rainbow animation in print mode anyway.
// Runs before attachment extraction so this path matches the slash-command
// path below (no await between setUserInputOnProcessing and setAppState —
// React batches both into one render, no flash).
if (
feature('ULTRAPLAN') &&
mode === 'prompt' &&
!context.options.isNonInteractiveSession &&
inputString !== null &&
!effectiveSkipSlash &&
!inputString.startsWith('/') &&
!context.getAppState().ultraplanSessionUrl &&
!context.getAppState().ultraplanLaunching &&
hasUltraplanKeyword(preExpansionInput ?? inputString)
) {
logEvent('tengu_ultraplan_keyword', {})
const rewritten = replaceUltraplanKeyword(inputString).trim()
const { processSlashCommand } = await import('./processSlashCommand.js')
const slashResult = await processSlashCommand(
`/ultraplan ${rewritten}`,
precedingInputBlocks,
imageContentBlocks,
[],
context,
setToolJSX,
uuid,
isAlreadyProcessing,
canUseTool,
autonomy,
)
return addImageMetadataMessage(slashResult, imageMetadataTexts)
}
// For slash commands, attachments will be extracted within getMessagesForSlashCommand
const shouldExtractAttachments =
!skipAttachments &&
inputString !== null &&
(mode !== 'prompt' || effectiveSkipSlash || !inputString.startsWith('/'))
queryCheckpoint('query_attachment_loading_start')
const attachmentMessages = shouldExtractAttachments
? await toArray(
getAttachmentMessages(
inputString,
context,
ideSelection ?? null,
[], // queuedCommands - handled by query.ts for mid-turn attachments
messages,
querySource,
),
)
: []
queryCheckpoint('query_attachment_loading_end')
// Bash commands
if (inputString !== null && mode === 'bash') {
const { processBashCommand } = await import('./processBashCommand.js')
return addImageMetadataMessage(
await processBashCommand(
inputString,
precedingInputBlocks,
attachmentMessages,
context,
setToolJSX,
),
imageMetadataTexts,
)
}
// Slash commands
// Skip for remote bridge messages — input from CCR clients is plain text
if (
inputString !== null &&
!effectiveSkipSlash &&
inputString.startsWith('/')
) {
const { processSlashCommand } = await import('./processSlashCommand.js')
const slashResult = await processSlashCommand(
inputString,
precedingInputBlocks,
imageContentBlocks,
attachmentMessages,
context,
setToolJSX,
uuid,
isAlreadyProcessing,
canUseTool,
autonomy,
)
return addImageMetadataMessage(slashResult, imageMetadataTexts)
}
// Log agent mention queries for analysis
if (inputString !== null && mode === 'prompt') {
const trimmedInput = inputString.trim()
const agentMention = attachmentMessages.find(
(m): m is AttachmentMessage<AgentMentionAttachment> =>
m.attachment.type === 'agent_mention',
)
if (agentMention) {
const agentMentionString = `@agent-${agentMention.attachment.agentType}`
const isSubagentOnly = trimmedInput === agentMentionString
const isPrefix =
trimmedInput.startsWith(agentMentionString) && !isSubagentOnly
// Log whenever users use @agent-<name> syntax
logEvent('tengu_subagent_at_mention', {
is_subagent_only: isSubagentOnly,
is_prefix: isPrefix,
})
}
}
// Regular user prompt
return addImageMetadataMessage(
processTextPrompt(
normalizedInput,
imageContentBlocks,
imagePasteIds,
attachmentMessages,
uuid,
permissionMode,
isMeta,
),
imageMetadataTexts,
)
}
// Adds image metadata texts as isMeta message to result
function addImageMetadataMessage(
result: ProcessUserInputBaseResult,
imageMetadataTexts: string[],
): ProcessUserInputBaseResult {
if (imageMetadataTexts.length > 0) {
result.messages.push(
createUserMessage({
content: imageMetadataTexts.map(text => ({ type: 'text', text })),
isMeta: true,
}),
)
}
return result
}