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.
This commit is contained in:
unraid
2026-04-29 14:04:27 +08:00
parent 4f1649e249
commit f2e9af4927
51 changed files with 4885 additions and 971 deletions

View File

@@ -1,8 +1,18 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { createAbortController } from '../utils/abortController'
import { QueryGuard } from '../utils/QueryGuard'
import { handlePromptSubmit } from '../utils/handlePromptSubmit'
import { getCommandQueue, resetCommandQueue } from '../utils/messageQueueManager'
import {
getCommandQueue,
resetCommandQueue,
} from '../utils/messageQueueManager'
import { cleanupTempDir, createTempDir } from '../../tests/mocks/file-system'
import {
createAutonomyQueuedPrompt,
markAutonomyRunCancelled,
} from '../utils/autonomyRuns'
let tempDirs: string[] = []
function createBaseParams() {
const queryGuard = new QueryGuard()
@@ -28,9 +38,7 @@ function createBaseParams() {
commands: [],
setUserInputOnProcessing: mock((_prompt?: string) => {}),
setAbortController: mock((_abortController: AbortController | null) => {}),
onQuery: mock(
async () => undefined,
) as unknown as (
onQuery: mock(async () => undefined) as unknown as (
...args: unknown[]
) => Promise<void>,
setAppState: mock((_updater: unknown) => {}),
@@ -40,6 +48,13 @@ function createBaseParams() {
describe('handlePromptSubmit', () => {
beforeEach(() => {
resetCommandQueue()
tempDirs = []
})
afterEach(async () => {
for (const tempDir of tempDirs) {
await cleanupTempDir(tempDir)
}
})
test('aborts the current turn when only cancel-interrupt tools are running', async () => {
@@ -118,4 +133,34 @@ describe('handlePromptSubmit', () => {
bridgeOrigin: true,
})
})
test('skips stale autonomy commands in the idle queued path', async () => {
const params = createBaseParams()
const abortController = createAbortController()
const tempDir = await createTempDir('handle-prompt-autonomy-')
tempDirs.push(tempDir)
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
await markAutonomyRunCancelled(command!.autonomy!.runId, tempDir)
await handlePromptSubmit({
...params,
input: '',
mode: 'prompt',
pastedContents: {},
abortController,
streamMode: 'normal' as any,
hasInterruptibleToolInProgress: false,
isExternalLoading: false,
queuedCommands: [command!],
})
expect(params.getToolUseContext).not.toHaveBeenCalled()
expect(params.onQuery).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,337 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { randomUUID } from 'crypto'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../bootstrap/state'
import { query } from '../query'
import { getEmptyToolPermissionContext } from '../Tool'
import type { AssistantMessage } from '../types/message'
import { asSystemPrompt } from '../utils/systemPromptType'
import {
createAssistantAPIErrorMessage,
createUserMessage,
} from '../utils/messages'
import { cleanupTempDir, createTempDir } from '../../tests/mocks/file-system'
import {
enqueue,
getCommandsByMaxPriority,
resetCommandQueue,
} from '../utils/messageQueueManager'
import { getAutonomyFlowById, listAutonomyFlows } from '../utils/autonomyFlows'
import {
getAutonomyRunById,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../utils/autonomyRuns'
let tempDir = ''
let originalProcessCwd = ''
beforeEach(async () => {
originalProcessCwd = process.cwd()
tempDir = await createTempDir('query-autonomy-provider-boundary-')
resetStateForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
setCwdState(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetCommandQueue()
if (originalProcessCwd) {
process.chdir(originalProcessCwd)
}
if (tempDir) {
let lastError: unknown
for (let attempt = 0; attempt < 20; attempt++) {
try {
await cleanupTempDir(tempDir)
lastError = undefined
break
} catch (error) {
lastError = error
await new Promise(resolve => setTimeout(resolve, 100))
}
}
if (lastError) {
throw lastError
}
}
})
function createToolUseAssistantMessage(): AssistantMessage {
return {
type: 'assistant',
uuid: randomUUID(),
timestamp: new Date().toISOString(),
requestId: undefined,
message: {
id: 'msg_tool_use',
type: 'message',
role: 'assistant',
model: 'test-model',
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
content: [
{
type: 'tool_use',
id: 'toolu_provider_boundary',
name: 'MissingBoundaryTool',
input: {},
},
],
},
} as unknown as AssistantMessage
}
function createToolUseContext(): any {
let inProgressToolUseIds = new Set<string>()
let responseLength = 0
let appState = {
toolPermissionContext: getEmptyToolPermissionContext(),
fastMode: false,
mcp: {
tools: [],
clients: [],
},
effortValue: undefined,
advisorModel: undefined,
sessionHooks: new Map(),
}
return {
options: {
commands: [],
debug: false,
mainLoopModel: 'claude-sonnet-4-5-20250929',
tools: [],
verbose: false,
thinkingConfig: { type: 'disabled' },
mcpClients: [],
mcpResources: {},
isNonInteractiveSession: true,
agentDefinitions: {
activeAgents: [],
allowedAgentTypes: [],
},
},
abortController: new AbortController(),
readFileState: new Map(),
getAppState: () => appState,
setAppState: (updater: (state: any) => any) => {
appState = updater(appState as never)
},
setInProgressToolUseIDs: (updater: (state: Set<string>) => Set<string>) => {
inProgressToolUseIds = updater(inProgressToolUseIds)
},
setResponseLength: (updater: (state: number) => number) => {
responseLength = updater(responseLength)
},
updateFileHistoryState: () => {},
updateAttributionState: () => {},
messages: [],
} as any
}
describe('query autonomy/provider boundary', () => {
test('provider api-error messages fail a consumed autonomy run instead of advancing the flow', async () => {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'provider-boundary',
interval: '1h',
prompt: 'Exercise provider boundary',
steps: [
{ name: 'first', prompt: 'First provider-boundary step' },
{ name: 'second', prompt: 'Second provider-boundary step' },
],
},
rootDir: tempDir,
currentDir: tempDir,
priority: 'next',
})
expect(command).not.toBeNull()
enqueue(command!)
const toolUseContext = createToolUseContext()
let callCount = 0
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* () {
callCount += 1
if (callCount === 1) {
yield createToolUseAssistantMessage()
return
}
yield createAssistantAPIErrorMessage({
content: 'API Error: provider unavailable',
apiError: 'api_error',
error: new Error('provider unavailable') as never,
})
},
}
const emitted: any[] = []
const generator = query({
messages: [
createUserMessage({
content: 'start provider-boundary test',
}),
],
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let next = await generator.next()
while (!next.done) {
emitted.push(next.value)
next = await generator.next()
}
const [flow] = await listAutonomyFlows(tempDir)
const finalFlow = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(next.value.reason).toBe('model_error')
expect(callCount).toBe(2)
expect(
emitted.some(
message =>
message.type === 'attachment' &&
message.attachment.type === 'queued_command',
),
).toBe(true)
expect(run!.status).toBe('failed')
expect(run!.error).toBe('provider api_error')
expect(finalFlow!.status).toBe('failed')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'failed',
'pending',
])
expect(getCommandsByMaxPriority('later')).toHaveLength(0)
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
})
test('generator return cancels a consumed autonomy run instead of leaving it running', async () => {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'return-boundary',
interval: '1h',
prompt: 'Exercise generator return boundary',
steps: [
{ name: 'first', prompt: 'First return-boundary step' },
{ name: 'second', prompt: 'Second return-boundary step' },
],
},
rootDir: tempDir,
currentDir: tempDir,
priority: 'next',
})
expect(command).not.toBeNull()
enqueue(command!)
const toolUseContext = createToolUseContext()
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* () {
yield createToolUseAssistantMessage()
},
}
const generator = query({
messages: [
createUserMessage({
content: 'start return-boundary test',
}),
],
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let sawQueuedAttachment = false
let next = await generator.next()
while (!next.done) {
const message = next.value as any
if (
message.type === 'attachment' &&
message.attachment.type === 'queued_command'
) {
sawQueuedAttachment = true
await generator.return(undefined as never)
break
}
next = await generator.next()
}
const [flow] = await listAutonomyFlows(tempDir)
const finalFlow = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(sawQueuedAttachment).toBe(true)
expect(run!.status).toBe('cancelled')
expect(finalFlow!.status).toBe('cancelled')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'cancelled',
'cancelled',
])
expect(getCommandsByMaxPriority('later')).toHaveLength(0)
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
})
})