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

@@ -7,7 +7,6 @@ import { clearClassifierApprovals } from '../../utils/classifierApprovals.js'
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
import { clearSessionMessagesCache } from '../../utils/sessionStorage.js'
import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js'
import { getLspServerManager } from '../../services/lsp/manager.js'
import { resetMicrocompactState } from './microCompact.js'
/**
@@ -29,7 +28,7 @@ import { resetMicrocompactState } from './microCompact.js'
* pass querySource — undefined is only safe for callers that are
* genuinely main-thread-only (/compact, /clear).
*/
export async function runPostCompactCleanup(querySource?: QuerySource): Promise<void> {
export function runPostCompactCleanup(querySource?: QuerySource): void {
// Subagents (agent:*) run in the same process and share module-level
// state with the main thread. Only reset main-thread module-level state
// (context-collapse, memory file cache) for main-thread compacts.
@@ -75,15 +74,4 @@ export async function runPostCompactCleanup(querySource?: QuerySource): Promise<
)
}
clearSessionMessagesCache()
// Close all LSP-tracked files so servers release state for files no longer
// in the active context after compaction. Best-effort — LSP may not be
// initialized, and closeAllFiles catches per-file errors internally.
try {
const lspManager = getLspServerManager()
if (lspManager) {
await lspManager.closeAllFiles()
}
} catch {
// LSP module may not be available in all environments
}
}

View File

