← reports

#2066 · Timeline cache retains obsolete revisions for the same request shape

Bug / perf Priority: Medium Effort: low perf threads open on GitHub 2026-08-21 · base fcada5a3b

Verdict: REPRODUCED · Root-cause confidence: high

Linked PR: #2067 (draft) — review verdict: REQUEST CHANGES (minor; the fix itself is correct and measured effective).

1. TL;DR

The bb server memoizes built thread-timeline responses in a 128-entry LRU so that refetching an idle thread is cheap. The cache key includes the thread's high-water event sequence (maxSeq). A thread's maxSeq only ever increases, so the moment an event is appended the previous entry can never be looked up again, but it stays strongly referenced until 127 more entries push it out. During a streaming turn the web client refetches the same window after every event batch (at least every 50 ms, up to 1 s on slow builds: realtime-cache-registry.ts:179-180 clamps the trailing delay between 50 ms and 1,000 ms scaled by the observed fetch duration), so one active thread fills all 128 slots with dead revisions within a few seconds and keeps them there. This is bounded (128 × one response object), not a leak, but it is pure waste: on a seeded 9,001-event thread 100 append+refetch rounds grew the server's GC'd V8 heap by 19–22 MB on fcada5a3b versus 3.6 MB with PR #2067 applied. Larger threads (the reporter's had 1,400-event windows) retain proportionally more. The issue's core claim is correct; its secondary numbers ("response payloads 1.1–4.8 MiB") appear to be a misreading of the eventDataBytes field of the slow-build log (input event JSON, not the response).

2. Claims vs findings

Claim from the issueStatusEvidence
The cache keys entries by request params and maxSeq.VerifiedbuildThreadTimelineCacheKey returns `${maxSeq}|${paramsKey}`; the route passes it at data.ts:358.
Every appended event creates a new key and the obsolete revision stays resident until global LRU eviction.VerifiedRoute-level test against the real GET /threads/:id/timeline: after 150 append+fetch rounds on one thread cache.size === 128 (only 1 entry is reachable). Unit test: 2 revisions → size 2. See §4.
Obsolete entries are unreachable by lookup (so retaining them has no value).VerifiedmaxSeq is MAX(sequence) (events.ts:3065) and is monotonic: even message-edit, the one flow that deletes events, first appends a system/operation event above the old max and then deletes the suffix below it (thread-edit-message.ts:554-578). Pruning never lowers it (doc comment, timeline-cache.ts:16).
Regression assertion fails with 2 !== 1 before the fix.VerifiedAssertionError: expected 2 to be 1 on fcada5a3b (§4, unit test).
Active thread stayed below the projected-row cutoff, so revisions were cacheable.Verified (in general)Cap is 200 rows (L33). The seeded 9,001-event thread's latest window is 99 rows / 107 KB JSON; in the live loop rows stayed ≤ 200 for the first 100 appended items. The 200-row cap only excludes large expanded turns; ordinary streaming turns are cached on every event.
"Response payloads around 1.1–4.8 MiB" in server logs.Refuted as statedThe server never logs response size. The slow-build log (timeline-build-log.ts:93-112) logs eventDataBytes — the JSON size of the input events in the window. On my thread that field was 459,376 bytes for a 107,571-byte response (4.3:1). The reporter's 1.1–4.8 MiB is almost certainly eventDataBytes; the cached objects were likely a quarter of that per revision.
Desktop app at 1.5–1.7 GB, server 300–650 MB, caused/amplified by this retention.Unverified (plausible contributor, not shown to be dominant)No heap snapshot in the issue. Measured here: 128 dead revisions of a 100–200-row response ≈ 20 MB of heap. For a 1,400-event window the per-revision object is larger (perhaps 0.3–1 MB), so 128 revisions could plausibly reach 40–130 MB. It cannot explain 300–650 MB on its own; the same incident also had 1.34 s builds decoding 1.1–4.8 MiB of events per request (see #1749, #1129).
Not a duplicate of #1749.Verified#1749 is about the event-budget calibration of the window size; this is about response retention in the cache. Related, not duplicate.
Fix is localized and low effort; does not change response data or cacheability.VerifiedPR #2067 touches one service file, one route call site and one test; all timeline/delta/public-thread tests pass (§7).

3. Environment

4. Minimal reproduction

4a. Unit level (the issue's own steps) — fails on fcada5a3b

  1. Save repro-2066-unit.test.ts as apps/server/test/services/threads/repro-2066-unit.test.ts.
  2. Run it from apps/server:
    pnpm exec vitest run test/services/threads/repro-2066-unit.test.ts
    
    expected: both tests pass (cache.size === 1 after a newer revision of the same shape)
    actual:
     RUN  v4.1.1 /Users/sawyerhood/.bb-machines/bee.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-23/apps/server
     ❯  @bb/server  test/services/threads/repro-2066-unit.test.ts (2 tests | 2 failed) 4ms
         × replaces the previous revision of the same request shape 3ms
         × is bounded only by the global LRU for a single streaming thread 1ms
    ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
     FAIL   @bb/server  test/services/threads/repro-2066-unit.test.ts > #2066 unit > replaces the previous revision of the same request shape
    AssertionError: expected 2 to be 1 // Object.is equality
    - Expected
    + Received
    - 1
    + 2
     ❯ test/services/threads/repro-2066-unit.test.ts:68:24
         66|     );
         67|     // Expected 1 (only maxSeq 11 is reachable). Actual on fcada5a3b: …
         68|     expect(cache.size).toBe(1);
           |                        ^
         69|   });
         70|
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
     FAIL   @bb/server  test/services/threads/repro-2066-unit.test.ts > #2066 unit > is bounded only by the global LRU for a single streaming thread
    AssertionError: expected 128 to be 1 // Object.is equality
    - Expected
    + Received
    - 1
    + 128
     ❯ test/services/threads/repro-2066-unit.test.ts:79:24
         77|     }
         78|     // Expected 1. Actual on fcada5a3b: 128 (DEFAULT_MAX_ENTRIES).
         79|     expect(cache.size).toBe(1);
           |                        ^
         80|   });
         81| });
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯
     Test Files  1 failed (1)
          Tests  2 failed (2)
       Start at  09:38:39
       Duration  82ms (transform 12ms, setup 0ms, import 17ms, tests 4ms, environment 0ms)
repro-2066-unit.test.ts
/**
 * Unit-level repro for get-bb/bb#2066 against the cache in isolation, using the
 * exact key builder the route uses. Two revisions (maxSeq 10 then 11) of one
 * request shape leave two resident entries although only the newest can ever
 * be looked up again (a thread's maxSeq never decreases).
 */
import { describe, expect, it } from "vitest";
import type { ThreadTimelineResponse } from "@bb/server-contract";
import {
  buildThreadTimelineCacheKey,
  createThreadTimelineCache,
  type ThreadTimelineCacheKeyArgs,
} from "../../../src/services/threads/timeline-cache.js";

function makeResponse(rowCount: number): ThreadTimelineResponse {
  return {
    rows: Array.from({ length: rowCount }, (_, index) => ({
      id: `row-${index}`,
      kind: "system",
      threadId: "thr_x",
      turnId: null,
      sourceSeqStart: index,
      sourceSeqEnd: index,
      startedAt: 0,
      createdAt: 0,
      systemKind: "debug",
      title: "t",
      detail: null,
      status: null,
    })),
    activePromptMode: null,
    activeThinking: null,
    activeWorkflows: [],
    activeBackgroundCommands: [],
    pendingTodos: null,
    goal: null,
    modelFallback: null,
    maxSeq: 0,
    timelinePage: {
      kind: "latest",
      segmentLimit: 20,
      returnedSegmentCount: 0,
      hasOlderRows: false,
      olderCursor: null,
    },
  };
}

const shape: ThreadTimelineCacheKeyArgs = {
  threadId: "thr_x",
  maxSeq: 10,
  status: "active",
  environmentId: null,
  page: { kind: "latest", segmentLimit: 20 },
  includeNestedRows: false,
  summaryOnly: false,
  includeProviderUnhandledOperations: false,
};

describe("#2066 unit", () => {
  it("replaces the previous revision of the same request shape", () => {
    const cache = createThreadTimelineCache();
    cache.getOrBuild(buildThreadTimelineCacheKey(shape), () => makeResponse(3));
    cache.getOrBuild(buildThreadTimelineCacheKey({ ...shape, maxSeq: 11 }), () =>
      makeResponse(3),
    );
    // Expected 1 (only maxSeq 11 is reachable). Actual on fcada5a3b: 2.
    expect(cache.size).toBe(1);
  });

  it("is bounded only by the global LRU for a single streaming thread", () => {
    const cache = createThreadTimelineCache();
    for (let maxSeq = 1; maxSeq <= 500; maxSeq++) {
      cache.getOrBuild(buildThreadTimelineCacheKey({ ...shape, maxSeq }), () =>
        makeResponse(3),
      );
    }
    // Expected 1. Actual on fcada5a3b: 128 (DEFAULT_MAX_ENTRIES).
    expect(cache.size).toBe(1);
  });
});

