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,19 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdir, readFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from 'src/bootstrap/state.js'
import { logMock } from '../../../../../../tests/mocks/log'
import { debugMock } from '../../../../../../tests/mocks/debug'
let requestStatus = 200
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
const auditRecords: Record<string, unknown>[] = []
mock.module('axios', () => ({
default: {
@@ -27,17 +15,13 @@ mock.module('axios', () => ({
mock.module('src/utils/auth.js', () => ({
checkAndRefreshOAuthTokenIfNeeded: async () => {},
getClaudeAIOAuthTokens: () => ({ accessToken: 'token' }),
isClaudeAISubscriber: () => true,
}))
mock.module('src/services/oauth/client.js', () => ({
getOrganizationUUID: async () => 'org',
}))
mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
fileSuffixForOauthConfig: () => '',
}))
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => true,
}))
@@ -46,40 +30,39 @@ mock.module('src/services/policyLimits/index.js', () => ({
isPolicyAllowed: () => true,
}))
mock.module('bun:bundle', () => ({
feature: () => false,
mock.module('src/constants/oauth.js', () => ({
ALL_OAUTH_SCOPES: ['user:profile', 'user:inference'],
CLAUDE_AI_INFERENCE_SCOPE: 'user:inference',
CLAUDE_AI_OAUTH_SCOPES: ['user:profile', 'user:inference'],
CLAUDE_AI_PROFILE_SCOPE: 'user:profile',
CONSOLE_OAUTH_SCOPES: ['org:create_api_key', 'user:profile'],
MCP_CLIENT_METADATA_URL: 'https://example.test/oauth/metadata',
OAUTH_BETA_HEADER: 'oauth-test',
fileSuffixForOauthConfig: () => '',
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
}))
let cwd = ''
let previousCwd = ''
let auditRecords: Array<Record<string, unknown>> = []
mock.module('src/utils/remoteTriggerAudit.js', () => ({
appendRemoteTriggerAuditRecord: async (record: Record<string, unknown>) => {
const full = { ...record, auditId: record.auditId ?? 'test-audit-id', createdAt: Date.now() }
auditRecords.push(full)
return full
appendRemoteTriggerAuditRecord: async (
record: Record<string, unknown>,
) => {
const fullRecord = {
auditId: `audit-${auditRecords.length + 1}`,
createdAt: Date.now(),
...record,
}
auditRecords.push(fullRecord)
return fullRecord
},
resolveRemoteTriggerAuditPath: () => join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
}))
beforeEach(async () => {
beforeEach(() => {
requestStatus = 200
auditRecords = []
previousCwd = process.cwd()
cwd = join(tmpdir(), `remote-trigger-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
await mkdir(cwd, { recursive: true })
await mkdir(join(cwd, '.claude'), { recursive: true })
process.chdir(cwd)
resetStateForTests()
setOriginalCwd(cwd)
setProjectRoot(cwd)
auditRecords.length = 0
})
afterEach(async () => {
resetStateForTests()
process.chdir(previousCwd)
await rm(cwd, { recursive: true, force: true })
afterEach(() => {
auditRecords.length = 0
})
describe('RemoteTriggerTool audit', () => {
@@ -91,10 +74,14 @@ describe('RemoteTriggerTool audit', () => {
)
expect(result.data.audit_id).toBeString()
expect(result.data.audit_id).toBe('audit-1')
expect(auditRecords).toHaveLength(1)
expect(auditRecords[0].action).toBe('run')
expect(auditRecords[0].triggerId).toBe('trigger-1')
expect(auditRecords[0].ok).toBe(true)
expect(auditRecords[0]).toMatchObject({
action: 'run',
triggerId: 'trigger-1',
ok: true,
status: 200,
})
})
test('writes an audit record before rethrowing validation failures', async () => {
@@ -108,8 +95,10 @@ describe('RemoteTriggerTool audit', () => {
).rejects.toThrow('run requires trigger_id')
expect(auditRecords).toHaveLength(1)
expect(auditRecords[0].action).toBe('run')
expect(auditRecords[0].ok).toBe(false)
expect(auditRecords[0].error).toBe('run requires trigger_id')
expect(auditRecords[0]).toMatchObject({
action: 'run',
ok: false,
error: 'run requires trigger_id',
})
})
})

View File

@@ -18,76 +18,19 @@
*/
import { diffArrays } from 'diff'
// Import the minimal highlight.js core (no languages) instead of the full
// bundle that loads 190+ grammars (~5-15MB). Individual languages are
// imported statically below and registered on the core instance. Static
// imports work in Bun --compile mode (only createRequire fails).
import hljs from 'highlight.js/lib/core'
import hljs from 'highlight.js'
import { basename, extname } from 'path'
// --- Register commonly-used languages (~25 instead of 190+) ---
import langBash from 'highlight.js/lib/languages/bash'
import langC from 'highlight.js/lib/languages/c'
import langCmake from 'highlight.js/lib/languages/cmake'
import langCpp from 'highlight.js/lib/languages/cpp'
import langCsharp from 'highlight.js/lib/languages/csharp'
import langCss from 'highlight.js/lib/languages/css'
import langDiff from 'highlight.js/lib/languages/diff'
import langDockerfile from 'highlight.js/lib/languages/dockerfile'
import langGo from 'highlight.js/lib/languages/go'
import langGraphQL from 'highlight.js/lib/languages/graphql'
import langJava from 'highlight.js/lib/languages/java'
import langJavaScript from 'highlight.js/lib/languages/javascript'
import langJson from 'highlight.js/lib/languages/json'
import langKotlin from 'highlight.js/lib/languages/kotlin'
import langMakefile from 'highlight.js/lib/languages/makefile'
import langMarkdown from 'highlight.js/lib/languages/markdown'
import langPerl from 'highlight.js/lib/languages/perl'
import langPhp from 'highlight.js/lib/languages/php'
import langPython from 'highlight.js/lib/languages/python'
import langRuby from 'highlight.js/lib/languages/ruby'
import langRust from 'highlight.js/lib/languages/rust'
import langShell from 'highlight.js/lib/languages/shell'
import langSql from 'highlight.js/lib/languages/sql'
import langTypeScript from 'highlight.js/lib/languages/typescript'
import langXml from 'highlight.js/lib/languages/xml'
import langYaml from 'highlight.js/lib/languages/yaml'
hljs.registerLanguage('bash', langBash)
hljs.registerLanguage('c', langC)
hljs.registerLanguage('cmake', langCmake)
hljs.registerLanguage('cpp', langCpp)
hljs.registerLanguage('csharp', langCsharp)
hljs.registerLanguage('css', langCss)
hljs.registerLanguage('diff', langDiff)
hljs.registerLanguage('dockerfile', langDockerfile)
hljs.registerLanguage('go', langGo)
hljs.registerLanguage('graphql', langGraphQL)
hljs.registerLanguage('java', langJava)
hljs.registerLanguage('javascript', langJavaScript)
hljs.registerLanguage('json', langJson)
hljs.registerLanguage('kotlin', langKotlin)
hljs.registerLanguage('makefile', langMakefile)
hljs.registerLanguage('markdown', langMarkdown)
hljs.registerLanguage('perl', langPerl)
hljs.registerLanguage('php', langPhp)
hljs.registerLanguage('python', langPython)
hljs.registerLanguage('ruby', langRuby)
hljs.registerLanguage('rust', langRust)
hljs.registerLanguage('shell', langShell)
hljs.registerLanguage('sql', langSql)
hljs.registerLanguage('typescript', langTypeScript)
hljs.registerLanguage('xml', langXml)
hljs.registerLanguage('yaml', langYaml)
// JavaScript grammar also handles .mjs/.cjs extensions
// TypeScript grammar also handles .tsx via auto-detection
// Static import — createRequire(import.meta.url) fails in Bun --compile mode
// because the resolved path points to the internal bunfs binary path where
// node_modules cannot be found. A top-level import ensures the module is
// bundled and accessible at runtime.
type HLJSApi = typeof hljs
let cachedHljs: HLJSApi | null = null
function hljsApi(): HLJSApi {
if (cachedHljs) return cachedHljs
// highlight.js/lib/core uses `export =` (CJS). Under bun/ESM the interop
// wraps it in .default; under node CJS the module IS the API. Check at runtime.
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
// in .default; under node CJS the module IS the API. Check at runtime.
const mod = hljs as HLJSApi & { default?: HLJSApi }
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
return cachedHljs!