@@ -1,12 +1,36 @@
import { feature } from 'bun:bundle'
export function isSkillLearningEnabled(): boolean {
if (process.env.SKILL_LEARNING_ENABLED === '0') return false
if (process.env.SKILL_LEARNING_ENABLED === '1') return true
if (process.env.FEATURE_SKILL_LEARNING === '0') return false
if (process.env.FEATURE_SKILL_LEARNING === '1') return true
if (feature('SKILL_LEARNING')) {
return true
}
/**
* Build-time presence check: is the `/skill-learning` slash command
* compiled into this build? Used by the command registry's `isEnabled` so
* the command appears in the menu whenever it is buildable. Operators
* activate the subsystem itself via `/skill-learning start`, which flips
* `SKILL_LEARNING_ENABLED=1` and turns the runtime observers on (see
* `isSkillLearningEnabled`).
*/
export function isSkillLearningCompiledIn(): boolean {
if (feature('SKILL_LEARNING')) return true
return false
}
/**
* Runtime activation check: is the skill-learning subsystem actively
* running (toolEvent, runtime, session observers attached, persisting
* observations to disk)? Off by default — the operator must run
* `/skill-learning start` (which sets `SKILL_LEARNING_ENABLED=1`).
*
* Legacy `FEATURE_SKILL_LEARNING=1` is also accepted for backward
* compatibility with operators who set it before the slash-command UX
* landed.
*
* Build-flag gating is intentionally NOT performed here: the command
* registry already gates command compilation on the build flag, and this
* function is only reached from code paths that the build flag has
* already let through. Decoupling keeps the test surface clean (tests
* exercise the env-var contract without needing to mock `bun:bundle`).
*/
export function isSkillLearningEnabled(): boolean {
if (process.env.SKILL_LEARNING_ENABLED === '1') return true
if (process.env.FEATURE_SKILL_LEARNING === '1') return true
return false
}

View File

@@ -45,15 +45,44 @@ export function getProjectContextPath(projectId: string): string {
// in the tool.call hot path (one wrapper invocation per tool) that cost would
// accumulate into the hundreds-of-ms range per session. Cache keyed by the
// exact cwd string so different worktrees still get independent entries.
//
// Bounded with LRU eviction: long-lived processes that traverse many
// worktrees (e.g. multi-repo build orchestrators) would otherwise grow the
// cache without limit. Each entry holds a SkillLearningProjectContext
// (instinct + skill lists), so the cap ensures bounded memory regardless
// of cwd diversity. `defines.ts` originally flagged this as
// "无淘汰机制(非 GB 级主因)" — this fix closes that gap.
const PROJECT_CONTEXT_CACHE_MAX = 32
const PROJECT_CONTEXT_CACHE_TRIM_TO = 24
const contextCache = new Map<string, SkillLearningProjectContext>()
const PERSIST_INTERVAL_MS = 5 * 60 * 1000
let lastPersistAt = 0
function setProjectContextCache(
cwd: string,
ctx: SkillLearningProjectContext,
): void {
if (contextCache.has(cwd)) contextCache.delete(cwd)
contextCache.set(cwd, ctx)
if (contextCache.size > PROJECT_CONTEXT_CACHE_MAX) {
const toDrop = contextCache.size - PROJECT_CONTEXT_CACHE_TRIM_TO
const iter = contextCache.keys()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
contextCache.delete(next.value)
}
}
}
export function resolveProjectContext(
cwd = process.cwd(),
): SkillLearningProjectContext {
const cached = contextCache.get(cwd)
if (cached) {
// Refresh insertion order so frequently-accessed cwds survive eviction.
contextCache.delete(cwd)
contextCache.set(cwd, cached)
// Still touch the registry so long-lived processes keep `lastSeenAt`
// reasonably fresh, but throttle the write so it doesn't fire on every
// tool call.
@@ -65,7 +94,7 @@ export function resolveProjectContext(
return cached
}
const resolved = resolveContext(cwd)
contextCache.set(cwd, resolved)
setProjectContextCache(cwd, resolved)
persistProjectContext(resolved)
lastPersistAt = Date.now()
return resolved

View File

@@ -23,8 +23,30 @@ export type PromotionOptions = {
minConfidence?: number
}
/**
* Set bounded with FIFO eviction. # promotions per session is small in
* practice (single digits), but a long-lived sandbox/daemon could push
* this if it never restarts. The cap is defensive and the degraded
* behaviour — re-promote if we exceed N then forget the oldest — is
* benign because promotion is idempotent at the lifecycle layer.
*/
const SESSION_PROMOTED_IDS_MAX = 256
const SESSION_PROMOTED_IDS_TRIM_TO = 192
const sessionPromotedIds = new Set<string>()
function recordSessionPromoted(id: string): void {
sessionPromotedIds.add(id)
if (sessionPromotedIds.size > SESSION_PROMOTED_IDS_MAX) {
const toDrop = sessionPromotedIds.size - SESSION_PROMOTED_IDS_TRIM_TO
const iter = sessionPromotedIds.values()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
sessionPromotedIds.delete(next.value)
}
}
}
export function resetPromotionBookkeeping(): void {
sessionPromotedIds.clear()
}
@@ -103,7 +125,7 @@ export async function checkPromotion(
}
await saveInstinct(globalInstinct, globalOptions)
sessionPromotedIds.add(candidate.instinctId)
recordSessionPromoted(candidate.instinctId)
promoted.push(candidate)
}

View File

@@ -1,10 +1,30 @@
import { feature } from 'bun:bundle'
export function isSkillSearchEnabled(): boolean {
if (process.env.SKILL_SEARCH_ENABLED === '0') return false
if (process.env.SKILL_SEARCH_ENABLED === '1') return true
if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
return true
}
/**
* Build-time presence check: is the `/skill-search` slash command compiled
* into this build? Used by the command registry's `isEnabled` so the
* command appears in the menu whenever it is buildable. Operators activate
* the subsystem itself via `/skill-search start`, which flips
* `SKILL_SEARCH_ENABLED=1` and turns the runtime hot paths on (see
* `isSkillSearchEnabled`).
*/
export function isSkillSearchCompiledIn(): boolean {
if (feature('EXPERIMENTAL_SKILL_SEARCH')) return true
return false
}
/**
* Runtime activation check: is the skill-search subsystem currently doing
* work (intentNormalize Haiku calls, prefetch hot path, telemetry)? Off by
* default — the operator must run `/skill-search start` (which sets
* `SKILL_SEARCH_ENABLED=1`). See docs/agent/sur-skill-overflow-bugs.md §5.
*
* Build-flag gating is intentionally NOT performed here: the command
* registry already gates command compilation on the build flag, and this
* function is only reached from code paths that the build flag has
* already let through. Decoupling keeps the test surface clean (tests
* exercise the env-var contract without needing to mock `bun:bundle`).
*/
export function isSkillSearchEnabled(): boolean {
return process.env.SKILL_SEARCH_ENABLED === '1'
}

View File

@@ -47,10 +47,35 @@ Output ONLY keywords. Nothing else.`
const DEFAULT_TIMEOUT_MS = 6_000
const MAX_QUERY_CHARS = 500
const MAX_KEYWORDS_CHARS = 120
/**
* Bound on the process-level query→keywords cache. Insertion-order LRU —
* Map iteration order is insertion order, so we evict from the front when
* size exceeds the cap. ~200 entries × ~600 bytes (query + keywords) ≈
* 120 KB worst case. Without this cap the cache grew monotonically with
* the diversity of Chinese queries in a long session.
*/
const CACHE_MAX_ENTRIES = 200
const CACHE_TRIM_TO = 150
/** Process-level cache. Keyed by the original (trimmed) query. */
const cache = new Map<string, string>()
function setCachedQueryIntent(key: string, value: string): void {
// Refresh insertion order on hit-then-write so frequently-used keys
// stay alive (delete + set is the canonical Map-LRU idiom).
if (cache.has(key)) cache.delete(key)
cache.set(key, value)
if (cache.size > CACHE_MAX_ENTRIES) {
const toDrop = cache.size - CACHE_TRIM_TO
const iter = cache.keys()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
cache.delete(next.value)
}
}
}
export function isIntentNormalizeEnabled(): boolean {
return process.env.SKILL_SEARCH_INTENT_ENABLED === '1'
}
@@ -74,12 +99,17 @@ export async function normalizeQueryIntent(query: string): Promise<string> {
if (!/[\u4e00-\u9fff]/.test(trimmed)) return trimmed
const cached = cache.get(trimmed)
if (cached !== undefined) return cached
if (cached !== undefined) {
// Refresh LRU position so frequently-queried strings survive eviction.
cache.delete(trimmed)
cache.set(trimmed, cached)
return cached
}
const capped = trimmed.slice(0, MAX_QUERY_CHARS)
const keywords = await callHaiku(capped)
const result = keywords ? `${trimmed} ${keywords}` : trimmed
cache.set(trimmed, result)
setCachedQueryIntent(trimmed, result)
logForDebugging(
`[skill-search] intent normalized: "${trimmed.slice(0, 40)}" -> "${keywords}"`,
)

View File

@@ -14,9 +14,35 @@ import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
/**
* Per-session memoization to avoid re-emitting the same skill discovery /
* gap signal twice. Each Set is bounded to keep long-running sessions from
* monotonically accumulating skill names and signal keys forever (which
* was the original session-scoped-but-unbounded design).
*
* FIFO eviction by insertion order — once the cap is hit, the oldest
* entries roll off and may be re-recorded if rediscovered, which is the
* correct degraded behaviour: at worst we re-emit a duplicate signal,
* never silently drop a real one.
*/
const SESSION_TRACKING_MAX = 1000
const SESSION_TRACKING_TRIM_TO = 750
const discoveredThisSession = new Set<string>()
const recordedGapSignals = new Set<string>()
function addBoundedSessionEntry(set: Set<string>, value: string): void {
set.add(value)
if (set.size > SESSION_TRACKING_MAX) {
const toDrop = set.size - SESSION_TRACKING_TRIM_TO
const iter = set.values()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
set.delete(next.value)
}
}
}
const AUTO_LOAD_MIN_SCORE = Number(
process.env.SKILL_SEARCH_AUTOLOAD_MIN_SCORE ?? '0.30',
)
@@ -185,7 +211,7 @@ async function maybeRecordSkillGap(
const gapSignalKey = `${trigger}:${queryText.trim().toLowerCase()}`
if (recordedGapSignals.has(gapSignalKey)) return undefined
recordedGapSignals.add(gapSignalKey)
addBoundedSessionEntry(recordedGapSignals, gapSignalKey)
try {
const [{ isSkillLearningEnabled }, { recordSkillGap }] = await Promise.all([
@@ -241,7 +267,7 @@ export async function startSkillDiscoveryPrefetch(
const newResults = results.filter(r => !discoveredThisSession.has(r.name))
if (newResults.length === 0) return []
for (const r of newResults) discoveredThisSession.add(r.name)
for (const r of newResults) addBoundedSessionEntry(discoveredThisSession, r.name)
const signal: DiscoverySignal = {
trigger: 'assistant_turn',
@@ -305,7 +331,7 @@ export async function getTurnZeroSkillDiscovery(
if (results.length === 0 && !gap) return null
for (const r of results) discoveredThisSession.add(r.name)
for (const r of results) addBoundedSessionEntry(discoveredThisSession, r.name)
const signal: DiscoverySignal = {
trigger: 'user_input',