fix(session-search): stop counting a deferred due row twice in filesDue

`status()` reports `filesDue` as `stateCounts().due + left`. The read loop
incremented `left` for every candidate the deadline cut off, including one
whose row already said `due` — and that row is what `stateCounts().due`
counts. A sweep that ran out of time therefore reported each already-due
transcript twice.

`left` now skips a deferred candidate whose row is already `due`. A
candidate with no row, and a `current` row whose file moved, still count:
those are the backlog no query can see, which is why `left` exists.
This commit is contained in:
Jinwoo-H
2026-09-16 11:54:04 -04:00
parent 97b9b64302
commit d5bc2428b7
2 changed files with 26 additions and 4 deletions
@@ -111,6 +111,22 @@ it('leaves what it ran out of time for owed, with nothing written down', async (
expect(store.files()).toHaveLength(2)
})
// A deferred candidate whose row already says `due` is in `stateCounts().due`,
// which the status adds `left` to; counting it here would report it twice.
it('leaves a deferred candidate out of the count when its row already says due', async () => {
await passOverAll()
for (const row of store.files()) {
store.setFileState(row.path, 'due')
}
const cut = await runSessionSearchIndexPass(store, await candidates(), {
rows: rows(),
overdue: () => true
})
expect(cut).toMatchObject({ outOfTime: true, left: 0 })
})
// The deadline is never applied before the pass has read anything, so a single
// transcript larger than one deadline is read alone rather than starved.
it('reads one file even when the deadline has already expired', async () => {
@@ -33,9 +33,11 @@ export type SessionSearchIndexPassOptions = {
* drop. What the reads themselves leave behind is written by the index consumer
* onto the rows.
*
* `left` is what makes the backlog sayable: a candidate with no row yet is owed
* a read and counted by no query, so without this the status has no way to tell
* an index that holds everything from one that has barely started.
* `left` is what makes the backlog sayable: a candidate with no row yet, or one
* whose row does not say it is owed, is counted by no `due` query, so without
* this the status has no way to tell an index that holds everything from one
* that has barely started. Candidates whose row is already `due` are left out,
* because the status adds `left` to that same count.
*/
export async function runSessionSearchIndexPass(
store: SessionSearchStore,
@@ -67,7 +69,11 @@ export async function runSessionSearchIndexPass(
// count of what a pass left is worth more than the microseconds.
outOfTime ||= read > 0 && options.overdue?.() === true
if (outOfTime) {
left += 1
// A `due` row is already in `stateCounts().due`, which the status adds
// this to; counting it here would report the same file twice.
if (row?.state !== 'due') {
left += 1
}
continue
}
// The clock the deadline reads is one the owner may close behind: the read