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.
318 lines
9.2 KiB
TypeScript
318 lines
9.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
import { join } from 'node:path'
|
|
import {
|
|
AUTONOMY_AGENTS_PATH_POSIX,
|
|
AUTONOMY_DIR,
|
|
buildAutonomyTurnPrompt,
|
|
loadAutonomyAuthority,
|
|
parseHeartbeatAuthorityTasks,
|
|
resetAutonomyAuthorityForTests,
|
|
} from '../autonomyAuthority'
|
|
import {
|
|
cleanupTempDir,
|
|
createTempDir,
|
|
createTempSubdir,
|
|
writeTempFile,
|
|
} from '../../../tests/mocks/file-system'
|
|
|
|
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
|
|
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
|
|
|
|
let tempDir = ''
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await createTempDir('autonomy-authority-')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
resetAutonomyAuthorityForTests()
|
|
if (tempDir) {
|
|
await cleanupTempDir(tempDir)
|
|
}
|
|
})
|
|
|
|
describe('autonomyAuthority', () => {
|
|
test('loadAutonomyAuthority merges AGENTS.md files from root to current directory', async () => {
|
|
const nestedDir = await createTempSubdir(tempDir, 'packages/app')
|
|
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
|
|
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
|
|
await writeTempFile(
|
|
tempDir,
|
|
HEARTBEAT_REL,
|
|
[
|
|
'# Heartbeat',
|
|
'tasks:',
|
|
' - name: inbox',
|
|
' interval: 30m',
|
|
' prompt: "Check inbox"',
|
|
].join('\n'),
|
|
)
|
|
|
|
const snapshot = await loadAutonomyAuthority({
|
|
rootDir: tempDir,
|
|
currentDir: nestedDir,
|
|
})
|
|
|
|
expect(snapshot.agentsFiles.map(file => file.relativePath)).toEqual([
|
|
AUTONOMY_AGENTS_PATH_POSIX,
|
|
`packages/app/${AUTONOMY_AGENTS_PATH_POSIX}`,
|
|
])
|
|
expect(snapshot.agentsContent).toContain('root authority')
|
|
expect(snapshot.agentsContent).toContain('nested authority')
|
|
expect(snapshot.heartbeatContent).toContain('# Heartbeat')
|
|
expect(snapshot.heartbeatTasks).toEqual([
|
|
{
|
|
name: 'inbox',
|
|
interval: '30m',
|
|
prompt: 'Check inbox',
|
|
steps: [],
|
|
},
|
|
])
|
|
})
|
|
|
|
test('loadAutonomyAuthority reads HEARTBEAT.md only from the workspace root', async () => {
|
|
const nestedDir = await createTempSubdir(tempDir, 'child')
|
|
await writeTempFile(
|
|
tempDir,
|
|
HEARTBEAT_REL,
|
|
'# Root heartbeat\nRemember the root task',
|
|
)
|
|
await writeTempFile(
|
|
nestedDir,
|
|
HEARTBEAT_REL,
|
|
'# Nested heartbeat\nThis should not be used',
|
|
)
|
|
|
|
const snapshot = await loadAutonomyAuthority({
|
|
rootDir: tempDir,
|
|
currentDir: nestedDir,
|
|
})
|
|
|
|
expect(snapshot.heartbeatFile?.path).toBe(join(tempDir, HEARTBEAT_REL))
|
|
expect(snapshot.heartbeatContent).toContain('Root heartbeat')
|
|
expect(snapshot.heartbeatContent).not.toContain('Nested heartbeat')
|
|
})
|
|
|
|
test('buildAutonomyTurnPrompt returns the original prompt when no authority files exist', async () => {
|
|
const prompt = await buildAutonomyTurnPrompt({
|
|
basePrompt: 'Run the scheduled task.',
|
|
trigger: 'scheduled-task',
|
|
rootDir: tempDir,
|
|
currentDir: tempDir,
|
|
})
|
|
|
|
expect(prompt).toBe('Run the scheduled task.')
|
|
})
|
|
|
|
test('buildAutonomyTurnPrompt injects AGENTS.md and HEARTBEAT.md for automated turns', async () => {
|
|
const nestedDir = await createTempSubdir(tempDir, 'nested')
|
|
await writeTempFile(tempDir, AGENTS_REL, 'root rules')
|
|
await writeTempFile(nestedDir, AGENTS_REL, 'nested rules')
|
|
await writeTempFile(tempDir, HEARTBEAT_REL, 'Check heartbeat directives')
|
|
|
|
const scheduledPrompt = await buildAutonomyTurnPrompt({
|
|
basePrompt: 'Review the nightly report.',
|
|
trigger: 'scheduled-task',
|
|
rootDir: tempDir,
|
|
currentDir: nestedDir,
|
|
})
|
|
const tickPrompt = await buildAutonomyTurnPrompt({
|
|
basePrompt: '<tick>12:00:00</tick>',
|
|
trigger: 'proactive-tick',
|
|
rootDir: tempDir,
|
|
currentDir: nestedDir,
|
|
})
|
|
|
|
expect(scheduledPrompt).toContain(
|
|
'This prompt was generated automatically. Follow the workspace authority below before acting.',
|
|
)
|
|
expect(scheduledPrompt).toContain('<autonomy_authority>')
|
|
expect(scheduledPrompt).toContain('root rules')
|
|
expect(scheduledPrompt).toContain('nested rules')
|
|
expect(scheduledPrompt).toContain('Check heartbeat directives')
|
|
expect(scheduledPrompt).toContain('Review the nightly report.')
|
|
|
|
expect(tickPrompt).toContain(
|
|
'This is an autonomous proactive turn. Follow the workspace authority below before acting.',
|
|
)
|
|
expect(tickPrompt).toContain('<tick>12:00:00</tick>')
|
|
})
|
|
|
|
test('proactive prompts surface due HEARTBEAT.md tasks only when their interval elapses', async () => {
|
|
await writeTempFile(
|
|
tempDir,
|
|
HEARTBEAT_REL,
|
|
[
|
|
'tasks:',
|
|
' - name: inbox',
|
|
' interval: 30m',
|
|
' prompt: "Check inbox"',
|
|
].join('\n'),
|
|
)
|
|
|
|
const first = await buildAutonomyTurnPrompt({
|
|
basePrompt: '<tick>12:00:00</tick>',
|
|
trigger: 'proactive-tick',
|
|
rootDir: tempDir,
|
|
currentDir: tempDir,
|
|
nowMs: 0,
|
|
})
|
|
const second = await buildAutonomyTurnPrompt({
|
|
basePrompt: '<tick>12:10:00</tick>',
|
|
trigger: 'proactive-tick',
|
|
rootDir: tempDir,
|
|
currentDir: tempDir,
|
|
nowMs: 10 * 60_000,
|
|
})
|
|
const third = await buildAutonomyTurnPrompt({
|
|
basePrompt: '<tick>12:31:00</tick>',
|
|
trigger: 'proactive-tick',
|
|
rootDir: tempDir,
|
|
currentDir: tempDir,
|
|
nowMs: 31 * 60_000,
|
|
})
|
|
|
|
expect(first).toContain('Due HEARTBEAT.md tasks:')
|
|
expect(first).toContain('- inbox (30m): Check inbox')
|
|
expect(second).not.toContain('Due HEARTBEAT.md tasks:')
|
|
expect(third).toContain('Due HEARTBEAT.md tasks:')
|
|
})
|
|
|
|
test('managed HEARTBEAT.md tasks parse nested steps and are not duplicated into the inline due-task section', async () => {
|
|
await writeTempFile(
|
|
tempDir,
|
|
HEARTBEAT_REL,
|
|
[
|
|
'tasks:',
|
|
' - name: inbox',
|
|
' interval: 30m',
|
|
' prompt: "Check inbox"',
|
|
' - name: weekly-report',
|
|
' interval: 7d',
|
|
' prompt: "Ship the weekly report"',
|
|
' steps:',
|
|
' - name: gather',
|
|
' prompt: "Gather weekly inputs"',
|
|
' - name: draft',
|
|
' prompt: "Draft the weekly report"',
|
|
' wait_for: manual',
|
|
].join('\n'),
|
|
)
|
|
|
|
const snapshot = await loadAutonomyAuthority({
|
|
rootDir: tempDir,
|
|
currentDir: tempDir,
|
|
})
|
|
const prompt = await buildAutonomyTurnPrompt({
|
|
basePrompt: '<tick>12:00:00</tick>',
|
|
trigger: 'proactive-tick',
|
|
rootDir: tempDir,
|
|
currentDir: tempDir,
|
|
nowMs: 0,
|
|
})
|
|
|
|
expect(snapshot.heartbeatTasks).toEqual([
|
|
{
|
|
name: 'inbox',
|
|
interval: '30m',
|
|
prompt: 'Check inbox',
|
|
steps: [],
|
|
},
|
|
{
|
|
name: 'weekly-report',
|
|
interval: '7d',
|
|
prompt: 'Ship the weekly report',
|
|
steps: [
|
|
{
|
|
name: 'gather',
|
|
prompt: 'Gather weekly inputs',
|
|
},
|
|
{
|
|
name: 'draft',
|
|
prompt: 'Draft the weekly report',
|
|
waitFor: 'manual',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
expect(prompt).toContain('- inbox (30m): Check inbox')
|
|
expect(prompt).not.toContain('- weekly-report (7d): Ship the weekly report')
|
|
expect(prompt).not.toContain('- gather (')
|
|
})
|
|
|
|
test('parseHeartbeatAuthorityTasks ignores tasks: literals inside markdown code fences', () => {
|
|
const content = [
|
|
'# HEARTBEAT.md',
|
|
'',
|
|
'```yaml',
|
|
'tasks:',
|
|
' - name: not-a-real-task',
|
|
' interval: 1m',
|
|
' prompt: "would-be-shadowed"',
|
|
'```',
|
|
'',
|
|
'tasks:',
|
|
' - name: real-task',
|
|
' interval: 30m',
|
|
' prompt: "Real prompt"',
|
|
].join('\n')
|
|
|
|
const parsed = parseHeartbeatAuthorityTasks(content)
|
|
|
|
expect(parsed).toHaveLength(1)
|
|
expect(parsed[0]).toMatchObject({
|
|
name: 'real-task',
|
|
interval: '30m',
|
|
prompt: 'Real prompt',
|
|
})
|
|
})
|
|
|
|
test('parseHeartbeatAuthorityTasks ignores tasks: literals inside tilde markdown code fences', () => {
|
|
const content = [
|
|
'# HEARTBEAT.md',
|
|
'',
|
|
'~~~yaml',
|
|
'tasks:',
|
|
' - name: not-a-real-task',
|
|
' interval: 1m',
|
|
' prompt: "would-be-shadowed"',
|
|
'~~~',
|
|
'',
|
|
'tasks:',
|
|
' - name: real-task',
|
|
' interval: 30m',
|
|
' prompt: "Real prompt"',
|
|
].join('\n')
|
|
|
|
const parsed = parseHeartbeatAuthorityTasks(content)
|
|
|
|
expect(parsed).toHaveLength(1)
|
|
expect(parsed[0]).toMatchObject({
|
|
name: 'real-task',
|
|
interval: '30m',
|
|
prompt: 'Real prompt',
|
|
})
|
|
})
|
|
|
|
test('parseHeartbeatAuthorityTasks parses real tasks even when documentation precedes them', () => {
|
|
const content = [
|
|
'# Heartbeat docs',
|
|
'',
|
|
'See `tasks:` below — the parser keys on the literal at column 0.',
|
|
'',
|
|
'tasks:',
|
|
' - name: weekly',
|
|
' interval: 7d',
|
|
' prompt: "Ship report"',
|
|
].join('\n')
|
|
|
|
const parsed = parseHeartbeatAuthorityTasks(content)
|
|
|
|
// Inline `tasks:` mention does NOT collide because it's not at column 0
|
|
// on its own line — the existing line.trim() === 'tasks:' guard handles
|
|
// that case. This test pins the behaviour.
|
|
expect(parsed).toHaveLength(1)
|
|
expect(parsed[0]?.name).toBe('weekly')
|
|
})
|
|
})
|