mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +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.
262 lines
6.9 KiB
TypeScript
262 lines
6.9 KiB
TypeScript
import type { QueuedCommand } from '../types/textInputTypes.js'
|
|
import {
|
|
finalizeAutonomyRunCompleted,
|
|
finalizeAutonomyRunFailed,
|
|
listAutonomyRuns,
|
|
markAutonomyRunCancelled,
|
|
markAutonomyRunRunning,
|
|
} from './autonomyRuns.js'
|
|
|
|
export type AutonomyQueuePartition = {
|
|
attachmentCommands: QueuedCommand[]
|
|
staleCommands: QueuedCommand[]
|
|
}
|
|
|
|
export type AutonomyQueueClaim = AutonomyQueuePartition & {
|
|
claimedRunIds: string[]
|
|
claimedCommands: QueuedCommand[]
|
|
}
|
|
|
|
export type AutonomyTurnOutcome =
|
|
| { type: 'completed' }
|
|
| { type: 'cancelled' }
|
|
| { type: 'failed'; error?: unknown; message?: string }
|
|
|
|
type AutonomyRunRef = {
|
|
runId: string
|
|
rootDir?: string
|
|
}
|
|
|
|
function getCommandRootDir(
|
|
command: QueuedCommand,
|
|
fallbackRootDir?: string,
|
|
): string | undefined {
|
|
return command.autonomy?.rootDir ?? fallbackRootDir
|
|
}
|
|
|
|
function refKey(ref: AutonomyRunRef): string {
|
|
return `${ref.rootDir ?? ''}\0${ref.runId}`
|
|
}
|
|
|
|
function getAutonomyRunRefs(
|
|
commands: QueuedCommand[],
|
|
fallbackRootDir?: string,
|
|
): AutonomyRunRef[] {
|
|
const refs = new Map<string, AutonomyRunRef>()
|
|
for (const command of commands) {
|
|
const runId = command.autonomy?.runId
|
|
if (!runId) {
|
|
continue
|
|
}
|
|
const ref = {
|
|
runId,
|
|
rootDir: getCommandRootDir(command, fallbackRootDir),
|
|
}
|
|
refs.set(refKey(ref), ref)
|
|
}
|
|
return [...refs.values()]
|
|
}
|
|
|
|
function isInlineQueuedCommand(command: QueuedCommand): boolean {
|
|
return command.mode === 'prompt' || command.mode === 'task-notification'
|
|
}
|
|
|
|
function groupRefsByRootDir(
|
|
refs: AutonomyRunRef[],
|
|
): Map<string, AutonomyRunRef[]> {
|
|
const grouped = new Map<string, AutonomyRunRef[]>()
|
|
for (const ref of refs) {
|
|
const key = ref.rootDir ?? ''
|
|
const group = grouped.get(key)
|
|
if (group) {
|
|
group.push(ref)
|
|
} else {
|
|
grouped.set(key, [ref])
|
|
}
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
/**
|
|
* Exclude queued autonomy commands whose persisted run is no longer queued.
|
|
* This prevents stale in-memory commands from reviving flows after cancellation
|
|
* or after another path has already consumed the run.
|
|
*/
|
|
export async function partitionConsumableQueuedAutonomyCommands(
|
|
commands: QueuedCommand[],
|
|
rootDir?: string,
|
|
): Promise<AutonomyQueuePartition> {
|
|
const attachmentCommands: QueuedCommand[] = []
|
|
const staleCommands: QueuedCommand[] = []
|
|
const refs = getAutonomyRunRefs(commands, rootDir)
|
|
const runsByRef = new Map<
|
|
string,
|
|
Awaited<ReturnType<typeof listAutonomyRuns>>[number]
|
|
>()
|
|
for (const [rootKey, group] of groupRefsByRootDir(refs)) {
|
|
const runs = await listAutonomyRuns(rootKey || undefined)
|
|
const wanted = new Set(group.map(ref => ref.runId))
|
|
for (const run of runs) {
|
|
if (wanted.has(run.runId)) {
|
|
runsByRef.set(
|
|
refKey({ runId: run.runId, rootDir: rootKey || undefined }),
|
|
run,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const command of commands) {
|
|
const runId = command.autonomy?.runId
|
|
if (!runId) {
|
|
attachmentCommands.push(command)
|
|
continue
|
|
}
|
|
|
|
const commandRootDir = getCommandRootDir(command, rootDir)
|
|
const run = runsByRef.get(refKey({ runId, rootDir: commandRootDir }))
|
|
if (run?.status === 'queued' && !run.startedAt && !run.endedAt) {
|
|
attachmentCommands.push(command)
|
|
} else {
|
|
staleCommands.push(command)
|
|
}
|
|
}
|
|
|
|
return { attachmentCommands, staleCommands }
|
|
}
|
|
|
|
export async function claimConsumableQueuedAutonomyCommands(
|
|
commands: QueuedCommand[],
|
|
rootDir?: string,
|
|
): Promise<AutonomyQueueClaim> {
|
|
const partition = await partitionConsumableQueuedAutonomyCommands(
|
|
commands,
|
|
rootDir,
|
|
)
|
|
const claimedRunIds: string[] = []
|
|
const claimedRunKeys: string[] = []
|
|
const staleRunKeys = new Set<string>()
|
|
const candidateRefs = getAutonomyRunRefs(
|
|
partition.attachmentCommands.filter(isInlineQueuedCommand),
|
|
rootDir,
|
|
)
|
|
|
|
for (const ref of candidateRefs) {
|
|
const updated = await markAutonomyRunRunning(ref.runId, ref.rootDir)
|
|
if (updated?.status === 'running') {
|
|
claimedRunIds.push(ref.runId)
|
|
claimedRunKeys.push(refKey(ref))
|
|
} else {
|
|
staleRunKeys.add(refKey(ref))
|
|
}
|
|
}
|
|
|
|
const claimedRunKeySet = new Set(claimedRunKeys)
|
|
const attachmentCommands: QueuedCommand[] = []
|
|
const claimedCommands: QueuedCommand[] = []
|
|
const staleCommands = [...partition.staleCommands]
|
|
|
|
for (const command of partition.attachmentCommands) {
|
|
const runId = command.autonomy?.runId
|
|
if (!runId) {
|
|
attachmentCommands.push(command)
|
|
continue
|
|
}
|
|
const key = refKey({
|
|
runId,
|
|
rootDir: getCommandRootDir(command, rootDir),
|
|
})
|
|
if (claimedRunKeySet.has(key)) {
|
|
attachmentCommands.push(command)
|
|
claimedCommands.push(command)
|
|
} else if (staleRunKeys.has(key)) {
|
|
staleCommands.push(command)
|
|
}
|
|
}
|
|
|
|
return {
|
|
attachmentCommands,
|
|
staleCommands,
|
|
claimedRunIds,
|
|
claimedCommands,
|
|
}
|
|
}
|
|
|
|
export async function cancelQueuedAutonomyCommands(params: {
|
|
commands: QueuedCommand[]
|
|
rootDir?: string
|
|
}): Promise<void> {
|
|
for (const ref of getAutonomyRunRefs(params.commands, params.rootDir)) {
|
|
await markAutonomyRunCancelled(ref.runId, ref.rootDir)
|
|
}
|
|
}
|
|
|
|
function stringifyAutonomyError(error: unknown): string {
|
|
if (typeof error === 'string') {
|
|
return error
|
|
}
|
|
if (error instanceof Error) {
|
|
return error.message
|
|
}
|
|
return String(error)
|
|
}
|
|
|
|
export function sanitizeAutonomyFailureForPersistence(
|
|
error: unknown,
|
|
fallback = 'query failed',
|
|
): string {
|
|
const message = stringifyAutonomyError(error)
|
|
const lower = message.toLowerCase()
|
|
if (
|
|
lower.includes('api_error') ||
|
|
lower.includes('provider') ||
|
|
lower.includes('openai') ||
|
|
lower.includes('gemini') ||
|
|
lower.includes('grok') ||
|
|
lower.includes('anthropic') ||
|
|
lower.includes('bedrock') ||
|
|
lower.includes('vertex')
|
|
) {
|
|
return 'provider api_error'
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
export async function finalizeAutonomyCommandsForTurn(params: {
|
|
commands: QueuedCommand[]
|
|
outcome: AutonomyTurnOutcome
|
|
currentDir?: string
|
|
priority?: 'now' | 'next' | 'later'
|
|
workload?: string
|
|
}): Promise<QueuedCommand[]> {
|
|
const nextCommands: QueuedCommand[] = []
|
|
for (const command of params.commands) {
|
|
const autonomy = command.autonomy
|
|
if (!autonomy?.runId) {
|
|
continue
|
|
}
|
|
if (params.outcome.type === 'completed') {
|
|
nextCommands.push(
|
|
...(await finalizeAutonomyRunCompleted({
|
|
runId: autonomy.runId,
|
|
rootDir: autonomy.rootDir,
|
|
currentDir: params.currentDir,
|
|
priority: params.priority,
|
|
workload: command.workload ?? params.workload,
|
|
})),
|
|
)
|
|
} else if (params.outcome.type === 'cancelled') {
|
|
await markAutonomyRunCancelled(autonomy.runId, autonomy.rootDir)
|
|
} else {
|
|
await finalizeAutonomyRunFailed({
|
|
runId: autonomy.runId,
|
|
rootDir: autonomy.rootDir,
|
|
error:
|
|
params.outcome.message ??
|
|
sanitizeAutonomyFailureForPersistence(params.outcome.error),
|
|
})
|
|
}
|
|
}
|
|
return nextCommands
|
|
}
|