mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor * feat(cli): wire ingest rate-limit config * feat(cli): report provider rate-limit signals * feat(cli): show ingest rate-limit waits * fix(cli): complete rate-limit event coverage * fix(cli): abort ingest provider calls cleanly * fix(cli): propagate ingest cancellation * fix(cli): reject pre-aborted ingest rate-limit waits * fix(cli): honor Claude rate-limit reset waits * fix(cli): retry thrown Codex rate-limit failures * fix(cli): type Claude rate-limit result details * fix(cli): emit ingest rate-limit countdowns from rejected signals * fix(cli): report ai sdk rate-limit header utilization * fix(cli): gate LLM rate-limit retries on the governor budget The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up to 6-7 times with no backoff when constructed without a RateLimitGovernor (scan, memory, setup) or with pacing disabled, ignoring Retry-After and worsening the limit. The outer retry loop only cooperates with the governor's pause, so without active pacing there is no backoff to apply. Route the retry bound through a single source: RateLimitGovernor .maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1 (no outer retry) when absent or disabled. All three runtimes (ai-sdk, codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries) still handles transient 429s. Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
This commit is contained in:
parent
5a8821073b
commit
c3d8cedb0b
35 changed files with 2336 additions and 72 deletions
|
|
@ -426,6 +426,177 @@ describe('IngestBundleRunner — Stages 1 → 7', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('uses the rate-limit governor for work-unit start slots', async () => {
|
||||
const deps = makeDeps();
|
||||
const acquireWorkSlot = vi.fn(async () => vi.fn());
|
||||
const runner = buildRunner(deps, {
|
||||
settings: {
|
||||
probeRowCount: 1,
|
||||
memoryIngestionModel: 'test-model',
|
||||
workUnitMaxConcurrency: 2,
|
||||
rateLimitGovernor: { acquireWorkSlot, subscribe: vi.fn(() => vi.fn()) } as never,
|
||||
},
|
||||
});
|
||||
deps.adapter.chunk.mockResolvedValue({
|
||||
workUnits: [
|
||||
{ unitKey: 'u1', rawFiles: ['a.yml'], peerFileIndex: [], dependencyPaths: [] },
|
||||
{ unitKey: 'u2', rawFiles: ['b.yml'], peerFileIndex: [], dependencyPaths: [] },
|
||||
],
|
||||
});
|
||||
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
|
||||
currentHashes: new Map([
|
||||
['a.yml', 'h1'],
|
||||
['b.yml', 'h2'],
|
||||
]),
|
||||
rawDirInWorktree: 'raw-sources/c1/fake/s',
|
||||
});
|
||||
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
|
||||
|
||||
await runner.run({
|
||||
jobId: 'j1',
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'fake',
|
||||
trigger: 'upload',
|
||||
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
|
||||
});
|
||||
|
||||
expect(acquireWorkSlot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes the job abort signal into rate-limit work-unit slots', async () => {
|
||||
const deps = makeDeps();
|
||||
const controller = new AbortController();
|
||||
const acquireWorkSlot = vi.fn(async () => vi.fn());
|
||||
const runner = buildRunner(deps, {
|
||||
settings: {
|
||||
probeRowCount: 1,
|
||||
memoryIngestionModel: 'test-model',
|
||||
workUnitMaxConcurrency: 1,
|
||||
rateLimitGovernor: { acquireWorkSlot, subscribe: vi.fn(() => vi.fn()) } as never,
|
||||
},
|
||||
});
|
||||
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
|
||||
currentHashes: new Map([['a.yml', 'h1']]),
|
||||
rawDirInWorktree: 'raw-sources/c1/fake/s',
|
||||
});
|
||||
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
|
||||
|
||||
await runner.run(
|
||||
{
|
||||
jobId: 'j1',
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'fake',
|
||||
trigger: 'upload',
|
||||
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
|
||||
},
|
||||
{ jobId: 'j1', abortSignal: controller.signal, startPhase: () => new TestJobContext('j1', null, async () => undefined, async () => undefined) } as any,
|
||||
);
|
||||
|
||||
expect(acquireWorkSlot).toHaveBeenCalledWith(controller.signal);
|
||||
});
|
||||
|
||||
it('does not convert aborted work-unit agent loops into failed work units', async () => {
|
||||
const deps = makeDeps();
|
||||
const controller = new AbortController();
|
||||
deps.agentRunner.runLoop.mockImplementation(async () => {
|
||||
controller.abort();
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
});
|
||||
const runner = buildRunner(deps, {
|
||||
settings: {
|
||||
probeRowCount: 1,
|
||||
memoryIngestionModel: 'test-model',
|
||||
workUnitMaxConcurrency: 1,
|
||||
},
|
||||
});
|
||||
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
|
||||
currentHashes: new Map([['a.yml', 'h1']]),
|
||||
rawDirInWorktree: 'raw-sources/c1/fake/s',
|
||||
});
|
||||
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
|
||||
|
||||
await expect(
|
||||
runner.run(
|
||||
{
|
||||
jobId: 'j1',
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'fake',
|
||||
trigger: 'upload',
|
||||
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
|
||||
},
|
||||
{ jobId: 'j1', abortSignal: controller.signal, startPhase: () => new TestJobContext('j1', null, async () => undefined, async () => undefined) } as any,
|
||||
),
|
||||
).rejects.toThrow(/Aborted/);
|
||||
|
||||
expect(deps.runsRepo.markFailed).toHaveBeenCalledWith('run-1');
|
||||
expect(deps.reportsRepo.create).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
failedWorkUnits: expect.arrayContaining(['u1']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits trace and memory-flow status for rate-limit waits', async () => {
|
||||
const deps = makeDeps();
|
||||
let subscriber: ((state: any) => void) | undefined;
|
||||
const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput());
|
||||
const runner = buildRunner(deps, {
|
||||
settings: {
|
||||
probeRowCount: 1,
|
||||
memoryIngestionModel: 'test-model',
|
||||
rateLimitGovernor: {
|
||||
acquireWorkSlot: vi.fn(async () => vi.fn()),
|
||||
subscribe: vi.fn((cb: (state: any) => void) => {
|
||||
subscriber = cb;
|
||||
return vi.fn();
|
||||
}),
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
(runner as any).runInner = async (_job: any, ctx: any) => {
|
||||
subscriber?.({
|
||||
kind: 'wait_tick',
|
||||
provider: 'claude-subscription',
|
||||
rateLimitType: 'five_hour',
|
||||
resumeAtMs: 2_000,
|
||||
remainingMs: 1_000,
|
||||
});
|
||||
ctx.memoryFlow.emit({ type: 'report_created', runId: 'run-1' });
|
||||
return {
|
||||
runId: 'run-1',
|
||||
syncId: 'sync-1',
|
||||
diffSummary: { added: 0, modified: 0, deleted: 0, unchanged: 0 },
|
||||
workUnitCount: 0,
|
||||
failedWorkUnits: [],
|
||||
artifactsWritten: 0,
|
||||
commitSha: null,
|
||||
};
|
||||
};
|
||||
|
||||
await runner.run(
|
||||
{
|
||||
jobId: 'j1',
|
||||
connectionId: 'c1',
|
||||
sourceKey: 'fake',
|
||||
trigger: 'upload',
|
||||
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
|
||||
},
|
||||
{ memoryFlow } as any,
|
||||
);
|
||||
|
||||
expect(memoryFlow.snapshot().events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'rate_limit_wait',
|
||||
provider: 'claude-subscription',
|
||||
rateLimitType: 'five_hour',
|
||||
resumeAtMs: 2_000,
|
||||
remainingMs: 1_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before squash when reconciliation leaves a touched wiki page with dangling refs', async () => {
|
||||
const deps = makeDeps();
|
||||
let currentToolSession: any = null;
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@ describe('createLocalBundleIngestRuntime', () => {
|
|||
'memoryIngestionModel',
|
||||
'probeRowCount',
|
||||
'profileIngest',
|
||||
'rateLimitGovernor',
|
||||
'workUnitFailureMode',
|
||||
'workUnitMaxConcurrency',
|
||||
'workUnitStepBudget',
|
||||
|
|
|
|||
|
|
@ -146,6 +146,29 @@ describe('memory-flow schemas', () => {
|
|||
expect(parsed.events).toContainEqual({ type: 'stage_skipped', stage: 'actions', reason: 'requires LLM' });
|
||||
});
|
||||
|
||||
it('accepts rate-limit wait replay events', () => {
|
||||
expect(
|
||||
memoryFlowReplayInputSchema.parse({
|
||||
...snapshot(),
|
||||
events: [
|
||||
{
|
||||
type: 'rate_limit_wait',
|
||||
provider: 'claude-subscription',
|
||||
rateLimitType: 'five_hour',
|
||||
resumeAtMs: 2_000,
|
||||
remainingMs: 1_000,
|
||||
},
|
||||
],
|
||||
}).events[0],
|
||||
).toEqual({
|
||||
type: 'rate_limit_wait',
|
||||
provider: 'claude-subscription',
|
||||
rateLimitType: 'five_hour',
|
||||
resumeAtMs: 2_000,
|
||||
remainingMs: 1_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses snapshot and closed stream events', () => {
|
||||
expect(memoryFlowStreamEventSchema.parse({ type: 'snapshot', snapshot: snapshot({ status: 'done' }) })).toEqual({
|
||||
type: 'snapshot',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue