Repository navigation
fix: correct backend and mixed-repo smoke failures #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| export interface SourceCoverageActionInput { | ||
| skippedFiles: number; | ||
| candidateBudgetExhausted?: boolean; | ||
| candidateRetrievalFailed?: boolean; | ||
| refreshAction?: string; | ||
| } | ||
|
|
||
| const SOURCE_REFRESH_ACTION = | ||
| 'Run refresh_index with incrementalOnly=true to refresh changed or unavailable sources, then retry.'; | ||
| const CANDIDATE_BUDGET_ACTION = | ||
| 'Search candidate budget was exhausted. Narrow the query to a concrete symbol or path and retry.'; | ||
|
|
||
| /** Stale or unreadable sources need a refresh. An exhausted candidate budget does not. */ | ||
| export function sourceCoverageRefreshAction(input: { | ||
| skippedFiles: number; | ||
| candidateBudgetExhausted: boolean; | ||
| candidateRetrievalFailed: boolean; | ||
| }): string | undefined { | ||
| if (input.skippedFiles > 0 || input.candidateRetrievalFailed) return SOURCE_REFRESH_ACTION; | ||
| if (input.candidateBudgetExhausted) return CANDIDATE_BUDGET_ACTION; | ||
| return undefined; | ||
| } | ||
|
|
||
| export function presentSourceCoverage<T extends SourceCoverageActionInput>(coverage: T): T { | ||
| if (coverage.skippedFiles > 0 || coverage.candidateRetrievalFailed === true) return coverage; | ||
| const refreshAction = sourceCoverageRefreshAction({ | ||
| skippedFiles: 0, | ||
| candidateBudgetExhausted: coverage.candidateBudgetExhausted === true, | ||
| candidateRetrievalFailed: false | ||
| }); | ||
| if (!refreshAction || refreshAction === coverage.refreshAction) return coverage; | ||
| return { ...coverage, refreshAction }; | ||
|
Comment on lines
+24
to
+32
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the index is older than seven days and the candidate budget runs out with no skipped files, Editing stays blocked, but narrowing the query does not clear the stale-index gate. Keep the stale-index refresh instruction ahead of the budget advice. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,61 @@ | ||
| import { promises as fs } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import type { Tool } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { INDEXING_STATS_FILENAME, KEYWORD_INDEX_FILENAME } from '../constants/codebase-context.js'; | ||
| import { resolveIndexArtifact } from '../core/index-generation.js'; | ||
| import type { ToolContext, ToolResponse } from './types.js'; | ||
|
|
||
| interface PersistedIndexingStats { | ||
| indexedFiles: number; | ||
| totalChunks: number; | ||
| totalFiles: number; | ||
| generatedAt: string; | ||
| } | ||
|
|
||
| interface DiskIndexSummary { | ||
| stats: PersistedIndexingStats; | ||
| searchableChunks: number; | ||
| } | ||
|
|
||
| function isPersistedIndexingStats(value: unknown): value is PersistedIndexingStats { | ||
| if (typeof value !== 'object' || value === null) return false; | ||
| const record = value as Record<string, unknown>; | ||
| return ( | ||
| typeof record.indexedFiles === 'number' && | ||
| Number.isFinite(record.indexedFiles) && | ||
| typeof record.totalChunks === 'number' && | ||
| Number.isFinite(record.totalChunks) && | ||
| typeof record.totalFiles === 'number' && | ||
| Number.isFinite(record.totalFiles) && | ||
| typeof record.generatedAt === 'string' | ||
| ); | ||
| } | ||
|
|
||
| function searchableChunkCount(value: unknown): number | null { | ||
| if (typeof value !== 'object' || value === null || !('chunks' in value)) return null; | ||
| const chunks = (value as { chunks?: unknown }).chunks; | ||
| return Array.isArray(chunks) ? chunks.length : null; | ||
| } | ||
|
|
||
| async function readDiskIndexSummary(rootPath: string): Promise<DiskIndexSummary | null> { | ||
| try { | ||
| const keywordPath = await resolveIndexArtifact(rootPath, KEYWORD_INDEX_FILENAME); | ||
| const statsPath = path.join(path.dirname(keywordPath), INDEXING_STATS_FILENAME); | ||
| const [statsRaw, indexRaw] = await Promise.all([ | ||
| fs.readFile(statsPath, 'utf-8'), | ||
| fs.readFile(keywordPath, 'utf-8') | ||
| ]); | ||
| const statsParsed: unknown = JSON.parse(statsRaw); | ||
| const indexParsed: unknown = JSON.parse(indexRaw); | ||
| const searchableChunks = searchableChunkCount(indexParsed); | ||
|
Comment on lines
+45
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Persist the kept-chunk count in Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| if (!isPersistedIndexingStats(statsParsed) || searchableChunks === null) return null; | ||
| return { stats: statsParsed, searchableChunks }; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| const loadedStatusModulePath = fileURLToPath(import.meta.url); | ||
|
|
||
| export const definition: Tool = { | ||
|
|
@@ -20,6 +74,15 @@ export async function handle( | |
| ctx: ToolContext | ||
| ): Promise<ToolResponse> { | ||
| const progress = ctx.indexState.indexer?.getProgress(); | ||
| const disk = await readDiskIndexSummary(ctx.rootPath); | ||
| const memory = ctx.indexState.stats; | ||
| const omittedChunks = | ||
| disk && disk.stats.totalChunks > disk.searchableChunks | ||
| ? disk.stats.totalChunks - disk.searchableChunks | ||
| : undefined; | ||
| const capFields = omittedChunks | ||
| ? { searchableChunks: disk?.searchableChunks, omittedChunks } | ||
| : {}; | ||
|
|
||
| return { | ||
| content: [ | ||
|
|
@@ -30,16 +93,24 @@ export async function handle( | |
| status: ctx.indexState.status, | ||
| rootPath: ctx.rootPath, | ||
| loadedStatusModulePath, | ||
| lastIndexed: ctx.indexState.lastIndexed?.toISOString(), | ||
| stats: ctx.indexState.stats | ||
| lastIndexed: ctx.indexState.lastIndexed?.toISOString() ?? disk?.stats.generatedAt, | ||
| stats: memory | ||
| ? { | ||
| totalFiles: ctx.indexState.stats.totalFiles, | ||
| indexedFiles: ctx.indexState.stats.indexedFiles, | ||
| totalChunks: ctx.indexState.stats.totalChunks, | ||
| duration: `${(ctx.indexState.stats.duration / 1000).toFixed(2)}s`, | ||
| incremental: ctx.indexState.stats.incremental | ||
| totalFiles: memory.totalFiles, | ||
| indexedFiles: memory.indexedFiles, | ||
| totalChunks: memory.totalChunks, | ||
| duration: `${(memory.duration / 1000).toFixed(2)}s`, | ||
| incremental: memory.incremental, | ||
| ...capFields | ||
| } | ||
| : undefined, | ||
| : disk | ||
| ? { | ||
| totalFiles: disk.stats.totalFiles, | ||
| indexedFiles: disk.stats.indexedFiles, | ||
| totalChunks: disk.stats.totalChunks, | ||
| ...capFields | ||
| } | ||
| : undefined, | ||
| progress: progress | ||
| ? { | ||
| phase: progress.phase, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import path from 'node:path'; | ||
| import { afterEach, describe, expect, it } from 'vitest'; | ||
| import { makePaths } from '../src/project-state.js'; | ||
| import { handle } from '../src/tools/get-indexing-status.js'; | ||
| import type { ToolContext } from '../src/tools/types.js'; | ||
|
|
||
| const roots: string[] = []; | ||
|
|
||
| afterEach(async () => { | ||
| await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); | ||
| }); | ||
|
|
||
| describe('indexing status disk stats', () => { | ||
| it('reports the keyword cap from persisted stats when the process has no in-memory stats', async () => { | ||
| const root = await mkdtemp(path.join(tmpdir(), 'cbc-status-cap-')); | ||
| roots.push(root); | ||
| const dir = path.join(root, '.codebase-context'); | ||
| await mkdir(dir); | ||
| await writeFile( | ||
| path.join(dir, 'indexing-stats.json'), | ||
| JSON.stringify({ | ||
| indexedFiles: 4, | ||
| totalChunks: 12, | ||
| totalFiles: 4, | ||
| generatedAt: '2026-10-30T22:40:09.166Z' | ||
| }) | ||
| ); | ||
| await writeFile( | ||
| path.join(dir, 'index.json'), | ||
| JSON.stringify({ chunks: [{ id: 'a' }, { id: 'b' }] }) | ||
| ); | ||
|
|
||
| const ctx: ToolContext = { | ||
| indexState: { status: 'ready' }, | ||
| paths: makePaths(root), | ||
| rootPath: root, | ||
| performIndexing: async () => undefined | ||
| }; | ||
| const response = await handle({}, ctx); | ||
| const payload = JSON.parse(response.content?.[0]?.text ?? '{}') as { | ||
| lastIndexed?: string; | ||
| stats?: { | ||
| indexedFiles?: number; | ||
| totalChunks?: number; | ||
| searchableChunks?: number; | ||
| omittedChunks?: number; | ||
| }; | ||
| }; | ||
|
|
||
| expect(payload.lastIndexed).toBe('2026-10-30T22:40:09.166Z'); | ||
| expect(payload.stats).toMatchObject({ | ||
| indexedFiles: 4, | ||
| totalChunks: 12, | ||
| searchableChunks: 2, | ||
| omittedChunks: 10 | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { | ||
| presentSourceCoverage, | ||
| sourceCoverageRefreshAction | ||
| } from '../src/core/source-coverage-action.js'; | ||
|
|
||
| describe('sourceCoverageRefreshAction', () => { | ||
| it('asks for a narrower query when only the candidate budget is exhausted', () => { | ||
| const action = sourceCoverageRefreshAction({ | ||
| skippedFiles: 0, | ||
| candidateBudgetExhausted: true, | ||
| candidateRetrievalFailed: false | ||
| }); | ||
| expect(action).toMatch(/candidate budget/i); | ||
| expect(action).not.toMatch(/refresh_index/); | ||
| }); | ||
|
|
||
| it('keeps refresh guidance when nominated sources were skipped', () => { | ||
| const action = sourceCoverageRefreshAction({ | ||
| skippedFiles: 2, | ||
| candidateBudgetExhausted: true, | ||
| candidateRetrievalFailed: false | ||
| }); | ||
| expect(action).toMatch(/refresh_index/); | ||
| }); | ||
|
|
||
| it('replaces a refresh instruction when the only gap is the candidate budget', () => { | ||
| const presented = presentSourceCoverage({ | ||
| skippedFiles: 0, | ||
| candidateBudgetExhausted: true, | ||
| refreshAction: | ||
| 'Run refresh_index with incrementalOnly=true to refresh changed or unavailable sources, then retry.' | ||
| }); | ||
| expect(presented.refreshAction).toMatch(/candidate budget/i); | ||
| expect(presented.refreshAction).not.toMatch(/refresh_index/); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This commit includes a
.planning/ROADMAP.mdupdate despite the repository's explicit guardrail against committing.planning/**without a direct user request, and its pre-commit requirement to abort when those files are staged. Remove this roadmap change from the proposed patch unless its inclusion was explicitly authorized; the public smoke results can remain indocs/benchmark.md.AGENTS.md reference: AGENTS.md:L122-L124
Useful? React with 👍 / 👎.