Scope of this file: it calls getOrBuild(buildThreadTimelineCacheKey(shape), build), i.e. the base string-key signature, so it only demonstrates the bug on fcada5a3b. Any fix changes that signature (PR #2067 takes { paramsKey, revisionKey }, the proposed fix in §6 takes { paramsKey, maxSeq }), and against either this file throws TypeError: Cannot read properties of undefined (reading 'value') rather than passing. The same two scenarios rewritten for each new signature are repro-2066-unit-pr2067.test.ts (passes on the PR branch, 2/2; log, which also shows the base-signature file's TypeError on that branch) and repro-2066-unit-after-fix.test.ts (passes on base + proposed-fix-2066.diff, 2/2; log). The signature-independent fail-before/pass-after evidence is the route-level test in §4b.

repro-2066-unit-pr2067.test.ts
/**
 * The two #2066 unit scenarios (repro-2066-unit.test.ts) rewritten against
 * PR #2067's `getOrBuild({ paramsKey, revisionKey }, build)` signature, so the
 * "pass after" half of the claim can be shown on the PR branch. The base-tree
 * version cannot run against the PR: it passes a string key and throws
 * `TypeError: Cannot read properties of undefined (reading 'value')`.
 *
 * Note: this file also fails on fcada5a3b (2 and 128 instead of 1) because the
 * base cache accepts the object as an opaque Map key, but that is a type error
 * at compile time, not evidence; use repro-2066-unit.test.ts for "fails before".
 */
import { describe, expect, it } from "vitest";
import type { ThreadTimelineResponse } from "@bb/server-contract";
import {
  buildThreadTimelineCacheKey,
  buildThreadTimelineParamsKey,
  createThreadTimelineCache,
  type ThreadTimelineCacheKeyArgs,
} from "../../../src/services/threads/timeline-cache.js";

function makeResponse(rowCount: number): ThreadTimelineResponse {
  return {
    rows: Array.from({ length: rowCount }, (_, index) => ({
      id: `row-${index}`,
      kind: "system",
      threadId: "thr_x",
      turnId: null,
      sourceSeqStart: index,
      sourceSeqEnd: index,
      startedAt: 0,
      createdAt: 0,
      systemKind: "debug",
      title: "t",
      detail: null,
      status: null,
    })),
    activePromptMode: null,
    activeThinking: null,
    activeWorkflows: [],
    activeBackgroundCommands: [],
    pendingTodos: null,
    goal: null,
    modelFallback: null,
    maxSeq: 0,
    timelinePage: {
      kind: "latest",
      segmentLimit: 20,
      returnedSegmentCount: 0,
      hasOlderRows: false,
      olderCursor: null,
    },
  };
}

const shape: ThreadTimelineCacheKeyArgs = {
  threadId: "thr_x",
  maxSeq: 10,
  status: "active",
  environmentId: null,
  page: { kind: "latest", segmentLimit: 20 },
  includeNestedRows: false,
  summaryOnly: false,
  includeProviderUnhandledOperations: false,
};

// Exactly what routes/threads/data.ts does on the PR branch.
const keysFor = (args: ThreadTimelineCacheKeyArgs) => ({
  paramsKey: buildThreadTimelineParamsKey(args),
  revisionKey: buildThreadTimelineCacheKey(args),
});

describe("#2066 unit (PR #2067 signature)", () => {
  it("replaces the previous revision of the same request shape", () => {
    const cache = createThreadTimelineCache();
    cache.getOrBuild(keysFor(shape), () => makeResponse(3));
    cache.getOrBuild(keysFor({ ...shape, maxSeq: 11 }), () => makeResponse(3));
    expect(cache.size).toBe(1);
  });

  it("keeps one entry for a single streaming thread regardless of revision count", () => {
    const cache = createThreadTimelineCache();
    for (let maxSeq = 1; maxSeq <= 500; maxSeq++) {
      cache.getOrBuild(keysFor({ ...shape, maxSeq }), () => makeResponse(3));
    }
    expect(cache.size).toBe(1);
  });
});
repro-2066-unit-after-fix.test.ts
/**
 * The two #2066 unit scenarios (repro-2066-unit.test.ts) written against the
 * proposed fix's `getOrBuild({ paramsKey, maxSeq }, build)` signature
 * (proposed-fix-2066.diff). Run on fcada5a3b + proposed-fix-2066.diff.
 * Does not apply to fcada5a3b (string key) or PR #2067 (`revisionKey`).
 */
import { describe, expect, it } from "vitest";
import type { ThreadTimelineResponse } from "@bb/server-contract";
import {
  buildThreadTimelineParamsKey,
  createThreadTimelineCache,
  type ThreadTimelineCacheKeyArgs,
} from "../../../src/services/threads/timeline-cache.js";

function makeResponse(rowCount: number): ThreadTimelineResponse {
  return {
    rows: Array.from({ length: rowCount }, (_, index) => ({
      id: `row-${index}`,
      kind: "system",
      threadId: "thr_x",
      turnId: null,
      sourceSeqStart: index,
      sourceSeqEnd: index,
      startedAt: 0,
      createdAt: 0,
      systemKind: "debug",
      title: "t",
      detail: null,
      status: null,
    })),
    activePromptMode: null,
    activeThinking: null,
    activeWorkflows: [],
    activeBackgroundCommands: [],
    pendingTodos: null,
    goal: null,
    modelFallback: null,
    maxSeq: 0,
    timelinePage: {
      kind: "latest",
      segmentLimit: 20,
      returnedSegmentCount: 0,
      hasOlderRows: false,
      olderCursor: null,
    },
  };
}

const shape: ThreadTimelineCacheKeyArgs = {
  threadId: "thr_x",
  status: "active",
  environmentId: null,
  page: { kind: "latest", segmentLimit: 20 },
  includeNestedRows: false,
  summaryOnly: false,
  includeProviderUnhandledOperations: false,
};
const paramsKey = buildThreadTimelineParamsKey(shape);

describe("#2066 unit (proposed-fix signature)", () => {
  it("replaces the previous revision of the same request shape", () => {
    const cache = createThreadTimelineCache();
    cache.getOrBuild({ paramsKey, maxSeq: 10 }, () => makeResponse(3));
    cache.getOrBuild({ paramsKey, maxSeq: 11 }, () => makeResponse(3));
    expect(cache.size).toBe(1);
  });

  it("keeps one entry for a single streaming thread regardless of revision count", () => {
    const cache = createThreadTimelineCache();
    for (let maxSeq = 1; maxSeq <= 500; maxSeq++) {
      cache.getOrBuild({ paramsKey, maxSeq }, () => makeResponse(3));
    }
    expect(cache.size).toBe(1);
  });
});
pr2067-unit-variants.log (PR branch: PR-signature file passes, base-signature file throws)
 RUN  v4.1.1 /Users/sawyerhood/.bb-machines/bee.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-23/apps/server
 ✓  @bb/server  test/services/threads/repro-2066-unit-pr2067.test.ts > #2066 unit (PR #2067 signature) > replaces the previous revision of the same request shape 1ms
 ✓  @bb/server  test/services/threads/repro-2066-unit-pr2067.test.ts > #2066 unit (PR #2067 signature) > keeps one entry for a single streaming thread regardless of revision count 1ms
 ×  @bb/server  test/services/threads/repro-2066-unit.test.ts > #2066 unit > replaces the previous revision of the same request shape 2ms
   → Cannot read properties of undefined (reading 'value')
 ×  @bb/server  test/services/threads/repro-2066-unit.test.ts > #2066 unit > is bounded only by the global LRU for a single streaming thread 0ms
   → Cannot read properties of undefined (reading 'value')
 ✓  @bb/server  test/services/threads/pr-2067-adversarial.test.ts > PR #2067 adversarial > an oversized newer revision evicts the previous cacheable revision (claimed in PR body, untested there) 1ms
 ✓  @bb/server  test/services/threads/pr-2067-adversarial.test.ts > PR #2067 adversarial > a replacement is most-recently-used for LRU purposes 1ms
 ✓  @bb/server  test/services/threads/pr-2067-adversarial.test.ts > PR #2067 adversarial > a stale revision request never returns the newer cached value 0ms
 ✓  @bb/server  test/services/threads/pr-2067-adversarial.test.ts > PR #2067 adversarial > bounded: N revisions of one shape never exceed one entry 1ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > builds once for the same key and serves cached on repeat 1ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > rebuilds and replaces the prior revision for the same request shape 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > retains separate request shapes independently 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > does not cache responses above the row cap (streaming expanded turns) 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > evicts least-recently-used entries beyond maxEntries 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > buildThreadTimelineCacheKey > differs when any projection input differs 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > buildThreadTimelineCacheKey > distinguishes older-page cursors 0ms
 ✓  @bb/server:isolated  test/public/repro-2066-timeline-cache-retention.test.ts > #2066 timeline cache retains superseded revisions > keeps one resident response per appended event for the same request shape 357ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
 FAIL   @bb/server  test/services/threads/repro-2066-unit.test.ts > #2066 unit > replaces the previous revision of the same request shape
TypeError: Cannot read properties of undefined (reading 'value')
 ❯ Object.getOrBuild src/services/threads/timeline-cache.ts:68:23
     66|         entries.delete(paramsKey);
     67|         entries.set(paramsKey, cached);
     68|         return cached.value;
       |                       ^
     69|       }
     70|
 ❯ test/services/threads/repro-2066-unit.test.ts:63:11
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
 FAIL   @bb/server  test/services/threads/repro-2066-unit.test.ts > #2066 unit > is bounded only by the global LRU for a single streaming thread
TypeError: Cannot read properties of undefined (reading 'value')
 ❯ Object.getOrBuild src/services/threads/timeline-cache.ts:68:23
     66|         entries.delete(paramsKey);
     67|         entries.set(paramsKey, cached);
     68|         return cached.value;
       |                       ^
     69|       }
     70|
 ❯ test/services/threads/repro-2066-unit.test.ts:74:13
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯
 Test Files  1 failed | 4 passed (5)
      Tests  2 failed | 14 passed (16)
   Start at  09:28:29
   Duration  2.28s (transform 1.25s, setup 0ms, import 1.98s, tests 369ms, environment 0ms)

4b. Route level — the real GET /api/v1/threads/:id/timeline against in-memory SQLite

