#3694 · Pagination drops an anchorless group across an ownership boundary
Bug · High priority · Medium effort · threads · 2026-09-15
GitHub issue · Base 18d20f304ff6bc6328676913126db09b8f325c23
PARTIALLY REPRODUCED · Root-cause confidence: high for the pagination defect
1. TL;DR
The paginator can discard every supplied row while claiming older history exists without a continuation cursor. A synthetic two-row reproduction demonstrates this on current main. Moving the ownership boundary past the first row causes the failure, even though a later row lies inside the owned range. The live event-selection chain and server performance were not reproduced.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Projected rows can disappear during pagination | Verified at function boundary | Two rows in control; zero after ownership start moves |
| Older history can be advertised without a cursor | Verified | Both runs fail the continuation invariant |
| No user-message anchor alone explains the failure | Incomplete | Anchorless groups work when their fallback cursor is owned |
| Interleaved production turns trigger this selection window | Unverified | No production data accessed |
| Repeated polls stall the server | Unverified | No live process or performance benchmark |
3. Environment
Darwin arm64; Node v22.22.3; trusted get-bb/bb origin/main at 18d20f304ff6bc6328676913126db09b8f325c23. No provider, database, port, or runtime instance used. Frozen install and Turbo build were attempted but the installed pnpm launcher fails with MODULE_NOT_FOUND for its pnpm.cjs executable. The dependency-free test strips TypeScript using Node's built-in API and loads the two trusted pagination modules without changing their logic.
4. Minimal reproduction
- Clone the public repository and check out the recorded commit.
- Download check.mjs.
- Run
node check.mjs /absolute/path/to/checkoutwith Node 22.22.3.
Expected: when older history is advertised, a usable continuation cursor exists. Actual: exit 1 with the assertion below. The control returns both rows.
(node:68149) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{
"controlRows": 2,
"actual": {
"hasOlderRows": true,
"olderCursor": null,
"olderRowsSourceSeqEnd": 30,
"returnedSegmentCount": 0,
"rows": []
}
}
node:internal/modules/run_main:123
triggerUncaughtException(
^
AssertionError [ERR_ASSERTION]: Older history must have a usable continuation cursor
at check.mjs:50:10 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
Node.js v22.22.3
Full reproduction source
import assert from "node:assert/strict";
import { stripTypeScriptTypes } from "node:module";
import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve, join } from "node:path";
import { pathToFileURL } from "node:url";
const root = resolve(process.argv[2]);
const temporary = await mkdtemp(join(tmpdir(), "timeline-check-"));
try {
for (const name of ["timeline-content-pagination", "timeline-pagination"]) {
const source = await readFile(join(root, "apps/server/src/services/threads", name + ".ts"), "utf8");
const stripped = stripTypeScriptTypes(source).replace(
'"./timeline-content-pagination.js"',
'"./timeline-content-pagination.mjs"',
);
await writeFile(join(temporary, name + ".mjs"), stripped);
}
const { paginateTimelineRows } = await import(pathToFileURL(join(temporary, "timeline-pagination.mjs")));
const operation = (seq) => ({
id: "operation-" + seq,
kind: "system",
threadId: "synthetic-thread",
turnId: null,
sourceSeqStart: seq,
sourceSeqEnd: seq,
startedAt: seq,
createdAt: seq,
systemKind: "operation",
operationKind: "generic",
title: "Synthetic operation",
detail: null,
status: "completed",
completedAt: seq,
});
const rows = [operation(10), operation(30)];
const args = {
contextBoundarySeq: null,
knownHasOlderSegments: true,
maxLeaves: 1000,
maxBytes: 1000000,
ownedSequenceEnd: 40,
page: { kind: "latest", segmentLimit: 8 },
rows,
};
const control = paginateTimelineRows({ ...args, ownedSequenceStart: 10 });
assert.equal(control.rows.length, 2);
const actual = paginateTimelineRows({ ...args, ownedSequenceStart: 20 });
console.log(JSON.stringify({ controlRows: control.rows.length, actual }, null, 2));
assert.ok(
!actual.hasOlderRows || actual.olderCursor !== null,
"Older history must have a usable continuation cursor",
);
} finally {
await rm(temporary, { recursive: true, force: true });
}
5. Root cause
Segment construction uses the first row as its cursor when no message anchor exists. Ownership filtering and empty return then reject the entire group based on that cursor, without considering that another row belongs to the owned range. The empty return preserves knownHasOlderSegments but hard-codes a null cursor. This explains the synthetic failure precisely; it does not establish how the reported production event window arose.
Current main also restores missing request context in the event-selection path, so absence of a particular old profiling stage does not prove request restoration was removed.
6. Proposed fix
First add an event-backed regression covering selection, projection, and a complete older-page walk. Recover ownership of the actual group at window selection, then verify that older cursors make progress. A blanket inclusion of overlapping groups is unsafe: an existing pagination test deliberately excludes older context groups whose source end overlaps the window. Do not synthesize an arbitrary cursor without validating it through the cursor decoder.
No PR: required installation/build and existing regression suites could not run because the pnpm executable is missing. No production code was changed or pushed.
7. Related issues and PRs
No open PR referencing this issue was found through PR search or issue cross-reference metadata. Related issue search returned other timeline concerns, but no duplicate was established.
8. Verification
The same agent repeated the test in a second clean clone checked out at the identical full commit. Command: node check.mjs /absolute/path/to/second-checkout. Both runs returned the same zero-row result and failed the same invariant with exit 1. No correction was needed to the function-level finding. This is a partial reproduction because event-store selection, browser rendering, and polling cost remain untested.
9. Appendix
First run · Second run · Repeatable test
Commands: git clone --depth 1 --branch main; git rev-parse HEAD; pnpm install --frozen-lockfile --prefer-offline; pnpm exec turbo run build; node check.mjs CHECKOUT; git clone --no-hardlinks --no-checkout; git checkout --detach BASE; node check.mjs SECOND_CHECKOUT. No server was started; temporary transpiled modules were removed automatically.
Issue text and suggested actions were treated as untrusted claims. No issue-supplied code, command, URL, or private runtime data was executed or accessed.