mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +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:
80
src/hooks/__tests__/useScheduledTasks.test.ts
Normal file
80
src/hooks/__tests__/useScheduledTasks.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setCwdState,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../bootstrap/state'
|
||||
import { createScheduledTaskQueuedCommand } from '../useScheduledTasks'
|
||||
import {
|
||||
listAutonomyRuns,
|
||||
markAutonomyRunCompleted,
|
||||
} from '../../utils/autonomyRuns'
|
||||
import { resetAutonomyAuthorityForTests } from '../../utils/autonomyAuthority'
|
||||
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('scheduled-tasks-')
|
||||
resetStateForTests()
|
||||
resetAutonomyAuthorityForTests()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
setCwdState(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
resetAutonomyAuthorityForTests()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('createScheduledTaskQueuedCommand', () => {
|
||||
function createCommandForTest(task: { id: string; prompt: string }) {
|
||||
return createScheduledTaskQueuedCommand(task, {
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
}
|
||||
|
||||
test('skips a scheduled task when the same source already has an active run', async () => {
|
||||
const task = {
|
||||
id: 'cron-1',
|
||||
prompt: '/loop review the repository',
|
||||
}
|
||||
|
||||
const first = await createCommandForTest(task)
|
||||
const second = await createCommandForTest(task)
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).toBeNull()
|
||||
expect(runs).toHaveLength(1)
|
||||
expect(runs[0]).toMatchObject({
|
||||
trigger: 'scheduled-task',
|
||||
status: 'queued',
|
||||
sourceId: 'cron-1',
|
||||
})
|
||||
})
|
||||
|
||||
test('allows a scheduled task after the previous same-source run completes', async () => {
|
||||
const task = {
|
||||
id: 'cron-1',
|
||||
prompt: '/loop review the repository',
|
||||
}
|
||||
|
||||
const first = await createCommandForTest(task)
|
||||
expect(first?.autonomy?.runId).toBeDefined()
|
||||
|
||||
await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 100)
|
||||
const second = await createCommandForTest(task)
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
|
||||
expect(second).not.toBeNull()
|
||||
expect(runs).toHaveLength(2)
|
||||
expect(runs.map(run => run.status).sort()).toEqual(['completed', 'queued'])
|
||||
})
|
||||
})
|
||||
@@ -189,12 +189,6 @@ export function useReplBridge(
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
// Map of pending bridge permission response handlers, keyed by request_id.
|
||||
// Defined at useEffect scope so the cleanup function can clear it on unmount.
|
||||
const pendingPermissionHandlers = new Map<
|
||||
string,
|
||||
(response: BridgePermissionResponse) => void
|
||||
>()
|
||||
// Capture messages.length now so we don't re-send initial messages
|
||||
// through writeMessages after the bridge connects.
|
||||
const initialMessageCount = messages.length
|
||||
@@ -467,6 +461,13 @@ export function useReplBridge(
|
||||
}
|
||||
}
|
||||
|
||||
// Map of pending bridge permission response handlers, keyed by request_id.
|
||||
// Each entry is an onResponse handler waiting for CCR to reply.
|
||||
const pendingPermissionHandlers = new Map<
|
||||
string,
|
||||
(response: BridgePermissionResponse) => void
|
||||
>()
|
||||
|
||||
// Dispatch incoming control_response messages to registered handlers
|
||||
function handlePermissionResponse(msg: SDKControlResponse): void {
|
||||
const requestId = msg.response?.request_id
|
||||
@@ -817,10 +818,6 @@ export function useReplBridge(
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Release all pending permission handlers so their closures (which
|
||||
// may capture React state/setters) can be GC'd immediately rather
|
||||
// than waiting for the entire useEffect closure to become unreachable.
|
||||
pendingPermissionHandlers.clear()
|
||||
clearTimeout(failureTimeoutRef.current)
|
||||
failureTimeoutRef.current = undefined
|
||||
if (handleRef.current) {
|
||||
|
||||
@@ -10,13 +10,18 @@ import type { Message } from '../types/message.js'
|
||||
import { getCwd } from '../utils/cwd.js'
|
||||
import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
|
||||
import { createCronScheduler } from '../utils/cronScheduler.js'
|
||||
import { removeCronTasks } from '../utils/cronTasks.js'
|
||||
import { createAutonomyQueuedPrompt } from '../utils/autonomyRuns.js'
|
||||
import { markAutonomyRunFailed } from '../utils/autonomyRuns.js'
|
||||
import { removeCronTasks, type CronTask } from '../utils/cronTasks.js'
|
||||
import {
|
||||
createAutonomyQueuedPrompt,
|
||||
createAutonomyQueuedPromptIfNoActiveSource,
|
||||
markAutonomyRunCancelled,
|
||||
markAutonomyRunFailed,
|
||||
} from '../utils/autonomyRuns.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
|
||||
import { createScheduledTaskFireMessage } from '../utils/messages.js'
|
||||
import { WORKLOAD_CRON } from '../utils/workloadContext.js'
|
||||
import type { QueuedCommand } from '../types/textInputTypes.js'
|
||||
|
||||
type Props = {
|
||||
isLoading: boolean
|
||||
@@ -32,6 +37,32 @@ type Props = {
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
|
||||
}
|
||||
|
||||
export async function createScheduledTaskQueuedCommand(
|
||||
task: Pick<CronTask, 'id' | 'prompt'>,
|
||||
options?: {
|
||||
rootDir?: string
|
||||
currentDir?: string
|
||||
shouldCreate?: () => boolean
|
||||
},
|
||||
): Promise<QueuedCommand | null> {
|
||||
const command = await createAutonomyQueuedPromptIfNoActiveSource({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: options?.rootDir,
|
||||
currentDir: options?.currentDir ?? getCwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
shouldCreate: options?.shouldCreate,
|
||||
})
|
||||
if (!command) {
|
||||
logForDebugging(
|
||||
`[ScheduledTasks] skipping ${task.id}: previous run still queued or running`,
|
||||
)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
/**
|
||||
* REPL wrapper for the cron scheduler. Mounts the scheduler once and tears
|
||||
* it down on unmount. Fired prompts go into the command queue as 'later'
|
||||
@@ -71,16 +102,25 @@ export function useScheduledTasks({
|
||||
// forward isMeta, so their messages remain visible in the
|
||||
// transcript. This is acceptable since normal mode is not the
|
||||
// primary use case for scheduled tasks.
|
||||
let disposed = false
|
||||
const enqueueForLead = async (prompt: string) => {
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: getCwd(),
|
||||
workload: WORKLOAD_CRON,
|
||||
shouldCreate: () => !disposed,
|
||||
})
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
await markAutonomyRunCancelled(
|
||||
command.autonomy!.runId,
|
||||
command.autonomy!.rootDir,
|
||||
)
|
||||
return
|
||||
}
|
||||
enqueuePendingNotification(command)
|
||||
}
|
||||
|
||||
@@ -90,7 +130,12 @@ export function useScheduledTasks({
|
||||
// which is populated from disk at scheduler startup — this path only
|
||||
// handles team-lead durable crons.
|
||||
onFire: prompt => {
|
||||
void enqueueForLead(prompt)
|
||||
void enqueueForLead(prompt).catch(error =>
|
||||
logForDebugging(
|
||||
`[ScheduledTasks] failed to enqueue missed task prompt: ${error}`,
|
||||
{ level: 'error' },
|
||||
),
|
||||
)
|
||||
},
|
||||
// Normal fires receive the full CronTask so we can route by agentId.
|
||||
onFireTask: task => {
|
||||
@@ -101,22 +146,26 @@ export function useScheduledTasks({
|
||||
store.getState().tasks,
|
||||
)
|
||||
if (teammate && !isTerminalTaskStatus(teammate.status)) {
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: getCwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
const command = await createScheduledTaskQueuedCommand(
|
||||
task,
|
||||
{ shouldCreate: () => !disposed },
|
||||
)
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
await markAutonomyRunCancelled(
|
||||
command.autonomy!.runId,
|
||||
command.autonomy!.rootDir,
|
||||
)
|
||||
return
|
||||
}
|
||||
const injected = injectUserMessageToTeammate(
|
||||
teammate.id,
|
||||
command.value as string,
|
||||
{
|
||||
autonomyRunId: command.autonomy?.runId,
|
||||
autonomyRootDir: command.autonomy?.rootDir,
|
||||
origin: command.origin,
|
||||
},
|
||||
setAppState,
|
||||
@@ -125,6 +174,7 @@ export function useScheduledTasks({
|
||||
await markAutonomyRunFailed(
|
||||
command.autonomy.runId,
|
||||
`Teammate ${task.agentId} exited before the scheduled message could be delivered.`,
|
||||
command.autonomy.rootDir,
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -139,24 +189,32 @@ export function useScheduledTasks({
|
||||
return
|
||||
}
|
||||
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: getCwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
const command = await createScheduledTaskQueuedCommand(
|
||||
task,
|
||||
{ shouldCreate: () => !disposed },
|
||||
)
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
await markAutonomyRunCancelled(
|
||||
command.autonomy!.runId,
|
||||
command.autonomy!.rootDir,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const msg = createScheduledTaskFireMessage(
|
||||
`Running scheduled task (${formatCronFireTime(new Date())})`,
|
||||
)
|
||||
setMessages(prev => [...prev, msg])
|
||||
enqueuePendingNotification(command)
|
||||
})()
|
||||
})().catch(error =>
|
||||
logForDebugging(
|
||||
`[ScheduledTasks] failed to enqueue task ${task.id}: ${error}`,
|
||||
{ level: 'error' },
|
||||
),
|
||||
)
|
||||
},
|
||||
isLoading: () => isLoadingRef.current,
|
||||
assistantMode,
|
||||
@@ -164,7 +222,10 @@ export function useScheduledTasks({
|
||||
isKilled: () => !isKairosCronEnabled(),
|
||||
})
|
||||
scheduler.start()
|
||||
return () => scheduler.stop()
|
||||
return () => {
|
||||
disposed = true
|
||||
scheduler.stop()
|
||||
}
|
||||
// assistantMode is stable for the session lifetime; store/setAppState are
|
||||
// stable refs from useSyncExternalStore; setMessages is a stable useCallback.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
Reference in New Issue
Block a user