Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ describe("timeline page row merging", () => {
]);
});

it("keeps a pinned active-turn prompt ahead of prepended in-turn work", () => {
const prompt = userRow({ id: "active-turn-prompt", sequence: 1 });
const rows = prependOlderTimelineRows({
olderRows: [prompt, commandRow({ id: "older-work", sequence: 10 })],
loadedRows: [prompt, commandRow({ id: "latest-work", sequence: 20 })],
});

expect(rows.map((row) => row.id)).toEqual([
"active-turn-prompt",
"older-work",
"latest-work",
]);
});

it("keeps distinct byte-budget slices of one finished turn", () => {
const olderCommands = [
commandRow({ id: "command-1", sequence: 10 }),
Expand Down
68 changes: 65 additions & 3 deletions apps/server/src/services/threads/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from "@bb/server-contract";
import {
findStoredTimelineWindowByteBudgetFloor,
findFirstStoredTurnInputAcceptedRow,
findTimelineWindowBudgetFloorSequence,
getStoredEventRowsByParentToolCallIdsDataBytes,
getEnvironment,
Expand All @@ -37,6 +38,7 @@ import {
listLatestBackgroundTaskStateRowsByItemIds,
listLatestGoalEventRowsByThreadIds,
listLatestOpenBackgroundTaskStateRowsForThread,
listStoredClientTurnRequestRowsByKeys,
listStoredTimelineWindowEventRows,
listTodoSnapshotEventRowsForThread,
listStoredToolCallRowsByItemIds,
Expand Down Expand Up @@ -1021,6 +1023,8 @@ interface ResolveTimelineSegmentWindowArgs {
}

interface ResolvedTimelineSegmentWindow {
/** Unfinished turn whose work is cut by this sequence window. */
activeTurnId: string | null;
beforeSequence: number | undefined;
byteWindowSequenceStart: number | null;
/**
Expand Down Expand Up @@ -1061,8 +1065,15 @@ function applyTimelineWindowByteBudget(
if (floor.kind === "single-event-too-large") {
const hasOlderRows =
floor.hasOlderRows || args.window.knownHasOlderSegments === true;
const activeTurnId =
args.window.activeTurnId ??
findUnfinishedTurnCoveringSequence(db, {
sequence: floor.sequenceStart,
threadId: args.threadId,
});
return {
...args.window,
activeTurnId,
byteWindowSequenceStart: floor.sequenceStart,
knownHasOlderSegments: hasOlderRows,
oversizedEventPlaceholder: {
Expand Down Expand Up @@ -1093,8 +1104,16 @@ function applyTimelineWindowByteBudget(
return args.window;
}

const activeTurnId =
args.window.activeTurnId ??
findUnfinishedTurnCoveringSequence(db, {
sequence: floor.sequenceStart,
threadId: args.threadId,
});

return {
...args.window,
activeTurnId,
byteWindowSequenceStart: floor.sequenceStart,
requiresWholeItemClosure: true,
sequenceWindowStart: {
Expand Down Expand Up @@ -1169,7 +1188,10 @@ function resolveTimelineWindowBounds(
args: ResolveTimelineWindowBoundsArgs,
): Pick<
ResolvedTimelineSegmentWindow,
"effectiveSegmentLimit" | "sequenceStart" | "sequenceWindowStart"
| "activeTurnId"
| "effectiveSegmentLimit"
| "sequenceStart"
| "sequenceWindowStart"
> & { affordableAnchorCount: number } {
const { anchors, budgetFloorSequence, segmentLimit, threadId } = args;
const affordable = countAffordableAnchors(
Expand All @@ -1194,6 +1216,7 @@ function resolveTimelineWindowBounds(
})
) {
return {
activeTurnId: unfinishedTurnId,
affordableAnchorCount: 0,
effectiveSegmentLimit: segmentLimit,
sequenceWindowStart: {
Expand All @@ -1209,6 +1232,7 @@ function resolveTimelineWindowBounds(
// empty thread rather than a slow one.
const segmentCount = Math.max(1, affordable);
return {
activeTurnId: null,
affordableAnchorCount: segmentCount,
effectiveSegmentLimit: segmentCount,
sequenceWindowStart: null,
Expand All @@ -1235,6 +1259,7 @@ function resolveTimelineSegmentWindow(
): ResolvedTimelineSegmentWindow {
const { eventBudget, page, threadId } = args;
const noAnchors: ResolvedTimelineSegmentWindow = {
activeTurnId: null,
beforeSequence: undefined,
byteWindowSequenceStart: null,
requiresWholeItemClosure: false,
Expand Down Expand Up @@ -1314,6 +1339,7 @@ function resolveTimelineSegmentWindow(
threadId,
});
return {
activeTurnId: bounds.activeTurnId,
// Every cursor names the first sequence the page that issued it covered,
// so this page ends exactly there. Reading up to the *next anchor* past
// the cursor instead — and trimming that segment off after projecting it
Expand Down Expand Up @@ -1353,6 +1379,7 @@ function resolveTimelineSegmentWindow(
threadId,
});
return {
activeTurnId: bounds.activeTurnId,
beforeSequence: undefined,
byteWindowSequenceStart: null,
requiresWholeItemClosure: bounds.sequenceWindowStart !== null,
Expand All @@ -1367,6 +1394,33 @@ function resolveTimelineSegmentWindow(
};
}

function ensureTimelineWindowActiveTurnInputRows(
db: DbConnection,
args: TimelineWindowRowsArgs & { activeTurnId: string | null },
): StoredEventRow[] {
if (args.activeTurnId === null) {
return [...args.rows];
}

const acceptedInputRow = findFirstStoredTurnInputAcceptedRow(db, {
threadId: args.threadId,
turnId: args.activeTurnId,
});
if (!acceptedInputRow) {
return [...args.rows];
}

const clientRequestId = parseAcceptedInputClientRequestId(acceptedInputRow);
const requestRows = listStoredClientTurnRequestRowsByKeys(db, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — High: the prompt backfill bypasses the timeline byte limit.

The four-megabyte check runs before this query. The code then adds the complete request row to every active-turn page.

Prompt text has no size limit. A large prompt can make each refresh consume excessive memory and make the server unavailable.

Please include these rows in the byte calculation. Use the existing oversized-event result when the prompt exceeds the remaining limit.

keys: [{ requestId: clientRequestId, threadId: args.threadId }],
});
return mergeStoredEventRowsById([
...requestRows,
acceptedInputRow,
...args.rows,
]);
}

function selectStandardTimelineEventRows(
db: DbConnection,
thread: Thread,
Expand Down Expand Up @@ -1402,15 +1456,23 @@ function selectStandardTimelineEventRows(
threadId: thread.id,
};
const windowRows = listStoredTimelineWindowEventRows(db, windowArgs);
const windowRowsWithActiveTurnInput = ensureTimelineWindowActiveTurnInputRows(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Medium: older byte pages can show the active prompt more than once.

The later page transform adds a different :sequence-page: suffix to each older page row. The client removes duplicates only when IDs match.

The new client test uses one prompt ID for both pages. Actual server responses use different prompt IDs.

Please keep the restored prompt ID stable across byte pages. Add a test that merges real latest-page and older-page IDs.

db,
{
activeTurnId: window.activeTurnId,
rows: windowRows,
threadId: thread.id,
},
);
const wholeItemWindowRows = window.requiresWholeItemClosure
? ensureSequenceWindowWholeItemRows(db, {
beforeSequence,
maxInlineOutputChars,
rows: windowRows,
rows: windowRowsWithActiveTurnInput,
sequenceStart,
threadId: thread.id,
})
: windowRows;
: windowRowsWithActiveTurnInput;
const selectedRowsWithTurnStarts = ensureTimelineWindowTurnStartedRows(db, {
threadId: thread.id,
rows: wholeItemWindowRows,
Expand Down
51 changes: 47 additions & 4 deletions apps/server/test/services/threads/timeline-in-turn-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ function walkAllPages(
thread: Thread,
eventBudget: number,
): WalkResult {
const rowsByPage: string[][] = [];
const rowsByPage: TimelineRow[][] = [];
const seenCursors = new Set<string>();
let cursor: TimelinePaginationCursor | null = null;
let maxEventRowCount = 0;
Expand All @@ -483,7 +483,7 @@ function walkAllPages(
const { profile, response } = buildPage(db, thread, eventBudget, cursor);
pages += 1;
maxEventRowCount = Math.max(maxEventRowCount, profile.eventRowCount);
rowsByPage.push(response.rows.map((row) => JSON.stringify(row)));
rowsByPage.push(response.rows);
if (!response.timelinePage.hasOlderRows) {
break;
}
Expand All @@ -499,11 +499,25 @@ function walkAllPages(
expect(pages).toBeLessThan(100);
}

return { maxEventRowCount, pages, rows: rowsByPage.reverse().flat() };
const serializedRowsById = new Map<string, string>();
for (const row of rowsByPage.reverse().flat()) {
const serialized = JSON.stringify(row);
const existing = serializedRowsById.get(row.id);
if (existing !== undefined) {
expect(existing).toBe(serialized);
continue;
}
serializedRowsById.set(row.id, serialized);
}
return {
maxEventRowCount,
pages,
rows: [...serializedRowsById.values()],
};
}

describe("in-turn timeline windows", () => {
it("bounds a running turn that is larger than the whole budget", () => {
it("bounds a running turn while keeping its initiating user message", () => {
const { db, thread } = setup();
// One 300-item turn still running: no user message inside it to cut on.
seedTurns(db, thread, { completeLastTurn: false, itemsPerTurn: [300] });
Expand All @@ -516,6 +530,14 @@ describe("in-turn timeline windows", () => {

const budgeted = buildPage(db, thread, 100, null);
expect(budgeted.profile.eventRowCount).toBeLessThanOrEqual(120);
expect(budgeted.response.rows).toContainEqual(
expect.objectContaining({
kind: "conversation",
role: "user",
text: "User message 1",
turnRequest: expect.objectContaining({ status: "accepted" }),
}),
);
expect(budgeted.response.rows.length).toBeLessThan(
unbudgeted.response.rows.length,
);
Expand Down Expand Up @@ -664,13 +686,34 @@ describe("in-turn timeline windows", () => {
itemStart: 75,
});
const second = buildPage(db, thread, LARGE_BUDGET, null).response;
const secondOlderCursor = second.timelinePage.olderCursor;
if (secondOlderCursor === null) {
throw new Error("expected the byte-budgeted turn to have an older page");
}
const secondOlder = buildPage(
db,
thread,
LARGE_BUDGET,
secondOlderCursor,
).response;
appendCommandItems(db, thread, {
commandChars: 25_000,
count: 10,
itemStart: 95,
});
const third = buildPage(db, thread, LARGE_BUDGET, null).response;

for (const response of [first, second, secondOlder, third]) {
expect(response.rows).toContainEqual(
expect.objectContaining({
kind: "conversation",
role: "user",
text: "User message 1",
turnRequest: expect.objectContaining({ status: "accepted" }),
}),
);
}

expect(first.timelinePage.olderCursor?.anchorSeq).not.toBe(
second.timelinePage.olderCursor?.anchorSeq,
);
Expand Down
26 changes: 26 additions & 0 deletions packages/db/src/data/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,11 @@ export interface ListOpenTurnInputAcceptedRowsByThreadIdsArgs {
threadIds: readonly string[];
}

export interface FindFirstStoredTurnInputAcceptedRowArgs {
threadId: string;
turnId: string;
}

export interface ThreadClientTurnRequestKey {
requestId: ClientTurnRequestId;
threadId: string;
Expand Down Expand Up @@ -1229,6 +1234,27 @@ export function listOpenTurnInputAcceptedRowsByThreadIds(
);
}

export function findFirstStoredTurnInputAcceptedRow(
db: DbQueryConnection,
args: FindFirstStoredTurnInputAcceptedRowArgs,
): StoredEventRow | null {
return (
db
.select(storedEventRowFields)
.from(events)
.where(
and(
eq(events.threadId, args.threadId),
eq(events.turnId, args.turnId),
eq(events.type, "turn/input/accepted"),
),
)
.orderBy(events.sequence)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — High: this lookup scales with the full thread history.

SQLite selects the thread/type/sequence index to satisfy this order. It then filters accepted rows until it reaches the active turn.

A 100,000-turn check used about 500,000 VM steps here. The request-row lookup used about 600,000 more steps per page.

Please make this lookup selective. Constrain the accepted row by item_id IS NULL, and add an indexed request identifier lookup.

.limit(1)
.get() ?? null
);
}

export function listStoredClientTurnRequestRowsByKeys(
db: DbQueryConnection,
args: ListStoredClientTurnRequestRowsByKeysArgs,
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export {
deleteThreadEventSuffixInTransaction,
getHighWaterMarks,
findStoredClientTurnRequestSequenceByRequestId,
findFirstStoredTurnInputAcceptedRow,
findStoredEventRow,
getActiveStoredTurnId,
hasRootStoredTurnStarted,
Expand Down Expand Up @@ -402,6 +403,7 @@ export type {
DeleteThreadEventSuffixArgs,
DeleteThreadEventSuffixResult,
FindStoredClientTurnRequestSequenceByRequestIdArgs,
FindFirstStoredTurnInputAcceptedRowArgs,
GetStoredTurnRequestEventForTurnArgs,
GetRootStoredTurnStartedSequenceArgs,
GetLatestThreadInterruptedReasonArgs,
Expand Down
42 changes: 42 additions & 0 deletions packages/db/test/data/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
appendStoredThreadEventInTransaction,
appendStoredThreadEventsInTransaction,
findStoredEventRow,
findFirstStoredTurnInputAcceptedRow,
findStoredTimelineWindowByteBudgetFloor,
getActiveStoredTurnId,
getHighWaterMarks,
Expand Down Expand Up @@ -1433,6 +1434,47 @@ describe("events", () => {
expect(rowsByThreadId.get(otherThread.id)?.sequence).toBe(1);
});

it("finds the first accepted input for one exact turn", () => {
const { db, thread } = setup();

insertEvents(db, noopNotifier, [
{
threadId: thread.id,
sequence: 1,
type: "turn/input/accepted",
...createTurnEventFields({ turnId: "turn-target" }),
data: JSON.stringify({ clientRequestId: "creq_23456789aa" }),
},
{
threadId: thread.id,
sequence: 2,
type: "turn/input/accepted",
...createTurnEventFields({ turnId: "turn-other" }),
data: JSON.stringify({ clientRequestId: "creq_23456789ab" }),
},
{
threadId: thread.id,
sequence: 3,
type: "turn/input/accepted",
...createTurnEventFields({ turnId: "turn-target" }),
data: JSON.stringify({ clientRequestId: "creq_23456789ac" }),
},
]);

expect(
findFirstStoredTurnInputAcceptedRow(db, {
threadId: thread.id,
turnId: "turn-target",
})?.sequence,
).toBe(1);
expect(
findFirstStoredTurnInputAcceptedRow(db, {
threadId: thread.id,
turnId: "turn-missing",
}),
).toBeNull();
});

it("lists client turn request rows by thread/request keys", () => {
const { db, project, thread } = setup();
const otherThread = createThread(db, noopNotifier, {
Expand Down