mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
148
tests/integration/autonomy-lifecycle-user-flow.test.ts
Normal file
148
tests/integration/autonomy-lifecycle-user-flow.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../src/bootstrap/state'
|
||||
import {
|
||||
listAutonomyRuns,
|
||||
startManagedAutonomyFlowFromHeartbeatTask,
|
||||
} from '../../src/utils/autonomyRuns'
|
||||
import { listAutonomyFlows } from '../../src/utils/autonomyFlows'
|
||||
|
||||
const CLI_ENTRYPOINT = resolve(import.meta.dir, '../../src/entrypoints/cli.tsx')
|
||||
|
||||
let tempDir = ''
|
||||
let configDir = ''
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
async function runAutonomyCli(args: string[]): Promise<string> {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [process.execPath, CLI_ENTRYPOINT, 'autonomy', ...args],
|
||||
cwd: tempDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: configDir,
|
||||
CI: 'true',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
NODE_ENV: 'development',
|
||||
NO_COLOR: '1',
|
||||
},
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(exitCode).toBe(0)
|
||||
return stdout
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'autonomy-user-flow-'))
|
||||
configDir = join(tempDir, 'config')
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
resetStateForTests()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetStateForTests()
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('autonomy lifecycle user-equivalent CLI flow', () => {
|
||||
test('status --deep works from a clean project without creating autonomy state', async () => {
|
||||
const output = await runAutonomyCli(['status', '--deep'])
|
||||
|
||||
expect(output).toContain('# Autonomy Deep Status')
|
||||
expect(output).toContain('Autonomy runs: 0')
|
||||
expect(output).toContain('Autonomy flows: 0')
|
||||
expect(existsSync(join(tempDir, '.claude', 'autonomy', 'runs.json'))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(existsSync(join(tempDir, '.claude', 'autonomy', 'flows.json'))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('real CLI can inspect, resume, and cancel a persisted managed flow', async () => {
|
||||
await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
task: {
|
||||
name: 'manual-user-flow',
|
||||
interval: '1h',
|
||||
prompt: 'Manual lifecycle acceptance',
|
||||
steps: [
|
||||
{
|
||||
name: 'approve',
|
||||
prompt: 'Wait for manual approval',
|
||||
waitFor: 'manual',
|
||||
},
|
||||
{
|
||||
name: 'execute',
|
||||
prompt: 'Execute approved work',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const [waitingFlow] = await listAutonomyFlows(tempDir)
|
||||
expect(waitingFlow?.status).toBe('waiting')
|
||||
|
||||
const status = await runAutonomyCli(['status', '--deep'])
|
||||
expect(status).toContain('Autonomy flows: 1')
|
||||
expect(status).toContain('Waiting: 1')
|
||||
|
||||
const flows = await runAutonomyCli(['flows', '5'])
|
||||
expect(flows).toContain(waitingFlow!.flowId)
|
||||
expect(flows).toContain('waiting')
|
||||
|
||||
const detailBefore = await runAutonomyCli(['flow', waitingFlow!.flowId])
|
||||
expect(detailBefore).toContain('Status: waiting')
|
||||
expect(detailBefore).toContain('Current step: approve')
|
||||
|
||||
const resume = await runAutonomyCli(['flow', 'resume', waitingFlow!.flowId])
|
||||
expect(resume).toContain('Prepared the next managed step')
|
||||
expect(resume).toContain('Prompt:')
|
||||
|
||||
const detailAfterResume = await runAutonomyCli([
|
||||
'flow',
|
||||
waitingFlow!.flowId,
|
||||
])
|
||||
expect(detailAfterResume).toContain('Status: queued')
|
||||
expect(detailAfterResume).toContain('Latest run:')
|
||||
|
||||
const cancel = await runAutonomyCli(['flow', 'cancel', waitingFlow!.flowId])
|
||||
expect(cancel).toContain('Cancelled flow')
|
||||
|
||||
const [cancelledRun] = await listAutonomyRuns(tempDir)
|
||||
const [cancelledFlow] = await listAutonomyFlows(tempDir)
|
||||
expect(cancelledRun?.status).toBe('cancelled')
|
||||
expect(cancelledFlow?.status).toBe('cancelled')
|
||||
|
||||
const detailAfterCancel = await runAutonomyCli([
|
||||
'flow',
|
||||
waitingFlow!.flowId,
|
||||
])
|
||||
expect(detailAfterCancel).toContain('Status: cancelled')
|
||||
}, 30000)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -78,7 +78,7 @@ describe('dependency security overrides', () => {
|
||||
expect(cryptoProvider.createNewGuid()).toMatch(uuidV4Pattern)
|
||||
})
|
||||
|
||||
test('remote control markdown renderer loads streamdown and mermaid', async () => {
|
||||
test('remote control markdown renderer resolves streamdown and mermaid', async () => {
|
||||
const rcsRequire = createRequire(
|
||||
join(repoRoot, 'packages/remote-control-server/package.json'),
|
||||
)
|
||||
@@ -90,13 +90,24 @@ describe('dependency security overrides', () => {
|
||||
const uuid = (await import(
|
||||
pathToFileURL(streamdownRequire.resolve('uuid')).href
|
||||
)) as { v4(): string }
|
||||
const mermaid = (await import(
|
||||
pathToFileURL(streamdownRequire.resolve('mermaid')).href
|
||||
)) as { default?: { initialize?: unknown } }
|
||||
const mermaidPath = streamdownRequire.resolve('mermaid')
|
||||
const mermaidPackagePath = streamdownRequire.resolve(
|
||||
'mermaid/package.json',
|
||||
)
|
||||
const mermaidPackage = JSON.parse(
|
||||
readFileSync(mermaidPackagePath, 'utf8'),
|
||||
) as {
|
||||
name?: unknown
|
||||
exports?: { '.'?: { import?: unknown } }
|
||||
}
|
||||
|
||||
expect(streamdown.Streamdown).toBeDefined()
|
||||
expect(uuid.v4()).toMatch(uuidV4Pattern)
|
||||
expect(typeof mermaid.default?.initialize).toBe('function')
|
||||
expect(mermaidPackage.name).toBe('mermaid')
|
||||
expect(mermaidPath).toContain('mermaid.core.mjs')
|
||||
expect(mermaidPackage.exports?.['.']?.import).toBe(
|
||||
'./dist/mermaid.core.mjs',
|
||||
)
|
||||
})
|
||||
|
||||
test('grpc proto-loader keeps its protobuf 7 parser path working', () => {
|
||||
|
||||
Reference in New Issue
Block a user