This drives the real route handler (real key construction, real row cap, real LRU). The only instrumentation is a vi.mock wrapper around createThreadTimelineCache so the test can read .size of the instance the route creates. One completed turn with a 24 KB assistant message, then a second turn to which 150 agentMessage items are appended one at a time, refetching the same window after each append (what the web client does during streaming, throttled to ≥50 ms in realtime-cache-registry.ts:179).

  1. Save repro-2066-timeline-cache-retention.test.ts as apps/server/test/public/repro-2066-timeline-cache-retention.test.ts.
  2. Run from apps/server:
    pnpm exec vitest run test/public/repro-2066-timeline-cache-retention.test.ts
    
    expected: cache.size === 1 (only the newest maxSeq can ever be requested again)
    actual:
     RUN  v4.1.1 /Users/sawyerhood/.bb-machines/bee.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-17/apps/server
    stdout | test/public/repro-2066-timeline-cache-retention.test.ts > #2066 timeline cache retains superseded revisions > keeps one resident response per appended event for the same request shape
    [2066] first-response bytes=24859 rows(last)=152 rounds=150 cache.size=128
     ❯  @bb/server:isolated  test/public/repro-2066-timeline-cache-retention.test.ts (1 test | 1 failed) 407ms
         × keeps one resident response per appended event for the same request shape 407ms
    ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
     FAIL   @bb/server:isolated  test/public/repro-2066-timeline-cache-retention.test.ts > #2066 timeline cache retains superseded revisions > keeps one resident response per appended event for the same request shape
    AssertionError: expected 128 to be 1 // Object.is equality
    - Expected
    + Received
    - 1
    + 128
     ❯ test/public/repro-2066-timeline-cache-retention.test.ts:144:26
        142|         `[2066] first-response bytes=${first.bytes} rows(last)=${lastR…
        143|       );
        144|       expect(cache.size).toBe(1);
           |                          ^
        145|     });
        146|   });
     ❯ Module.withTestHarness test/helpers/test-app.ts:283:12
     ❯ test/public/repro-2066-timeline-cache-retention.test.ts:54:5
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
     Test Files  1 failed (1)
          Tests  1 failed (1)
       Start at  08:59:47
       Duration  3.80s (transform 1.81s, setup 0ms, import 3.24s, tests 407ms, environment 0ms)
repro-2066-timeline-cache-retention.test.ts
/**
 * Repro for get-bb/bb#2066: the timeline response cache keys on `maxSeq`, so
 * every appended event leaves the previous revision resident (and unreachable,
 * because a thread's maxSeq is monotonic) until global LRU eviction.
 *
 * Drives the REAL `GET /api/v1/threads/:id/timeline` route against an
 * in-memory SQLite database. The only instrumentation is wrapping the cache
 * factory so the test can read `.size` of the instance the route creates.
 */
import { describe, expect, it, vi } from "vitest";
import { threadScope, turnScope } from "@bb/domain";
import { threadTimelineResponseSchema } from "@bb/server-contract";
import { seedEvent, seedThreadFixture } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";
import type { TestAppHarness } from "../helpers/test-app.js";
import type { createThreadTimelineCache as CreateCache } from "../../src/services/threads/timeline-cache.js";

const createdCaches: ReturnType<typeof CreateCache>[] = [];

vi.mock(
  "../../src/services/threads/timeline-cache.js",
  async (importOriginal) => {
    const mod =
      await importOriginal<
        typeof import("../../src/services/threads/timeline-cache.js")
      >();
    return {
      ...mod,
      createThreadTimelineCache: (
        ...args: Parameters<typeof mod.createThreadTimelineCache>
      ) => {
        const cache = mod.createThreadTimelineCache(...args);
        createdCaches.push(cache);
        return cache;
      },
    };
  },
);

async function fetchTimeline(harness: TestAppHarness, threadId: string) {
  const response = await harness.app.request(
    `/api/v1/threads/${threadId}/timeline`,
  );
  expect(response.status).toBe(200);
  const text = await response.text();
  return {
    bytes: Buffer.byteLength(text),
    body: threadTimelineResponseSchema.parse(JSON.parse(text)),
  };
}

describe("#2066 timeline cache retains superseded revisions", () => {
  it("keeps one resident response per appended event for the same request shape", async () => {
    await withTestHarness(async (harness) => {
      const cache = createdCaches.at(-1);
      if (!cache) {
        throw new Error("route did not create a timeline cache");
      }
      const { environment, thread } = seedThreadFixture(harness);
      const base = {
        threadId: thread.id,
        environmentId: environment.id,
        providerThreadId: "p1",
      } as const;

      // A completed turn with a sizeable assistant message so each cached
      // revision carries real payload weight.
      const bigText = "lorem ipsum ".repeat(2_000); // ~24 KB
      seedEvent(harness.deps, {
        ...base,
        scope: threadScope(),
        sequence: 1,
        type: "system/manager/user_message",
        data: { text: "hello" },
      });
      seedEvent(harness.deps, {
        ...base,
        scope: turnScope("turn-1"),
        sequence: 2,
        type: "turn/started",
        data: {},
      });
      seedEvent(harness.deps, {
        ...base,
        scope: turnScope("turn-1"),
        sequence: 3,
        type: "item/completed",
        data: { item: { type: "agentMessage", id: "a-1", text: bigText } },
      });
      seedEvent(harness.deps, {
        ...base,
        scope: turnScope("turn-1"),
        sequence: 4,
        type: "turn/completed",
        data: { status: "completed" },
      });

      const first = await fetchTimeline(harness, thread.id);
      expect(first.body.maxSeq).toBe(4);
      expect(cache.size).toBe(1);
      // Well under the 200-row cap, so every revision is cacheable.
      expect(first.body.rows.length).toBeLessThan(200);

      // A second, streaming turn: each appended event bumps maxSeq and the
      // client refetches the same window (same request shape).
      seedEvent(harness.deps, {
        ...base,
        scope: turnScope("turn-2"),
        sequence: 5,
        type: "turn/started",
        data: {},
      });
      const rounds = 150;
      let lastRows = 0;
      for (let i = 0; i < rounds; i++) {
        seedEvent(harness.deps, {
          ...base,
          scope: turnScope("turn-2"),
          sequence: 6 + i,
          type: "item/completed",
          data: {
            item: {
              type: "agentMessage",
              id: `a-2-${i}`,
              text: `chunk ${i}`,
            },
          },
        });
        const page = await fetchTimeline(harness, thread.id);
        expect(page.body.maxSeq).toBe(6 + i);
        lastRows = page.body.rows.length;
        // Still cacheable (row count grows slowly; stays <= 200 here).
        expect(lastRows).toBeLessThanOrEqual(200);
      }

      // A thread's maxSeq is monotonic (getLatestThreadSequence = MAX(sequence)),
      // so only the entry keyed by the newest maxSeq can ever be hit again.
      // Every other entry is dead weight. Expected: 1 live revision.
      // Actual on fcada5a3b: the global LRU bound (128) of dead revisions.
      // eslint-disable-next-line no-console
      console.log(
        `[2066] first-response bytes=${first.bytes} rows(last)=${lastRows} rounds=${rounds} cache.size=${cache.size}`,
      );
      expect(cache.size).toBe(1);
    });
  });
});

This file drives the route through HTTP and never names the cache signature, so it is the one that runs unchanged before and after. With PR #2067 cherry-picked onto fcada5a3b it passes (log, and again in pr2067-unit-variants.log); with the proposed fix from §6 applied it passes too (proposed-fix-tests.log).

4c. Live instance — heap growth on a seeded 9,001-event thread

Fresh server each run, 3 warm fetches, force a full GC through the inspector and read Runtime.getHeapUsage; then 100 rounds of "insert one agentMessage event into the dev SQLite DB, GET the timeline"; then GC + read again. Scripts: measure.sh, live-loop.sh, heap-after-gc.mjs.

To run it yourself (nothing is tied to a particular checkout: measure.sh derives the worktree from git rev-parse --show-toplevel and the data dir / server URL from scripts/bb-dev-app status, and uses the inspector on 127.0.0.1:9229):

cd <your bb worktree at fcada5a3b>
pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
scripts/bb-dev-app current && pnpm dev:stop               # creates the per-worktree data dir
pnpm seed:perf -- --projects 1 --threads 6 --events 30000 --seed 7   # deterministic: thr_6zik7e8uvr / env_866bjs4xjm / 8e22443c-… are stable
/tmp/bb-reports/issues/2066/repro/measure.sh base 100
git fetch origin pull/2067/head:pr-2067 && git checkout -B pr-2067-on-base fcada5a3b && git cherry-pick 206096e4b
/tmp/bb-reports/issues/2066/repro/measure.sh pr2067 100
pnpm dev:stop

Original runs (author's first worktree, f02-17, server :20143):

branch=fcada5a3b pid=57624
{"label":"base: before loop (warm, 1 cached revision)","usedMB":159.8,"totalMB":168.1}
server pid=57624 start rss=329680KB thread=thr_6zik7e8uvr turn=turn_repro_1787328398
end rss=399872KB
{"label":"base: after 100 append+fetch rounds","usedMB":179.2,"totalMB":189.1}
branch=fcada5a3b pid=62002
{"label":"base-run2: before loop (warm, 1 cached revision)","usedMB":159.5,"totalMB":170.6}
server pid=62002 start rss=325632KB thread=thr_6zik7e8uvr turn=turn_repro_1787328467
end rss=511872KB
{"label":"base-run2: after 100 append+fetch rounds","usedMB":181.3,"totalMB":192.6}
branch=f81524c34 pid=59927
{"label":"pr2067: before loop (warm, 1 cached revision)","usedMB":160,"totalMB":169.3}
server pid=59927 start rss=343296KB thread=thr_6zik7e8uvr turn=turn_repro_1787328433
end rss=402336KB
{"label":"pr2067: after 100 append+fetch rounds","usedMB":163.6,"totalMB":173.6}

Re-run for this revision with the portable measure.sh in a fresh worktree (f02-23, server :20532, data dir re-seeded with the same command), plus the proposed fix from §6 (2066/repro/revise/):

worktree=/Users/sawyerhood/.bb-machines/bee.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-23 db=/Users/sawyerhood/.bb-dev/bb-machines-bee.getbb.app-checkouts-bb-.claude-worktrees-wf_21e66a79-f02-23-9a71be3ce607/bb.db server=http://localhost:20532 out=/tmp/bb-reports/issues/2066/repro/revise
branch=fcada5a3b pid=51097
{"label":"base: before loop (warm, 1 cached revision)","usedMB":159.9,"totalMB":169.6}
server pid=51097 start rss=368208KB thread=thr_6zik7e8uvr turn=turn_repro_1787329950
end rss=548896KB
{"label":"base: after 100 append+fetch rounds","usedMB":181.6,"totalMB":191.8}

worktree=/Users/sawyerhood/.bb-machines/bee.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-23 db=/Users/sawyerhood/.bb-dev/bb-machines-bee.getbb.app-checkouts-bb-.claude-worktrees-wf_21e66a79-f02-23-9a71be3ce607/bb.db server=http://localhost:20532 out=/tmp/bb-reports/issues/2066/repro/revise
branch=2723db725 pid=53490
{"label":"pr2067: before loop (warm, 1 cached revision)","usedMB":160.5,"totalMB":170.8}
server pid=53490 start rss=345264KB thread=thr_6zik7e8uvr turn=turn_repro_1787329993
end rss=408416KB
{"label":"pr2067: after 100 append+fetch rounds","usedMB":164.1,"totalMB":175.1}

worktree=/Users/sawyerhood/.bb-machines/bee.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-23 db=/Users/sawyerhood/.bb-dev/bb-machines-bee.getbb.app-checkouts-bb-.claude-worktrees-wf_21e66a79-f02-23-9a71be3ce607/bb.db server=http://localhost:20532 out=/tmp/bb-reports/issues/2066/repro/revise
branch=d9499b4a7 pid=56205
{"label":"proposed-fix: before loop (warm, 1 cached revision)","usedMB":159.1,"totalMB":170.3}
server pid=56205 start rss=342320KB thread=thr_6zik7e8uvr turn=turn_repro_1787330025
end rss=416720KB
{"label":"proposed-fix: after 100 append+fetch rounds","usedMB":166,"totalMB":177.1}
Bar chart: GC'd heap before and after 100 rounds across eight runs. Base grows by 19.4, 21.8, 19.3 and 21.7 MB; PR #2067 grows by 3.6, 6.0 and 3.6 MB; the proposed fix grows by 6.9 MB.
Post-GC V8 heap of the bb server process before vs after 100 append+refetch rounds, across the author's two original runs, the independent verifier's run (2066/verify/), and the re-run for this revision. Base retains ~19–22 MB (100 dead revisions of a 100–200-row response, ~200 KB each in-heap); with PR #2067 or the proposed fix the growth is 3.6–6.9 MB (the separate 4-deep timelineLatestRowsCache ring, the 100 extra events in the window, plus noise). Process RSS (in the CSVs) is not a useful signal here — V8 had not collected in any run.

Per-round samples (live-base.csv, live-pr2067.csv; revise re-run: live-base.csv, live-pr2067.csv, live-proposed-fix.csv): every response was 99–199 rows, i.e. under the 200-row cap, so every revision was stored. The response bytes per round are byte-identical across branches (the fix does not change response data).

base:
round,maxSeq,responseBytes,rows,serverRssKb
1,9003,107927,100,339664
10,9012,111133,109,364624
50,9052,125453,149,388016
100,9102,143355,199,399872

PR #2067:
round,maxSeq,responseBytes,rows,serverRssKb
1,9003,107927,100,351136
10,9012,111133,109,383664
50,9052,125453,149,397520
100,9102,143355,199,402336

Repro files: 2066/repro/

5. Root cause

The cache (timeline-cache.ts:52-85) is a plain Map<string, ThreadTimelineResponse> with LRU re-insertion and a 128-entry bound:

getOrBuild(key, build) {
  const cached = entries.get(key);
  if (cached !== undefined) { entries.delete(key); entries.set(key, cached); return cached; }
  const value = build();
  if (value.rows.length <= maxCacheableRows) {
    entries.set(key, value);
    while (entries.size > maxEntries) { /* evict oldest */ }
  }
  return value;
}

The key is `${maxSeq}|${paramsKey}` (L126-130), and the route computes maxSeq from the database on every request (data.ts:335). Because maxSeq = MAX(sequence) never decreases, an entry keyed by an older maxSeq is unreachable the moment a newer one is stored, yet nothing removes it: the design deliberately uses the key change as the invalidation mechanism ("Keying on the thread high-water maxSeq makes invalidation implicit", L16-23) and relies on the row cap ("an expanded active turn produces hundreds of rows AND a maxSeq that changes on every event, so caching it only thrashes the LRU", L25-29) to keep streaming revisions out.

That second assumption is where the reasoning breaks: the 200-row cap excludes only large expanded turns. A typical streaming turn on a long thread projects to well under 200 rows (the seeded thread: 99 rows at rest, 199 after 100 appended items), so every event-driven refetch stores a fresh full response. The client refetches after every event batch, so one viewed streaming thread produces ~128 revisions in seconds and from then on holds all 128 LRU slots with entries that can never hit. The symptom (server heap proportional to 128 × response size, even though only one response is useful) follows directly.

Related but separate mechanisms, so the reader does not conflate them:

6. Proposed fix (first principles)

Key the map by the request shape (paramsKey, which already exists for the delta cache) and store the revision alongside the value; treat a different revision as a miss that replaces the slot. This is exactly what PR #2067 does. I would make one simplification: pass maxSeq as a number rather than a second string that embeds the params key again.

// timeline-cache.ts
const entries = new Map<string, { maxSeq: number; value: ThreadTimelineResponse }>();

getOrBuild({ paramsKey, maxSeq }, build) {
  const cached = entries.get(paramsKey);
  if (cached?.maxSeq === maxSeq) { entries.delete(paramsKey); entries.set(paramsKey, cached); return cached.value; }
  const value = build();
  entries.delete(paramsKey);                  // a newer revision supersedes the old one even if uncacheable
  if (value.rows.length <= maxCacheableRows) {
    entries.set(paramsKey, { maxSeq, value });
    while (entries.size > maxEntries) { /* evict oldest */ }
  }
  return value;
}

// data.ts
const full = timelineCache.getOrBuild({ paramsKey, maxSeq }, () => ...);   // paramsKey computed once, above

buildThreadTimelineCacheKey then has no remaining caller and can be deleted (the repo's simplicity rule). Correctness argument: all inputs that select the projection are still in paramsKey; the hit condition is unchanged (paramsKey equal and maxSeq equal); the only behavioral difference is that stale revisions are dropped eagerly, and they were unreachable anyway. Risk: if maxSeq were ever allowed to decrease (e.g. a future rewind that deletes a suffix without appending a marker event), both the old and the new design would serve stale rows for an equal maxSeq; that is pre-existing and should be guarded at the event-deletion site, not here. Tests: the route test in §4b fails before (128 !== 1) and passes after without modification; the two unit scenarios from §4a, rewritten for the { paramsKey, maxSeq } signature (repro-2066-unit-after-fix.test.ts), pass after (the base-signature file in §4a cannot run against any fix, see the note there).

Implemented and checked (branch proposed-fix-2066 on fcada5a3b, proposed-fix-2066.diff: timeline-cache.ts, one call site in data.ts, and timeline-cache.test.ts adapted — buildThreadTimelineCacheKey and the maxSeq field of ThreadTimelineCacheKeyArgs are gone, the key-builder tests target buildThreadTimelineParamsKey, and the oversized-replacement and stale-revision cases are added): repro-2066-unit-after-fix 2/2, route test 1/1, timeline-cache.test.ts 9/9, timeline-latest-rows-cache, public-thread-timeline-delta and public-thread-timeline-output-preview all pass (25/25, log); pnpm exec turbo run typecheck --filter=@bb/server passes (log); ESLint clean on the three files. Live measurement (§4c): +6.9 MB over 100 rounds versus +21.7 MB for base in the same session.

proposed-fix-2066.diff
diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts
index f8a741ade..e3c9a3007 100644
--- a/apps/server/src/routes/threads/data.ts
+++ b/apps/server/src/routes/threads/data.ts
@@ -56,7 +56,6 @@ import type {
 } from "../../services/threads/timeline-pagination.js";
 import { createSlowThreadTimelineBuildLogger } from "../../services/threads/timeline-build-log.js";
 import {
-  buildThreadTimelineCacheKey,
   buildThreadTimelineParamsKey,
   createThreadTimelineCache,
 } from "../../services/threads/timeline-cache.js";
@@ -355,8 +354,9 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void {
       summaryOnly,
       includeProviderUnhandledOperations,
     };
+    const paramsKey = buildThreadTimelineParamsKey(keyArgs);
     const full = timelineCache.getOrBuild(
-      buildThreadTimelineCacheKey({ ...keyArgs, maxSeq }),
+      { paramsKey, maxSeq },
       () => {
         const { profile, response } = buildThreadTimelineWithProfile(
           deps.db,
@@ -399,7 +399,6 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void {
       query.afterSequence,
       "afterSequence",
     );
-    const paramsKey = buildThreadTimelineParamsKey(keyArgs);
     const previous =
       afterSequence === undefined
         ? undefined
diff --git a/apps/server/src/services/threads/timeline-cache.ts b/apps/server/src/services/threads/timeline-cache.ts
index f5e1c903a..a8bca92f9 100644
--- a/apps/server/src/services/threads/timeline-cache.ts
+++ b/apps/server/src/services/threads/timeline-cache.ts
@@ -13,9 +13,12 @@ import type { ThreadTimelinePageRequest } from "./timeline-pagination.js";
  * (detail view + side-chat tabs), debounced realtime invalidations that fire
  * after the tail already settled, and re-opening a thread.
  *
- * Keying on the thread high-water `maxSeq` makes invalidation implicit: any
- * appended event bumps `maxSeq`, producing a new key and a cold rebuild. The
- * key MUST also include every other input the projection depends on:
+ * Entries are keyed by request shape (`paramsKey`) and store the thread
+ * high-water `maxSeq` they were built at. A request with a different `maxSeq`
+ * is a miss that *replaces* the slot: `maxSeq` never decreases, so the old
+ * revision could never be looked up again and keeping it until global LRU
+ * eviction only pins a dead response per appended event (#2066). The request
+ * shape MUST include every other input the projection depends on:
  * `thread.status` (interrupt flips earlier rows), `environmentId` (workspace
  * root relativizes file paths), provider display name (labels dynamic-provider
  * diagnostic rows), and the row-shape request flags. Event pruning
@@ -23,10 +26,9 @@ import type { ThreadTimelinePageRequest } from "./timeline-pagination.js";
  * and never lowers `maxSeq`, so it cannot stale a cached entry.
  *
  * Entries with many rows are not cached: an expanded active turn (the streaming
- * case) produces hundreds of rows AND a `maxSeq` that changes on every event,
- * so caching it only thrashes the LRU and pins large objects for no reuse. Idle
- * windows collapse completed turns to a handful of rows regardless of thread
- * size, so the cap excludes exactly the entries that would never be reused.
+ * case) produces hundreds of rows that are rebuilt on every event, so storing
+ * them pins a large object for no reuse. The per-shape slot bounds the *count*
+ * of retained revisions; the row cap bounds their *size*.
  */
 
 const DEFAULT_MAX_ENTRIES = 128;
@@ -40,7 +42,7 @@ interface ThreadTimelineCacheOptions {
 
 interface ThreadTimelineCache {
   getOrBuild(
-    key: string,
+    key: { paramsKey: string; maxSeq: number },
     build: () => ThreadTimelineResponse,
   ): ThreadTimelineResponse;
   /** Number of currently cached entries (for tests/metrics). */
@@ -53,21 +55,27 @@ export function createThreadTimelineCache(
   const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
   const maxCacheableRows =
     options.maxCacheableRows ?? DEFAULT_MAX_CACHEABLE_ROWS;
-  const entries = new Map<string, ThreadTimelineResponse>();
+  const entries = new Map<
+    string,
+    { maxSeq: number; value: ThreadTimelineResponse }
+  >();
 
   return {
-    getOrBuild(key, build) {
-      const cached = entries.get(key);
-      if (cached !== undefined) {
+    getOrBuild({ paramsKey, maxSeq }, build) {
+      const cached = entries.get(paramsKey);
+      if (cached?.maxSeq === maxSeq) {
         // Re-insert to mark most-recently-used.
-        entries.delete(key);
-        entries.set(key, cached);
-        return cached;
+        entries.delete(paramsKey);
+        entries.set(paramsKey, cached);
+        return cached.value;
       }
 
       const value = build();
+      // A newer revision supersedes the stored one even when the new value is
+      // too large to cache: the old one can never hit again.
+      entries.delete(paramsKey);
       if (value.rows.length <= maxCacheableRows) {
-        entries.set(key, value);
+        entries.set(paramsKey, { maxSeq, value });
         while (entries.size > maxEntries) {
           const oldest = entries.keys().next().value;
           if (oldest === undefined) {
@@ -86,8 +94,6 @@ export function createThreadTimelineCache(
 
 export interface ThreadTimelineCacheKeyArgs {
   threadId: string;
-  /** Thread high-water event sequence; bumps on every appended event. */
-  maxSeq: number;
   status: ThreadStatus;
   environmentId: string | null;
   providerDisplayName?: string;
@@ -104,12 +110,12 @@ function pageKeyPart(page: ThreadTimelinePageRequest): string {
 }
 
 /**
- * The cache identity *excluding* `maxSeq` — i.e. everything that selects which
- * window is being requested, but not which revision of it. Used to track the
- * latest-sent rows per request shape for delta computation.
+ * The request shape: everything that selects which window is being requested,
+ * but not which revision (`maxSeq`) of it. Shared by the response cache and the
+ * latest-rows delta cache.
  */
 export function buildThreadTimelineParamsKey(
-  args: Omit<ThreadTimelineCacheKeyArgs, "maxSeq">,
+  args: ThreadTimelineCacheKeyArgs,
 ): string {
   return [
     args.threadId,
@@ -122,9 +128,3 @@ export function buildThreadTimelineParamsKey(
     args.includeProviderUnhandledOperations ? "1" : "0",
   ].join("|");
 }
-
-export function buildThreadTimelineCacheKey(
-  args: ThreadTimelineCacheKeyArgs,
-): string {
-  return `${args.maxSeq}|${buildThreadTimelineParamsKey(args)}`;
-}
diff --git a/apps/server/test/services/threads/timeline-cache.test.ts b/apps/server/test/services/threads/timeline-cache.test.ts
index 141af64fe..7ef779e94 100644
--- a/apps/server/test/services/threads/timeline-cache.test.ts
+++ b/apps/server/test/services/threads/timeline-cache.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
 import type { ThreadTimelineResponse } from "@bb/server-contract";
 import type { ThreadTimelinePageRequest } from "../../../src/services/threads/timeline-pagination.js";
 import {
-  buildThreadTimelineCacheKey,
+  buildThreadTimelineParamsKey,
   createThreadTimelineCache,
   type ThreadTimelineCacheKeyArgs,
 } from "../../../src/services/threads/timeline-cache.js";
@@ -48,7 +48,6 @@ const latestPage: ThreadTimelinePageRequest = {
 
 const baseKeyArgs: ThreadTimelineCacheKeyArgs = {
   threadId: "thr_x",
-  maxSeq: 10,
   status: "idle",
   environmentId: null,
   page: latestPage,
@@ -57,62 +56,92 @@ const baseKeyArgs: ThreadTimelineCacheKeyArgs = {
   includeProviderUnhandledOperations: false,
 };
 
+const k = (paramsKey: string, maxSeq = 1) => ({ paramsKey, maxSeq });
+
 describe("createThreadTimelineCache", () => {
-  it("builds once for the same key and serves cached on repeat", () => {
+  it("builds once for the same shape and revision and serves cached on repeat", () => {
     const cache = createThreadTimelineCache();
     const build = vi.fn(() => makeResponse(3));
 
-    const first = cache.getOrBuild("k", build);
-    const second = cache.getOrBuild("k", build);
+    const first = cache.getOrBuild(k("k"), build);
+    const second = cache.getOrBuild(k("k"), build);
 
     expect(build).toHaveBeenCalledTimes(1);
     expect(second).toBe(first);
     expect(cache.size).toBe(1);
   });
 
-  it("rebuilds when the key changes (e.g. new maxSeq)", () => {
+  it("rebuilds on a new maxSeq and replaces the prior revision of the same shape", () => {
     const cache = createThreadTimelineCache();
     const build = vi.fn(() => makeResponse(3));
 
-    cache.getOrBuild("k1", build);
-    cache.getOrBuild("k2", build);
+    cache.getOrBuild(k("k", 10), build);
+    cache.getOrBuild(k("k", 11), build);
 
     expect(build).toHaveBeenCalledTimes(2);
+    expect(cache.size).toBe(1);
+  });
+
+  it("never returns a newer revision to a request for an older maxSeq", () => {
+    const cache = createThreadTimelineCache();
+    const newer = makeResponse(2);
+    cache.getOrBuild(k("k", 11), () => newer);
+    const rebuilt = makeResponse(1);
+    const served = cache.getOrBuild(k("k", 10), () => rebuilt);
+    expect(served).toBe(rebuilt);
+  });
+
+  it("retains separate request shapes independently", () => {
+    const cache = createThreadTimelineCache();
+    const build = vi.fn(() => makeResponse(3));
+
+    cache.getOrBuild(k("latest", 10), build);
+    cache.getOrBuild(k("older:5", 10), build);
+
+    expect(cache.size).toBe(2);
   });
 
   it("does not cache responses above the row cap (streaming expanded turns)", () => {
     const cache = createThreadTimelineCache({ maxCacheableRows: 5 });
     const build = vi.fn(() => makeResponse(50));
 
-    cache.getOrBuild("k", build);
-    cache.getOrBuild("k", build);
+    cache.getOrBuild(k("k"), build);
+    cache.getOrBuild(k("k"), build);
 
     expect(build).toHaveBeenCalledTimes(2);
     expect(cache.size).toBe(0);
   });
 
+  it("drops a cached revision when its replacement is above the row cap", () => {
+    const cache = createThreadTimelineCache({ maxCacheableRows: 5 });
+
+    cache.getOrBuild(k("k", 1), () => makeResponse(3));
+    expect(cache.size).toBe(1);
+    cache.getOrBuild(k("k", 2), () => makeResponse(50));
+    expect(cache.size).toBe(0);
+  });
+
   it("evicts least-recently-used entries beyond maxEntries", () => {
     const cache = createThreadTimelineCache({ maxEntries: 2 });
     const build = vi.fn(() => makeResponse(1));
 
-    cache.getOrBuild("a", build); // [a]
-    cache.getOrBuild("b", build); // [a,b]
-    cache.getOrBuild("a", build); // touch a -> [b,a]
-    cache.getOrBuild("c", build); // evict b -> [a,c]
+    cache.getOrBuild(k("a"), build); // [a]
+    cache.getOrBuild(k("b"), build); // [a,b]
+    cache.getOrBuild(k("a"), build); // touch a -> [b,a]
+    cache.getOrBuild(k("c"), build); // evict b -> [a,c]
 
     expect(cache.size).toBe(2);
     const buildAgain = vi.fn(() => makeResponse(1));
-    cache.getOrBuild("a", buildAgain); // still cached
-    cache.getOrBuild("b", buildAgain); // evicted -> rebuild
+    cache.getOrBuild(k("a"), buildAgain); // still cached
+    cache.getOrBuild(k("b"), buildAgain); // evicted -> rebuild
     expect(buildAgain).toHaveBeenCalledTimes(1);
   });
 });
 
-describe("buildThreadTimelineCacheKey", () => {
+describe("buildThreadTimelineParamsKey", () => {
   it("differs when any projection input differs", () => {
-    const base = buildThreadTimelineCacheKey(baseKeyArgs);
+    const base = buildThreadTimelineParamsKey(baseKeyArgs);
     const variants: ThreadTimelineCacheKeyArgs[] = [
-      { ...baseKeyArgs, maxSeq: 11 },
       { ...baseKeyArgs, status: "active" },
       { ...baseKeyArgs, environmentId: "env_1" },
       { ...baseKeyArgs, includeNestedRows: true },
@@ -128,12 +157,12 @@ describe("buildThreadTimelineCacheKey", () => {
       },
     ];
     for (const variant of variants) {
-      expect(buildThreadTimelineCacheKey(variant)).not.toBe(base);
+      expect(buildThreadTimelineParamsKey(variant)).not.toBe(base);
     }
   });
 
   it("distinguishes older-page cursors", () => {
-    const cursorA = buildThreadTimelineCacheKey({
+    const cursorA = buildThreadTimelineParamsKey({
       ...baseKeyArgs,
       page: {
         kind: "older",
@@ -141,7 +170,7 @@ describe("buildThreadTimelineCacheKey", () => {
         beforeCursor: { anchorSeq: 5, anchorId: "a5" },
       },
     });
-    const cursorB = buildThreadTimelineCacheKey({
+    const cursorB = buildThreadTimelineParamsKey({
       ...baseKeyArgs,
       page: {
         kind: "older",
proposed-fix-tests.log
 RUN  v4.1.1 /Users/sawyerhood/.bb-machines/bee.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-23/apps/server
 ✓  @bb/server  test/services/threads/repro-2066-unit-after-fix.test.ts > #2066 unit (proposed-fix signature) > replaces the previous revision of the same request shape 1ms
 ✓  @bb/server  test/services/threads/repro-2066-unit-after-fix.test.ts > #2066 unit (proposed-fix signature) > keeps one entry for a single streaming thread regardless of revision count 0ms
 ✓  @bb/server  test/services/threads/timeline-latest-rows-cache.test.ts > createTimelineLatestRowsCache > keeps a ring of recent revisions per params key and evicts the oldest 1ms
 ✓  @bb/server  test/services/threads/timeline-latest-rows-cache.test.ts > createTimelineLatestRowsCache > a repeated set at the same revision refreshes recency without consuming a ring slot 0ms
 ✓  @bb/server  test/services/threads/timeline-latest-rows-cache.test.ts > createTimelineLatestRowsCache > bounds params keys LRU-style; a lookup counts as use 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > builds once for the same shape and revision and serves cached on repeat 1ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > rebuilds on a new maxSeq and replaces the prior revision of the same shape 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > never returns a newer revision to a request for an older maxSeq 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > retains separate request shapes independently 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > does not cache responses above the row cap (streaming expanded turns) 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > drops a cached revision when its replacement is above the row cap 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > createThreadTimelineCache > evicts least-recently-used entries beyond maxEntries 0ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > buildThreadTimelineParamsKey > differs when any projection input differs 1ms
 ✓  @bb/server  test/services/threads/timeline-cache.test.ts > buildThreadTimelineParamsKey > distinguishes older-page cursors 0ms
 ✓  @bb/server  test/public/public-thread-timeline-output-preview.test.ts > GET /threads/:id/timeline inline output preview > previews the running turn's large outputs and leaves small ones whole 119ms
 ✓  @bb/server  test/public/public-thread-timeline-output-preview.test.ts > GET /threads/:id/timeline inline output preview > nested-row consumers still receive the full inline output 78ms
 ✓  @bb/server  test/public/public-thread-timeline-delta.test.ts > GET /threads/:id/timeline?afterSequence (row-patch delta) > a full fetch carries no delta and echoes maxSeq 115ms
 ✓  @bb/server  test/public/public-thread-timeline-delta.test.ts > GET /threads/:id/timeline?afterSequence (row-patch delta) > delta + merge reproduces a fresh full window when rows are appended 86ms
 ✓  @bb/server  test/public/public-thread-timeline-delta.test.ts > GET /threads/:id/timeline?afterSequence (row-patch delta) > delta + merge reproduces a fresh full window when a turn completes (collapse) 72ms
 ✓  @bb/server  test/public/public-thread-timeline-delta.test.ts > GET /threads/:id/timeline?afterSequence (row-patch delta) > two interleaved clients both receive deltas (snapshot ring per params key) 83ms
 ✓  @bb/server  test/public/public-thread-timeline-output-preview.test.ts > GET /threads/:id/timeline inline output preview > turn-summary-details scoped to the previewed row returns its whole output 70ms
 ✓  @bb/server  test/public/public-thread-timeline-output-preview.test.ts > GET /threads/:id/timeline inline output preview > row-scoped details still resolve after the turn completes (expand/complete race) 74ms
 ✓  @bb/server  test/public/public-thread-timeline-output-preview.test.ts > GET /threads/:id/timeline inline output preview (tool rows) > previews a large tool result and row-scoped details return it whole 71ms
 ✓  @bb/server  test/public/public-thread-timeline-delta.test.ts > GET /threads/:id/timeline?afterSequence (row-patch delta) > a no-op delta (no new events) returns an empty patch and merges to the same rows 69ms
 ✓  @bb/server:isolated  test/public/repro-2066-timeline-cache-retention.test.ts > #2066 timeline cache retains superseded revisions > keeps one resident response per appended event for the same request shape 440ms
 Test Files  6 passed (6)
      Tests  25 passed (25)
   Start at  09:30:04
   Duration  3.19s (transform 5.30s, setup 0ms, import 8.15s, tests 1.29s, environment 0ms)

7. PR review — #2067 "fix(server): retain one timeline revision per request shape"

Status: draft, from fork Yazington/bb, single commit 206096e4b, based on 6be45053b (68 commits behind the base commit); no CI has run on it ("no checks reported"). It cherry-picks onto fcada5a3b with no conflicts (branch pr-2067-on-base in my worktree; diff).

What it changes: getOrBuild takes { paramsKey, revisionKey }, the map is keyed by paramsKey and stores { revisionKey, value }; a hit requires the stored revisionKey to equal the requested one; a miss deletes the slot before deciding whether the new value is cacheable; the route computes paramsKey once and passes both keys. Tests rewritten for the new signature plus two new ones (replace-same-shape → size 1; distinct shapes → size 2). Doc comment updated.

Root cause or symptom? Root cause. It removes the dead-revision retention at the cache layer, on the server side where the policy belongs. No wire shapes change, so no HOST_DAEMON_PROTOCOL_VERSION bump is needed. No casts, no unknown, no behavior change to responses or cacheability thresholds.

Findings

WhereSeverityFinding
PR description, "How you verified"minor (accuracy)Claims regression coverage for "oversized replacements" and "7/7 passed". The test file has 7 tests, but none covers a small cacheable revision being superseded by an oversized one; the existing "does not cache responses above the row cap" test uses the same oversized value twice. I wrote that case (pr-2067-adversarial.test.ts); the code handles it (size goes 1 → 0), so add the test rather than claim it.
apps/server/src/services/threads/timeline-cache.ts L42-45, L56-59; routes/threads/data.ts L361-366minor (simplicity)revisionKey is buildThreadTimelineCacheKey(...) = `${maxSeq}|${paramsKey}`, which re-embeds the params key the map is already keyed by. Passing maxSeq: number and storing it (as timelineLatestRowsCache already does) is simpler, removes a string build per request, and lets buildThreadTimelineCacheKey be deleted along with the two tests that only exercise it ("differs when any projection input differs", "distinguishes older-page cursors" can target buildThreadTimelineParamsKey instead).
timeline-cache.ts doc comment L25-28nitThe rewritten rationale for the row cap drops the observation that the cap is what keeps large streaming revisions out; worth keeping one sentence that the cap is still needed for size, while the per-shape slot is what bounds count.
Branch hygieneprocessDraft, 68 commits behind, no CI. Needs a rebase onto current main, ready-for-review, and the PR template's "tests that fail before and pass after" (the new replace test does fail before — verified in §4a).

Tests I ran on pr-2067-on-base

Verdict: REQUEST CHANGES (minor). The change is correct, at the right layer, and measurably fixes the retention. Before merge: add the oversized-replacement test the description already claims, consider passing maxSeq instead of a redundant revisionKey string and deleting buildThreadTimelineCacheKey, rebase, and take it out of draft so CI runs.

PR #2067 diff as applied onto fcada5a3b
diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts
index f8a741ade..e8ce0acc8 100644
--- a/apps/server/src/routes/threads/data.ts
+++ b/apps/server/src/routes/threads/data.ts
@@ -355,8 +355,12 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void {
       summaryOnly,
       includeProviderUnhandledOperations,
     };
+    const paramsKey = buildThreadTimelineParamsKey(keyArgs);
     const full = timelineCache.getOrBuild(
-      buildThreadTimelineCacheKey({ ...keyArgs, maxSeq }),
+      {
+        paramsKey,
+        revisionKey: buildThreadTimelineCacheKey({ ...keyArgs, maxSeq }),
+      },
       () => {
         const { profile, response } = buildThreadTimelineWithProfile(
           deps.db,
@@ -399,7 +403,6 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void {
       query.afterSequence,
       "afterSequence",
     );
-    const paramsKey = buildThreadTimelineParamsKey(keyArgs);
     const previous =
       afterSequence === undefined
         ? undefined
diff --git a/apps/server/src/services/threads/timeline-cache.ts b/apps/server/src/services/threads/timeline-cache.ts
index f5e1c903a..340a7120c 100644
--- a/apps/server/src/services/threads/timeline-cache.ts
+++ b/apps/server/src/services/threads/timeline-cache.ts
@@ -13,20 +13,20 @@ import type { ThreadTimelinePageRequest } from "./timeline-pagination.js";
  * (detail view + side-chat tabs), debounced realtime invalidations that fire
  * after the tail already settled, and re-opening a thread.
  *
- * Keying on the thread high-water `maxSeq` makes invalidation implicit: any
- * appended event bumps `maxSeq`, producing a new key and a cold rebuild. The
- * key MUST also include every other input the projection depends on:
+ * Keying each revision on the thread high-water `maxSeq` makes invalidation
+ * implicit: any appended event bumps `maxSeq`, producing a cold rebuild. The
+ * cache keeps only the newest revision for each request shape; otherwise an
+ * active thread with compact projected rows can pin one full response per
+ * event until the global LRU fills. The request shape MUST include every other
+ * input the projection depends on:
  * `thread.status` (interrupt flips earlier rows), `environmentId` (workspace
  * root relativizes file paths), provider display name (labels dynamic-provider
  * diagnostic rows), and the row-shape request flags. Event pruning
  * (`pruneResolvedItemDeltas`, background-task progress) is output-preserving
  * and never lowers `maxSeq`, so it cannot stale a cached entry.
  *
- * Entries with many rows are not cached: an expanded active turn (the streaming
- * case) produces hundreds of rows AND a `maxSeq` that changes on every event,
- * so caching it only thrashes the LRU and pins large objects for no reuse. Idle
- * windows collapse completed turns to a handful of rows regardless of thread
- * size, so the cap excludes exactly the entries that would never be reused.
+ * Entries with many rows are not cached: an expanded active turn produces
+ * hundreds of rows, so caching it would pin a large object for little reuse.
  */
 
 const DEFAULT_MAX_ENTRIES = 128;
@@ -40,7 +40,7 @@ interface ThreadTimelineCacheOptions {
 
 interface ThreadTimelineCache {
   getOrBuild(
-    key: string,
+    keys: { paramsKey: string; revisionKey: string },
     build: () => ThreadTimelineResponse,
   ): ThreadTimelineResponse;
   /** Number of currently cached entries (for tests/metrics). */
@@ -53,21 +53,28 @@ export function createThreadTimelineCache(
   const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
   const maxCacheableRows =
     options.maxCacheableRows ?? DEFAULT_MAX_CACHEABLE_ROWS;
-  const entries = new Map<string, ThreadTimelineResponse>();
+  const entries = new Map<
+    string,
+    { revisionKey: string; value: ThreadTimelineResponse }
+  >();
 
   return {
-    getOrBuild(key, build) {
-      const cached = entries.get(key);
-      if (cached !== undefined) {
+    getOrBuild({ paramsKey, revisionKey }, build) {
+      const cached = entries.get(paramsKey);
+      if (cached?.revisionKey === revisionKey) {
         // Re-insert to mark most-recently-used.
-        entries.delete(key);
-        entries.set(key, cached);
-        return cached;
+        entries.delete(paramsKey);
+        entries.set(paramsKey, cached);
+        return cached.value;
       }
 
       const value = build();
+      // A newer revision supersedes the old response even when the new value
+      // is too large to cache. Keeping the stale value cannot produce a hit
+      // and needlessly retains its rows.
+      entries.delete(paramsKey);
       if (value.rows.length <= maxCacheableRows) {
-        entries.set(key, value);
+        entries.set(paramsKey, { revisionKey, value });
         while (entries.size > maxEntries) {
           const oldest = entries.keys().next().value;
           if (oldest === undefined) {
diff --git a/apps/server/test/services/threads/timeline-cache.test.ts b/apps/server/test/services/threads/timeline-cache.test.ts
index 141af64fe..da68e0f7f 100644
--- a/apps/server/test/services/threads/timeline-cache.test.ts
+++ b/apps/server/test/services/threads/timeline-cache.test.ts
@@ -57,35 +57,60 @@ const baseKeyArgs: ThreadTimelineCacheKeyArgs = {
   includeProviderUnhandledOperations: false,
 };
 
+function cacheKeys(revisionKey: string, paramsKey = revisionKey) {
+  return { paramsKey, revisionKey };
+}
+
 describe("createThreadTimelineCache", () => {
   it("builds once for the same key and serves cached on repeat", () => {
     const cache = createThreadTimelineCache();
     const build = vi.fn(() => makeResponse(3));
 
-    const first = cache.getOrBuild("k", build);
-    const second = cache.getOrBuild("k", build);
+    const first = cache.getOrBuild(cacheKeys("k"), build);
+    const second = cache.getOrBuild(cacheKeys("k"), build);
 
     expect(build).toHaveBeenCalledTimes(1);
     expect(second).toBe(first);
     expect(cache.size).toBe(1);
   });
 
-  it("rebuilds when the key changes (e.g. new maxSeq)", () => {
+  it("rebuilds and replaces the prior revision for the same request shape", () => {
     const cache = createThreadTimelineCache();
     const build = vi.fn(() => makeResponse(3));
 
-    cache.getOrBuild("k1", build);
-    cache.getOrBuild("k2", build);
+    cache.getOrBuild(
+      cacheKeys(buildThreadTimelineCacheKey(baseKeyArgs), "latest-shape"),
+      build,
+    );
+    cache.getOrBuild(
+      cacheKeys(
+        buildThreadTimelineCacheKey({ ...baseKeyArgs, maxSeq: 11 }),
+        "latest-shape",
+      ),
+      build,
+    );
 
     expect(build).toHaveBeenCalledTimes(2);
+    expect(cache.size).toBe(1);
+  });
+
+  it("retains separate request shapes independently", () => {
+    const cache = createThreadTimelineCache();
+    const build = vi.fn(() => makeResponse(3));
+
+    cache.getOrBuild(cacheKeys("revision", "latest-page"), build);
+    cache.getOrBuild(cacheKeys("revision", "older-page"), build);
+
+    expect(build).toHaveBeenCalledTimes(2);
+    expect(cache.size).toBe(2);
   });
 
   it("does not cache responses above the row cap (streaming expanded turns)", () => {
     const cache = createThreadTimelineCache({ maxCacheableRows: 5 });
     const build = vi.fn(() => makeResponse(50));
 
-    cache.getOrBuild("k", build);
-    cache.getOrBuild("k", build);
+    cache.getOrBuild(cacheKeys("k"), build);
+    cache.getOrBuild(cacheKeys("k"), build);
 
     expect(build).toHaveBeenCalledTimes(2);
     expect(cache.size).toBe(0);
@@ -95,15 +120,15 @@ describe("createThreadTimelineCache", () => {
     const cache = createThreadTimelineCache({ maxEntries: 2 });
     const build = vi.fn(() => makeResponse(1));
 
-    cache.getOrBuild("a", build); // [a]
-    cache.getOrBuild("b", build); // [a,b]
-    cache.getOrBuild("a", build); // touch a -> [b,a]
-    cache.getOrBuild("c", build); // evict b -> [a,c]
+    cache.getOrBuild(cacheKeys("a"), build); // [a]
+    cache.getOrBuild(cacheKeys("b"), build); // [a,b]
+    cache.getOrBuild(cacheKeys("a"), build); // touch a -> [b,a]
+    cache.getOrBuild(cacheKeys("c"), build); // evict b -> [a,c]
 
     expect(cache.size).toBe(2);
     const buildAgain = vi.fn(() => makeResponse(1));
-    cache.getOrBuild("a", buildAgain); // still cached
-    cache.getOrBuild("b", buildAgain); // evicted -> rebuild
+    cache.getOrBuild(cacheKeys("a"), buildAgain); // still cached
+    cache.getOrBuild(cacheKeys("b"), buildAgain); // evicted -> rebuild
     expect(buildAgain).toHaveBeenCalledTimes(1);
   });
 });
pr-2067-adversarial.test.ts
/**
 * Adversarial checks for PR #2067 (one timeline revision per request shape).
 * Exercises paths the PR's own tests do not: an oversized replacement must
 * drop the previous cacheable revision, a replacement must become MRU, and a
 * hit on the newest revision must not be disturbed by a sibling shape.
 */
import { describe, expect, it, vi } from "vitest";
import type { ThreadTimelineResponse } from "@bb/server-contract";
import { createThreadTimelineCache } from "../../../src/services/threads/timeline-cache.js";

function makeResponse(rowCount: number): ThreadTimelineResponse {
  return {
    rows: Array.from({ length: rowCount }, (_, index) => ({
      id: `row-${index}`,
      kind: "system",
      threadId: "thr_x",
      turnId: null,
      sourceSeqStart: index,
      sourceSeqEnd: index,
      startedAt: 0,
      createdAt: 0,
      systemKind: "debug",
      title: "t",
      detail: null,
      status: null,
    })),
    activePromptMode: null,
    activeThinking: null,
    activeWorkflows: [],
    activeBackgroundCommands: [],
    pendingTodos: null,
    goal: null,
    modelFallback: null,
    maxSeq: 0,
    timelinePage: {
      kind: "latest",
      segmentLimit: 20,
      returnedSegmentCount: 0,
      hasOlderRows: false,
      olderCursor: null,
    },
  };
}

const k = (paramsKey: string, maxSeq: number) => ({
  paramsKey,
  revisionKey: `${maxSeq}|${paramsKey}`,
});

describe("PR #2067 adversarial", () => {
  it("an oversized newer revision evicts the previous cacheable revision (claimed in PR body, untested there)", () => {
    const cache = createThreadTimelineCache({ maxCacheableRows: 5 });
    cache.getOrBuild(k("shape", 1), () => makeResponse(3));
    expect(cache.size).toBe(1);
    cache.getOrBuild(k("shape", 2), () => makeResponse(50));
    expect(cache.size).toBe(0);
  });

  it("a replacement is most-recently-used for LRU purposes", () => {
    const cache = createThreadTimelineCache({ maxEntries: 2 });
    cache.getOrBuild(k("a", 1), () => makeResponse(1)); // [a]
    cache.getOrBuild(k("b", 1), () => makeResponse(1)); // [a,b]
    cache.getOrBuild(k("a", 2), () => makeResponse(1)); // replace a -> [b,a]
    cache.getOrBuild(k("c", 1), () => makeResponse(1)); // evict b -> [a,c]
    const rebuild = vi.fn(() => makeResponse(1));
    cache.getOrBuild(k("a", 2), rebuild);
    expect(rebuild).not.toHaveBeenCalled();
    cache.getOrBuild(k("b", 1), rebuild);
    expect(rebuild).toHaveBeenCalledTimes(1);
  });

  it("a stale revision request never returns the newer cached value", () => {
    const cache = createThreadTimelineCache();
    const v2 = makeResponse(2);
    cache.getOrBuild(k("shape", 2), () => v2);
    const v1 = makeResponse(1);
    const got = cache.getOrBuild(k("shape", 1), () => v1);
    expect(got).toBe(v1);
    // And the lower revision now owns the slot (caller-supplied truth wins).
    expect(cache.getOrBuild(k("shape", 1), () => makeResponse(9))).toBe(v1);
  });

  it("bounded: N revisions of one shape never exceed one entry", () => {
    const cache = createThreadTimelineCache();
    for (let seq = 1; seq <= 1000; seq++) {
      cache.getOrBuild(k("shape", seq), () => makeResponse(3));
    }
    expect(cache.size).toBe(1);
  });
});

8. Related issues

9. Appendix

Slow-build log line from my instance (shows which byte count the server logs)

{"level":30,"time":1787328248165,"component":"server","threadId":"thr_6zik7e8uvr","totalDurationMs":454.7,"thresholdMs":150,"suppressedSinceLastLog":0,"selectionStrategy":"standard-window","pageKind":"latest","segmentLimit":20,"eventRowCount":956,"eventDataBytes":459376,"decodedEventCount":956,"projectedRowCount":99,"responseRowCount":99,"stageTimings":[{"durationMs":48.724042000001646,"stage":"event-query"},{"durationMs":28.06095799999821,"stage":"accepted-client-request-context-query"},{"durationMs":292.8353329999991,"stage":"event-json-decode"},{"durationMs":0.565082999997685,"stage":"summary-compaction"},{"durationMs":0.7982910000027914,"stage":"context-window-query"},{"durationMs":33.00687499999913,"stage":"context-window-json-decode"},{"durationMs":50.58583400000134,"stage":"thread-view-projection"},{"durationMs":0.135707999997976,"stage":"pagination-segmentation"}],"msg":"Thread t

eventDataBytes: 459376 for a window whose HTTP response was 107,571 bytes (99 rows). There is no response-size field anywhere in the server's logging (grep -rn "responseBytes|payloadBytes|content-length" apps/server/src only finds compression/static-file headers).

Commands run

git checkout -B repro-2066 fcada5a3b
pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
cd apps/server && pnpm exec vitest run test/services/threads/repro-2066-unit.test.ts      # fails: 2!==1, 128!==1
cd apps/server && pnpm exec vitest run test/public/repro-2066-timeline-cache-retention.test.ts   # fails: 128!==1
scripts/bb-dev-app current          # App :12143  Server :20143  Host daemon :28143
pnpm dev:stop && pnpm seed:perf -- --projects 1 --threads 6 --events 30000 --seed 7
sqlite3 <data-dir>/bb.db "select thread_id,count(*),sum(length(data)),max(sequence) from events group by thread_id order by 2 desc"
/tmp/bb-reports/issues/2066/repro/measure.sh base 100
git fetch origin pull/2067/head:pr-2067 && git checkout -B pr-2067-on-base fcada5a3b && git cherry-pick 206096e4b
/tmp/bb-reports/issues/2066/repro/measure.sh pr2067 100
git checkout repro-2066 && /tmp/bb-reports/issues/2066/repro/measure.sh base-run2 100
cd apps/server && pnpm exec vitest run test/services/threads/pr-2067-adversarial.test.ts test/services/threads/timeline-cache.test.ts   # on PR branch
cd apps/server && pnpm exec vitest run test/public test/services/threads test/threads                                                  # on PR branch
pnpm exec turbo run typecheck --filter=@bb/server
pnpm dev:stop; rm -rf <data-dir>

# revision pass (fresh worktree f02-23, server :20532)
git checkout -B revise-2066 fcada5a3b && pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
cd apps/server && pnpm exec vitest run test/services/threads/repro-2066-unit.test.ts test/public/repro-2066-timeline-cache-retention.test.ts   # base: 3 failed (2!==1, 128!==1, 128!==1)
git fetch origin pull/2067/head:pr-2067 && git checkout -B pr-2067-on-base fcada5a3b && git cherry-pick 206096e4b
cd apps/server && pnpm exec vitest run --reporter=verbose test/services/threads/repro-2066-unit-pr2067.test.ts test/services/threads/repro-2066-unit.test.ts test/public/repro-2066-timeline-cache-retention.test.ts test/services/threads/pr-2067-adversarial.test.ts test/services/threads/timeline-cache.test.ts   # 14 passed, 2 failed (base-signature file: TypeError)
git checkout -B proposed-fix-2066 fcada5a3b && git apply /tmp/bb-reports/issues/2066/repro/proposed-fix-2066.diff
cd apps/server && pnpm exec vitest run --reporter=verbose test/services/threads/repro-2066-unit-after-fix.test.ts test/public/repro-2066-timeline-cache-retention.test.ts test/services/threads/timeline-cache.test.ts test/public/public-thread-timeline-delta.test.ts test/public/public-thread-timeline-output-preview.test.ts test/services/threads/timeline-latest-rows-cache.test.ts   # 25 passed
pnpm exec turbo run typecheck --filter=@bb/server   # pass
scripts/bb-dev-app current && pnpm dev:stop && pnpm seed:perf -- --projects 1 --threads 6 --events 30000 --seed 7
git checkout revise-2066 && OUT_DIR=/tmp/bb-reports/issues/2066/repro/revise /tmp/bb-reports/issues/2066/repro/measure.sh base 100          # +21.7 MB
git checkout pr-2067-on-base && OUT_DIR=… measure.sh pr2067 100                                                                          # +3.6 MB
git checkout proposed-fix-2066 && OUT_DIR=… measure.sh proposed-fix 100                                                                  # +6.9 MB
pnpm dev:stop; rm -rf <data-dir>

live-loop.sh

#!/usr/bin/env bash
# Live repro for get-bb/bb#2066 against an isolated bb dev instance.
#
# Appends N agentMessage events to an existing thread (directly in the dev
# SQLite db, which the server re-reads per request) and refetches the same
# timeline window after each append -- exactly what the web client does during
# a streaming turn. Samples the server process RSS as it goes.
#
# Usage: live-loop.sh <server-url> <db-path> <thread-id> <env-id> <provider-thread-id> <rounds> <out-csv>
set -euo pipefail
SERVER=$1; DB=$2; THREAD=$3; ENV_ID=$4; PTID=$5; ROUNDS=$6; OUT=$7
PORT=${SERVER##*:}
PID=$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t | head -1)
TURN="turn_repro_$(date +%s)"
seq_now() { sqlite3 "$DB" "select max(sequence) from events where thread_id='$THREAD';"; }
rss_kb() { ps -p "$PID" -o rss= | tr -d ' '; }
append() { # $1 type, $2 item_id|NULL, $3 item_kind|NULL, $4 data-json
  local s; s=$(( $(seq_now) + 1 ))
  sqlite3 "$DB" "insert into events (id,thread_id,environment_id,scope_kind,turn_id,provider_thread_id,sequence,type,item_id,item_kind,data,created_at)
    values ('evt_repro_${s}','$THREAD','$ENV_ID','turn','$TURN','$PTID',$s,'$1',$2,$3,'$4',$(date +%s)000);"
}
fetch() { curl -s -m 30 -o /tmp/2066-timeline.json -w '%{size_download}' "$SERVER/api/v1/threads/$THREAD/timeline"; }

echo "round,maxSeq,responseBytes,rows,serverRssKb" > "$OUT"
echo "server pid=$PID start rss=$(rss_kb)KB thread=$THREAD turn=$TURN" >&2
append 'turn/started' NULL NULL "{\"providerThreadId\":\"$PTID\"}"
for ((i=1; i<=ROUNDS; i++)); do
  append 'item/completed' "'msg_repro_$i'" "'agentMessage'" "{\"providerThreadId\":\"$PTID\",\"item\":{\"type\":\"agentMessage\",\"id\":\"msg_repro_$i\",\"text\":\"chunk $i\"}}"
  bytes=$(fetch)
  rows=$(node -e 'const r=JSON.parse(require("fs").readFileSync("/tmp/2066-timeline.json","utf8"));process.stdout.write(String(r.rows.length)+" "+r.maxSeq)')
  echo "$i,${rows#* },$bytes,${rows% *},$(rss_kb)" >> "$OUT"
done
echo "end rss=$(rss_kb)KB" >&2

heap-after-gc.mjs

// Measures the V8 heap of a running node process after a forced full GC.
// Usage: node heap-after-gc.mjs <pid> [label]
// Sends SIGUSR1 (activates the inspector on 127.0.0.1:9229), connects over
// the DevTools protocol, runs HeapProfiler.collectGarbage, then reads
// Runtime.getHeapUsage. Prints JSON {label, usedMB, totalMB}.
import { execSync } from "node:child_process";

const pid = Number(process.argv[2]);
const label = process.argv[3] ?? "";
process.kill(pid, "SIGUSR1");
let target;
for (let i = 0; i < 50; i++) {
  try {
    const list = JSON.parse(
      execSync("curl -s http://127.0.0.1:9229/json/list").toString(),
    );
    target = list[0]?.webSocketDebuggerUrl;
    if (target) break;
  } catch {}
  await new Promise((r) => setTimeout(r, 100));
}
if (!target) throw new Error("inspector did not come up on 9229");
const ws = new WebSocket(target);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let id = 0;
const pending = new Map();
ws.onmessage = (m) => {
  const msg = JSON.parse(m.data);
  if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
};
const call = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); });
await call("HeapProfiler.enable");
await call("HeapProfiler.collectGarbage");
await call("HeapProfiler.collectGarbage");
const { result } = await call("Runtime.getHeapUsage");
console.log(JSON.stringify({ label, usedMB: +(result.usedSize / 1048576).toFixed(1), totalMB: +(result.totalSize / 1048576).toFixed(1) }));
ws.close();

measure.sh

#!/usr/bin/env bash
# Full live measurement for one branch: fresh server, warm fetches, GC'd heap
# baseline, N append+fetch rounds, GC'd heap afterwards.
#
# Usage (run from inside the bb worktree whose branch you want to measure):
#   cd <your bb worktree> && /tmp/bb-reports/issues/2066/repro/measure.sh <label> <rounds>
#
# Nothing is hardcoded to a particular worktree: WT is the git toplevel of the
# current directory, and DB / SERVER come from `scripts/bb-dev-app status`
# (which derives ports and the data dir from the worktree path). Override any
# of WT, DB, SERVER, OUT_DIR via the environment if needed.
#
# Prerequisites (once per worktree):
#   pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
#   scripts/bb-dev-app current && pnpm dev:stop      # creates the data dir
#   pnpm seed:perf -- --projects 1 --threads 6 --events 30000 --seed 7
# The seed is deterministic, so THREAD / ENV_ID / PTID below are stable.
# heap-after-gc.mjs uses the inspector on 127.0.0.1:9229 (SIGUSR1 default);
# the script aborts if that port is already taken.
set -euo pipefail
LABEL=$1; ROUNDS=$2
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
OUT_DIR=${OUT_DIR:-$HERE}
WT=${WT:-$(git rev-parse --show-toplevel)}
cd "$WT"
STATUS=$(scripts/bb-dev-app status)
DB=${DB:-$(printf '%s\n' "$STATUS" | sed -n 's/^Data dir: //p')/bb.db}
SERVER=${SERVER:-$(printf '%s\n' "$STATUS" | sed -n 's/^Server: //p')}
PORT=${SERVER##*:}
THREAD=thr_6zik7e8uvr; ENV_ID=env_866bjs4xjm; PTID=8e22443c-a63a-724c-e6a1-7e51fdbc2789

[ -f "$DB" ] || { echo "no db at $DB (run scripts/bb-dev-app current, pnpm dev:stop, pnpm seed:perf first)" >&2; exit 1; }
echo "worktree=$WT db=$DB server=$SERVER out=$OUT_DIR" >&2

pnpm dev:stop >/dev/null 2>&1 || true   # also releases 9229 if a previous run activated the inspector
sleep 3
if lsof -nP -iTCP:9229 -sTCP:LISTEN -t >/dev/null 2>&1; then echo "inspector port 9229 already in use by another process" >&2; exit 1; fi
sqlite3 "$DB" "delete from events where id like 'evt_repro_%';"
scripts/bb-dev-app current >"$OUT_DIR/dev-start-$LABEL.log" 2>&1
for i in $(seq 1 60); do curl -s -m 3 -o /dev/null -f "$SERVER/api/v1/projects" && break; sleep 1; done
PID=$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t | head -1)
echo "branch=$(git rev-parse --short HEAD) pid=$PID"
for i in 1 2 3; do curl -s -m 30 -o /dev/null "$SERVER/api/v1/threads/$THREAD/timeline"; done
node "$HERE/heap-after-gc.mjs" "$PID" "$LABEL: before loop (warm, 1 cached revision)"
"$HERE/live-loop.sh" "$SERVER" "$DB" "$THREAD" "$ENV_ID" "$PTID" "$ROUNDS" "$OUT_DIR/live-$LABEL.csv"
node "$HERE/heap-after-gc.mjs" "$PID" "$LABEL: after $ROUNDS append+fetch rounds"

Other artifacts: seed-perf.log, timeline-thr_6zik7e8uvr.json (the 107 KB at-rest response), route-test-base.log, unit-test-base.log, pr2067-typecheck.log, pr2067-unit-variants.log, proposed-fix-tests.log, proposed-fix-typecheck.log, revise/ (re-run logs and CSVs), verify/ (independent verifier's logs).

10. Verification

An independent verifier followed §4a and §4b literally in a separate worktree at fcada5a3b (fresh install + build): both failed exactly as shown, including the identical [2066] first-response bytes=24859 rows(last)=152 rounds=150 cache.size=128 line. For §4c the verifier had to edit four hardcoded lines of the original measure.sh (worktree path, DB path, port) and then reproduced the measurement on their own isolated instance: base 160.1 → 179.4 MB (+19.3), PR #2067 159.8 → 165.8 MB (+6.0) (logs). All root-cause code references, the origin/main not-fixed check, the PR diff / merge-base / draft status, and the "oversized replacement" test-coverage finding were confirmed.

What changed in this revision: