Files
claude-code/src/utils/__tests__/autonomyQueueLifecycle.test.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

280 lines
8.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { createTempDir, cleanupTempDir } from '../../../tests/mocks/file-system'
import { getAttachmentMessages } from '../attachments'
import {
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
getAutonomyRunById,
markAutonomyRunCancelled,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../autonomyRuns'
import { getAutonomyFlowById, listAutonomyFlows } from '../autonomyFlows'
import {
cancelQueuedAutonomyCommands,
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
partitionConsumableQueuedAutonomyCommands,
} from '../autonomyQueueLifecycle'
import {
enqueue,
getCommandsByMaxPriority,
remove as removeFromQueue,
resetCommandQueue,
} from '../messageQueueManager'
let tempDir = ''
let extraTempDirs: string[] = []
beforeEach(async () => {
tempDir = await createTempDir('autonomy-queue-lifecycle-')
extraTempDirs = []
resetCommandQueue()
})
afterEach(async () => {
resetCommandQueue()
if (tempDir) {
await cleanupTempDir(tempDir)
}
for (const extraTempDir of extraTempDirs) {
await cleanupTempDir(extraTempDir)
}
})
describe('autonomyQueueLifecycle', () => {
async function consumeQueuedAutonomyAttachmentTurn() {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const snapshot = getCommandsByMaxPriority('later')
const claim = await claimConsumableQueuedAutonomyCommands(
snapshot,
tempDir,
)
removeFromQueue(claim.staleCommands)
removeFromQueue(claim.claimedCommands)
const attachments = []
for await (const attachment of getAttachmentMessages(
null,
{} as never,
null,
claim.attachmentCommands,
[],
)) {
attachments.push(attachment)
}
const consumedCommands = claim.attachmentCommands.filter(
command =>
(command.mode === 'prompt' || command.mode === 'task-notification') &&
!claim.claimedCommands.includes(command),
)
removeFromQueue(consumedCommands)
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'completed' },
currentDir: tempDir,
priority: 'later',
})
for (const command of nextCommands) {
enqueue(command)
}
return { attachments, runningRunIds: claim.claimedRunIds, nextCommands }
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
}
test('filters stale autonomy commands before mid-turn attachment consumption', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const initial = await partitionConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
expect(initial.attachmentCommands).toHaveLength(1)
expect(initial.staleCommands).toHaveLength(0)
await markAutonomyRunCancelled(command!.autonomy!.runId, tempDir)
const afterCancel = await partitionConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
expect(afterCancel.attachmentCommands).toHaveLength(0)
expect(afterCancel.staleCommands).toHaveLength(1)
})
test('cancels proactive commands that are created but dropped before enqueue', async () => {
const commands = await createProactiveAutonomyCommands({
basePrompt: '<tick>12:00:00</tick>',
rootDir: tempDir,
currentDir: tempDir,
})
expect(commands).toHaveLength(1)
const queuedRun = await getAutonomyRunById(
commands[0]!.autonomy!.runId,
tempDir,
)
expect(queuedRun!.status).toBe('queued')
await cancelQueuedAutonomyCommands({ commands, rootDir: tempDir })
const cancelledRun = await getAutonomyRunById(
commands[0]!.autonomy!.runId,
tempDir,
)
expect(cancelledRun!.status).toBe('cancelled')
})
test('uses command rootDir when claiming after project context changes', async () => {
const otherProjectDir = await createTempDir('autonomy-other-project-')
extraTempDirs.push(otherProjectDir)
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
expect(command!.autonomy?.rootDir).toBe(tempDir)
const claim = await claimConsumableQueuedAutonomyCommands(
[command!],
otherProjectDir,
)
const originalRun = await getAutonomyRunById(
command!.autonomy!.runId,
tempDir,
)
const wrongProjectRun = await getAutonomyRunById(
command!.autonomy!.runId,
otherProjectDir,
)
expect(claim.claimedRunIds).toEqual([command!.autonomy!.runId])
expect(claim.attachmentCommands).toHaveLength(1)
expect(originalRun!.status).toBe('running')
expect(wrongProjectRun).toBeNull()
})
test('advances a managed flow consumed as a queued attachment', async () => {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{ name: 'gather', prompt: 'Gather weekly inputs' },
{ name: 'draft', prompt: 'Draft weekly report' },
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const claim = await claimConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
const runningRunIds = claim.claimedRunIds
expect(runningRunIds).toEqual([command!.autonomy!.runId])
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'completed' },
currentDir: tempDir,
priority: 'later',
})
const [flow] = await listAutonomyFlows(tempDir)
const detail = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(run!.status).toBe('completed')
expect(nextCommands).toHaveLength(1)
expect(nextCommands[0]!.autonomy?.flowId).toBe(flow!.flowId)
expect(detail!.stateJson!.steps.map(step => step.status)).toEqual([
'completed',
'queued',
])
})
test('keeps managed autonomy flow coherent across queued attachment turns', async () => {
const firstCommand = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{ name: 'gather', prompt: 'Gather weekly inputs' },
{ name: 'draft', prompt: 'Draft weekly report' },
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(firstCommand).not.toBeNull()
enqueue(firstCommand!)
const firstTurn = await consumeQueuedAutonomyAttachmentTurn()
const queuedAfterFirstTurn = getCommandsByMaxPriority('later')
const [flowAfterFirstTurn] = await listAutonomyFlows(tempDir)
const firstRun = await getAutonomyRunById(
firstCommand!.autonomy!.runId,
tempDir,
)
expect(firstTurn.attachments).toHaveLength(1)
expect(firstTurn.attachments[0]!.attachment?.type).toBe('queued_command')
expect(firstTurn.runningRunIds).toEqual([firstCommand!.autonomy!.runId])
expect(firstTurn.nextCommands).toHaveLength(1)
expect(queuedAfterFirstTurn).toHaveLength(1)
expect(queuedAfterFirstTurn[0]!.autonomy?.flowId).toBe(
flowAfterFirstTurn!.flowId,
)
expect(firstRun!.status).toBe('completed')
expect(
flowAfterFirstTurn!.stateJson!.steps.map(step => step.status),
).toEqual(['completed', 'queued'])
const secondCommand = queuedAfterFirstTurn[0]!
const secondTurn = await consumeQueuedAutonomyAttachmentTurn()
const queuedAfterSecondTurn = getCommandsByMaxPriority('later')
const finalFlow = await getAutonomyFlowById(
flowAfterFirstTurn!.flowId,
tempDir,
)
const secondRun = await getAutonomyRunById(
secondCommand.autonomy!.runId,
tempDir,
)
expect(secondTurn.attachments).toHaveLength(1)
expect(secondTurn.runningRunIds).toEqual([secondCommand.autonomy!.runId])
expect(secondTurn.nextCommands).toHaveLength(0)
expect(queuedAfterSecondTurn).toHaveLength(0)
expect(secondRun!.status).toBe('completed')
expect(finalFlow!.status).toBe('succeeded')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'completed',
'completed',
])
})
})