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:
unraid
2026-04-29 14:04:27 +08:00
parent 4f1649e249
commit f2e9af4927
51 changed files with 4885 additions and 971 deletions

View File

@@ -1,173 +1,162 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mock } from "bun:test";
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
let mockedModelType: "gemini" | undefined;
const { getAPIProvider, isFirstPartyAnthropicBaseUrl } = await import(
'../providers'
)
mock.module("../../settings/settings.js", () => ({
getInitialSettings: () =>
mockedModelType ? { modelType: mockedModelType } : {},
}));
const { getAPIProvider, isFirstPartyAnthropicBaseUrl } =
await import("../providers");
describe("getAPIProvider", () => {
describe('getAPIProvider', () => {
const envKeys = [
"CLAUDE_CODE_USE_GEMINI",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_OPENAI",
] as const;
const savedEnv: Record<string, string | undefined> = {};
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GROK',
] as const
const savedEnv: Record<string, string | undefined> = {}
beforeEach(() => {
// Save and clear environment variables
mockedModelType = undefined;
for (const key of envKeys) {
savedEnv[key] = process.env[key];
delete process.env[key];
savedEnv[key] = process.env[key]
delete process.env[key]
}
});
})
afterEach(() => {
// Restore environment variables
mockedModelType = undefined;
for (const key of envKeys) {
if (savedEnv[key] !== undefined) {
process.env[key] = savedEnv[key];
process.env[key] = savedEnv[key]
} else {
delete process.env[key];
delete process.env[key]
}
}
});
})
test('returns "firstParty" by default', () => {
expect(getAPIProvider()).toBe("firstParty");
});
expect(getAPIProvider({})).toBe('firstParty')
})
test('returns "gemini" when modelType is gemini', () => {
mockedModelType = "gemini";
expect(getAPIProvider()).toBe("gemini");
});
expect(getAPIProvider({ modelType: 'gemini' })).toBe('gemini')
})
test("modelType takes precedence over environment variables", () => {
mockedModelType = "gemini";
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
expect(getAPIProvider()).toBe("gemini");
});
test('modelType takes precedence over environment variables', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
expect(getAPIProvider({ modelType: 'gemini' })).toBe('gemini')
})
test('returns "gemini" when CLAUDE_CODE_USE_GEMINI is set', () => {
process.env.CLAUDE_CODE_USE_GEMINI = "1";
expect(getAPIProvider()).toBe("gemini");
});
process.env.CLAUDE_CODE_USE_GEMINI = '1'
expect(getAPIProvider({})).toBe('gemini')
})
test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
expect(getAPIProvider()).toBe("bedrock");
});
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
expect(getAPIProvider({})).toBe('bedrock')
})
test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set', () => {
process.env.CLAUDE_CODE_USE_VERTEX = "1";
expect(getAPIProvider()).toBe("vertex");
});
process.env.CLAUDE_CODE_USE_VERTEX = '1'
expect(getAPIProvider({})).toBe('vertex')
})
test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set', () => {
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
expect(getAPIProvider()).toBe("foundry");
});
process.env.CLAUDE_CODE_USE_FOUNDRY = '1'
expect(getAPIProvider({})).toBe('foundry')
})
test("bedrock takes precedence over gemini", () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
process.env.CLAUDE_CODE_USE_GEMINI = "1";
expect(getAPIProvider()).toBe("bedrock");
});
test('bedrock takes precedence over gemini', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
process.env.CLAUDE_CODE_USE_GEMINI = '1'
expect(getAPIProvider({})).toBe('bedrock')
})
test("bedrock takes precedence over vertex", () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
process.env.CLAUDE_CODE_USE_VERTEX = "1";
expect(getAPIProvider()).toBe("bedrock");
});
test('bedrock takes precedence over vertex', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
process.env.CLAUDE_CODE_USE_VERTEX = '1'
expect(getAPIProvider({})).toBe('bedrock')
})
test("bedrock wins when all three env vars are set", () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
process.env.CLAUDE_CODE_USE_VERTEX = "1";
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
expect(getAPIProvider()).toBe("bedrock");
});
test('bedrock wins when all three env vars are set', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
process.env.CLAUDE_CODE_USE_VERTEX = '1'
process.env.CLAUDE_CODE_USE_FOUNDRY = '1'
expect(getAPIProvider({})).toBe('bedrock')
})
test('"true" is truthy', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "true";
expect(getAPIProvider()).toBe("bedrock");
});
process.env.CLAUDE_CODE_USE_BEDROCK = 'true'
expect(getAPIProvider({})).toBe('bedrock')
})
test('"0" is not truthy', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "0";
expect(getAPIProvider()).toBe("firstParty");
});
process.env.CLAUDE_CODE_USE_BEDROCK = '0'
expect(getAPIProvider({})).toBe('firstParty')
})
test('empty string is not truthy', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "";
expect(getAPIProvider()).toBe("firstParty");
});
});
process.env.CLAUDE_CODE_USE_BEDROCK = ''
expect(getAPIProvider({})).toBe('firstParty')
})
})
describe("isFirstPartyAnthropicBaseUrl", () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
const originalUserType = process.env.USER_TYPE;
describe('isFirstPartyAnthropicBaseUrl', () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL
const originalUserType = process.env.USER_TYPE
afterEach(() => {
if (originalBaseUrl !== undefined) {
process.env.ANTHROPIC_BASE_URL = originalBaseUrl;
process.env.ANTHROPIC_BASE_URL = originalBaseUrl
} else {
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_BASE_URL
}
if (originalUserType !== undefined) {
process.env.USER_TYPE = originalUserType;
process.env.USER_TYPE = originalUserType
} else {
delete process.env.USER_TYPE;
delete process.env.USER_TYPE
}
});
})
test("returns true when ANTHROPIC_BASE_URL is not set", () => {
delete process.env.ANTHROPIC_BASE_URL;
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns true when ANTHROPIC_BASE_URL is not set', () => {
delete process.env.ANTHROPIC_BASE_URL
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns true for api.anthropic.com", () => {
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns true for api.anthropic.com', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com'
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns false for custom URL", () => {
process.env.ANTHROPIC_BASE_URL = "https://my-proxy.com";
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
});
test('returns false for custom URL', () => {
process.env.ANTHROPIC_BASE_URL = 'https://my-proxy.com'
expect(isFirstPartyAnthropicBaseUrl()).toBe(false)
})
test("returns false for invalid URL", () => {
process.env.ANTHROPIC_BASE_URL = "not-a-url";
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
});
test('returns false for invalid URL', () => {
process.env.ANTHROPIC_BASE_URL = 'not-a-url'
expect(isFirstPartyAnthropicBaseUrl()).toBe(false)
})
test("returns true for staging URL when USER_TYPE is ant", () => {
process.env.ANTHROPIC_BASE_URL = "https://api-staging.anthropic.com";
process.env.USER_TYPE = "ant";
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns true for staging URL when USER_TYPE is ant', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api-staging.anthropic.com'
process.env.USER_TYPE = 'ant'
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns true for URL with path", () => {
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns true for URL with path', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns true for trailing slash", () => {
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/";
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns true for trailing slash', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com/'
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns false for subdomain attack", () => {
process.env.ANTHROPIC_BASE_URL = "https://evil-api.anthropic.com";
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
});
});
test('returns false for subdomain attack', () => {
process.env.ANTHROPIC_BASE_URL = 'https://evil-api.anthropic.com'
expect(isFirstPartyAnthropicBaseUrl()).toBe(false)
})
})

View File

@@ -1,5 +1,6 @@
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js'
import { getInitialSettings } from '../settings/settings.js'
import type { SettingsJson } from '../settings/types.js'
import { isEnvTruthy } from '../envUtils.js'
export type APIProvider =
@@ -11,8 +12,10 @@ export type APIProvider =
| 'gemini'
| 'grok'
export function getAPIProvider(): APIProvider {
const modelType = getInitialSettings().modelType
export function getAPIProvider(
settings: Pick<SettingsJson, 'modelType'> = getInitialSettings(),
): APIProvider {
const modelType = settings.modelType
if (modelType === 'openai') return 'openai'
if (modelType === 'gemini') return 'gemini'
if (modelType === 'grok') return 'grok'