mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15: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:
@@ -83,6 +83,20 @@ export type AutonomyFlowRecord = {
|
||||
waitJson?: AutonomyFlowWaitState
|
||||
cancelRequestedAt?: number
|
||||
lastError?: string
|
||||
/**
|
||||
* Repo-relative POSIX glob patterns describing which paths this flow's
|
||||
* `report`-step approval covers. The pre-tool-use hook
|
||||
* `require-plan-for-risky-edit.mjs` consults this list to permit edits
|
||||
* only when the target file matches at least one entry. Absent or empty
|
||||
* means "no boundary declared" — during the pilot window the hook
|
||||
* treats this as broad approval (v1 behaviour). Once all production
|
||||
* flows declare boundaries, the hook will deny absent-boundary flows.
|
||||
*
|
||||
* Supported syntax: `*` matches one path segment, `**` matches any
|
||||
* number including zero. Examples: `src/utils/autonomy*`,
|
||||
* `src/services/api/**`, `src/Tool.ts`.
|
||||
*/
|
||||
boundary?: string[]
|
||||
}
|
||||
|
||||
type AutonomyFlowsFile = {
|
||||
@@ -138,6 +152,7 @@ function cloneWaitState(
|
||||
function cloneFlowRecord(flow: AutonomyFlowRecord): AutonomyFlowRecord {
|
||||
return {
|
||||
...flow,
|
||||
...(flow.boundary ? { boundary: [...flow.boundary] } : {}),
|
||||
...(flow.stateJson ? { stateJson: cloneManagedState(flow.stateJson) } : {}),
|
||||
...(flow.waitJson ? { waitJson: cloneWaitState(flow.waitJson) } : {}),
|
||||
}
|
||||
@@ -152,6 +167,25 @@ function isManagedFlowStatusActive(status: AutonomyFlowStatus): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function selectPersistedAutonomyFlows(
|
||||
flows: AutonomyFlowRecord[],
|
||||
): AutonomyFlowRecord[] {
|
||||
const retained = flows
|
||||
.slice()
|
||||
.map(cloneFlowRecord)
|
||||
.sort((left, right) => {
|
||||
const leftActive = isManagedFlowStatusActive(left.status)
|
||||
const rightActive = isManagedFlowStatusActive(right.status)
|
||||
if (leftActive !== rightActive) {
|
||||
return leftActive ? -1 : 1
|
||||
}
|
||||
return right.updatedAt - left.updatedAt
|
||||
})
|
||||
.slice(0, AUTONOMY_FLOWS_MAX)
|
||||
|
||||
return retained.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
}
|
||||
|
||||
function defaultFlowSource(params: {
|
||||
trigger: AutonomyTriggerKind
|
||||
sourceId?: string
|
||||
@@ -237,6 +271,35 @@ function normalizeWaitState(value: unknown): AutonomyFlowWaitState | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function isPosixBoundaryGlob(value: string): boolean {
|
||||
if (!value || value.startsWith('/') || value.includes('\\')) {
|
||||
return false
|
||||
}
|
||||
if (value.includes('\0')) {
|
||||
return false
|
||||
}
|
||||
return !value.split('/').some(segment => segment === '..')
|
||||
}
|
||||
|
||||
function normalizeBoundary(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const boundary = value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map(entry => entry.trim())
|
||||
.filter(isPosixBoundaryGlob)
|
||||
.filter(entry => {
|
||||
if (seen.has(entry)) {
|
||||
return false
|
||||
}
|
||||
seen.add(entry)
|
||||
return true
|
||||
})
|
||||
return boundary.length > 0 ? boundary : undefined
|
||||
}
|
||||
|
||||
function normalizeFlowRecord(flow: AutonomyFlowRecord): AutonomyFlowRecord {
|
||||
const source = defaultFlowSource(flow)
|
||||
return {
|
||||
@@ -247,6 +310,7 @@ function normalizeFlowRecord(flow: AutonomyFlowRecord): AutonomyFlowRecord {
|
||||
goal: flow.goal || flow.sourceLabel || flow.sourceId || flow.flowKey,
|
||||
currentDir: flow.currentDir || flow.rootDir,
|
||||
runCount: Math.max(flow.runCount ?? 0, 0),
|
||||
boundary: normalizeBoundary(flow.boundary),
|
||||
stateJson: normalizeManagedState(flow.stateJson),
|
||||
waitJson: normalizeWaitState(flow.waitJson),
|
||||
...(flow.sourceId
|
||||
@@ -369,11 +433,7 @@ async function writeAutonomyFlows(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
flows: flows
|
||||
.slice()
|
||||
.map(cloneFlowRecord)
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
.slice(0, AUTONOMY_FLOWS_MAX),
|
||||
flows: selectPersistedAutonomyFlows(flows),
|
||||
} satisfies AutonomyFlowsFile,
|
||||
null,
|
||||
2,
|
||||
@@ -420,6 +480,7 @@ export async function startManagedAutonomyFlow(params: {
|
||||
ownerKey?: string
|
||||
sourceId?: string
|
||||
sourceLabel?: string
|
||||
boundary?: string[]
|
||||
nowMs?: number
|
||||
}): Promise<ManagedAutonomyFlowStartResult | null> {
|
||||
if (params.steps.length === 0) {
|
||||
@@ -450,6 +511,8 @@ export async function startManagedAutonomyFlow(params: {
|
||||
|
||||
const stateJson = buildManagedState(params.steps)
|
||||
const firstStep = stateJson.steps[0]!
|
||||
const boundary =
|
||||
normalizeBoundary(params.boundary) ?? normalizeBoundary(current?.boundary)
|
||||
const waiting =
|
||||
firstStep.waitFor != null
|
||||
? {
|
||||
@@ -474,6 +537,7 @@ export async function startManagedAutonomyFlow(params: {
|
||||
currentDir,
|
||||
...(source.sourceId ? { sourceId: source.sourceId } : {}),
|
||||
...(source.sourceLabel ? { sourceLabel: source.sourceLabel } : {}),
|
||||
...(boundary ? { boundary } : {}),
|
||||
latestRunId: undefined,
|
||||
runCount: current?.runCount ?? 0,
|
||||
createdAt: current?.createdAt ?? nowMs,
|
||||
|
||||
Reference in New Issue
Block a user