mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
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.
184 lines
5.1 KiB
TypeScript
184 lines
5.1 KiB
TypeScript
import { readdir } from 'node:fs/promises'
|
|
import { existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import type { Instinct, StoredInstinct } from './instinctParser.js'
|
|
import {
|
|
getInstinctsDir,
|
|
loadInstincts,
|
|
saveInstinct,
|
|
type InstinctStoreOptions,
|
|
} from './instinctStore.js'
|
|
import { getSkillLearningRoot } from './observationStore.js'
|
|
import type { SkillLearningProjectContext } from './types.js'
|
|
|
|
export type PromotionCandidate = {
|
|
instinctId: string
|
|
averageConfidence: number
|
|
projectIds: string[]
|
|
}
|
|
|
|
export type PromotionOptions = {
|
|
rootDir?: string
|
|
minProjects?: number
|
|
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()
|
|
}
|
|
|
|
export function findPromotionCandidates(
|
|
instincts: Instinct[],
|
|
minProjects = 2,
|
|
minConfidence = 0.8,
|
|
): PromotionCandidate[] {
|
|
const grouped = new Map<string, Instinct[]>()
|
|
for (const instinct of instincts) {
|
|
if (instinct.scope !== 'project') continue
|
|
const group = grouped.get(instinct.id) ?? []
|
|
group.push(instinct)
|
|
grouped.set(instinct.id, group)
|
|
}
|
|
|
|
return Array.from(grouped.entries()).flatMap(([instinctId, group]) => {
|
|
const projectIds = Array.from(
|
|
new Set(group.map(instinct => instinct.projectId).filter(Boolean)),
|
|
) as string[]
|
|
const averageConfidence =
|
|
group.reduce((sum, instinct) => sum + instinct.confidence, 0) /
|
|
group.length
|
|
if (
|
|
projectIds.length >= minProjects &&
|
|
averageConfidence >= minConfidence
|
|
) {
|
|
return [
|
|
{
|
|
instinctId,
|
|
projectIds,
|
|
averageConfidence: Number(averageConfidence.toFixed(2)),
|
|
},
|
|
]
|
|
}
|
|
return []
|
|
})
|
|
}
|
|
|
|
export async function checkPromotion(
|
|
options: PromotionOptions = {},
|
|
): Promise<PromotionCandidate[]> {
|
|
const minProjects = options.minProjects ?? 2
|
|
const minConfidence = options.minConfidence ?? 0.8
|
|
const allProjectInstincts = await loadAllProjectInstincts(options.rootDir)
|
|
|
|
const candidates = findPromotionCandidates(
|
|
allProjectInstincts,
|
|
minProjects,
|
|
minConfidence,
|
|
)
|
|
const promoted: PromotionCandidate[] = []
|
|
|
|
for (const candidate of candidates) {
|
|
if (sessionPromotedIds.has(candidate.instinctId)) continue
|
|
|
|
const source = allProjectInstincts.find(
|
|
instinct => instinct.id === candidate.instinctId,
|
|
)
|
|
if (!source) continue
|
|
|
|
const globalInstinct: StoredInstinct = {
|
|
...source,
|
|
scope: 'global',
|
|
projectId: undefined,
|
|
projectName: undefined,
|
|
confidence: candidate.averageConfidence,
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
|
|
const globalOptions: InstinctStoreOptions = {
|
|
rootDir: options.rootDir,
|
|
scope: 'global',
|
|
project: globalProjectContext(options.rootDir),
|
|
}
|
|
await saveInstinct(globalInstinct, globalOptions)
|
|
|
|
recordSessionPromoted(candidate.instinctId)
|
|
promoted.push(candidate)
|
|
}
|
|
|
|
return promoted
|
|
}
|
|
|
|
async function loadAllProjectInstincts(
|
|
rootDir?: string,
|
|
): Promise<StoredInstinct[]> {
|
|
const root = getSkillLearningRoot(rootDir ? { rootDir } : undefined)
|
|
const projectsRoot = join(root, 'projects')
|
|
if (!existsSync(projectsRoot)) return []
|
|
|
|
const entries = await readdir(projectsRoot, { withFileTypes: true })
|
|
const instincts: StoredInstinct[] = []
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue
|
|
const project: SkillLearningProjectContext = {
|
|
projectId: entry.name,
|
|
projectName: entry.name,
|
|
scope: 'project',
|
|
source: 'git_root',
|
|
cwd: projectsRoot,
|
|
storageDir: join(projectsRoot, entry.name),
|
|
}
|
|
const projectInstincts = await loadInstincts({
|
|
rootDir,
|
|
project,
|
|
scope: 'project',
|
|
})
|
|
instincts.push(...projectInstincts)
|
|
}
|
|
return instincts
|
|
}
|
|
|
|
function globalProjectContext(rootDir?: string): SkillLearningProjectContext {
|
|
const root = getSkillLearningRoot(rootDir ? { rootDir } : undefined)
|
|
return {
|
|
projectId: 'global',
|
|
projectName: 'Global',
|
|
scope: 'global',
|
|
source: 'global',
|
|
cwd: root,
|
|
storageDir: join(root, 'global'),
|
|
}
|
|
}
|
|
|
|
// Re-export for consumers that need to inspect the global instincts directory.
|
|
export function getGlobalInstinctsDir(rootDir?: string): string {
|
|
return getInstinctsDir({
|
|
rootDir,
|
|
scope: 'global',
|
|
project: globalProjectContext(rootDir),
|
|
})
|
|
}
|