Skip to content

Bound activity payload bytes, and reclaim what the uncapped ones already wrote - #113

Merged
tusharbhardwaj-bk merged 8 commits into
bkmainfrom
expbkmain
Aug 17, 2026
Merged

tusharbhardwaj-bk merged 8 commits into
bkmainfrom
expbkmain

Conversation

@tusharbhardwaj-bk

@tusharbhardwaj-bk tusharbhardwaj-bk commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

Why

bkt3's state.sqlite grew 3.30 → 3.61 GB in two days and was the largest untouched contributor to disk pressure on a host that fell to 21 GB free during this work.

Two measurements redirected the obvious fix:

Measurement Value Consequence
Events older than 30 days 3,912 rows / 9 MB age-based retention is worthless — the DB is only ~30 days deep
freelist_count 0 VACUUM alone would have reclaimed nothing

The bloat is unbounded tool output. OrchestrationThreadActivity.payload is Schema.Unknown — whatever the provider adapter produced, verbatim — and it is stored twice: in the append-only event log, and again in projection_thread_activities. Bytes by JSON path across rows over 20 KB:

241 MB  $.data.item.aggregatedOutput           command stdout/stderr
219 MB  $.data.item.result.content[].text      MCP tool result text
 24 MB  ...thread.activities[]...aggregatedOutput

That third path is the pathological one: T3's own MCP tools return thread activities, so a t3_get_session call embeds other activities' captured output inside its own result, which is stored as a new activity. The database was feeding on itself, and every round trip was re-logged through the WAL.

What this does

activityPayloadCap.ts caps strings at 32 KiB, arrays at 1,000 items, depth at 24 — applied at decider.ts, where the event is constructed. Because the projection is derived from the event, one cap covers both tables.

It truncates with a self-describing marker rather than dropping fields: unlike a never-rendered blob, this content is shown in the timeline, and the head of a command's output is the part anyone reads. Payloads already under the caps return the same reference, so the ~98% common case neither copies nor re-serializes.

The caps are idempotent by construction — the marker is charged against the budget, so a capped value lands at the cap and never above it. The backfill re-reads rows it may already have rewritten, and a non-idempotent cap would eat real output on every pass.

compact-activity-payloads.ts rewrites rows written before the cap existed. It imports the server's own capActivityPayload, so backfill and runtime cannot drift. It rewrites in place — no row, no sequence, and no thread history is deleted, so the projection bootstrap replay (from the persisted cursor) and the WebSocket reconnect replay are structurally untouched.

Verification

Run against real production payloads from a pre-change backup:

  itemType             before      after   ratio  valid-JSON  idempotent
  image_view            1658KB       35KB      2%        true        true
  image_view            1558KB       35KB      2%        true        true
  longest string after capping: 32713 (cap 32768) -> PASS

Already applied to both live databases:

expbkt3 bkt3
Reclaimed 29 MB 696 MB
Rows rewritten 338 6,960 (0.9% of rows)
Integrity / cursor == maxSeq ok ok
Restarts 0 0

Growth has stopped on bkt3: the file has been flat at 3,491 MB for 6.75 h while free pages were consumed (690 → 654 MB) rather than the file being extended. Live bytes are down to 2,837 MB. Zero errors on either instance over 6 h. The cap has run on expbkt3 since 7066da17.

Known limits

  • The cap has not yet been observed firing on live traffic — expbkt3 was idle for the 6 h after deploy. It is verified by unit tests and against real production payloads, but bkt3's load is what will actually exercise it.
  • The file does not shrink until a VACUUM, which needs the service stopped. Growth stopping was the actual problem; the ~700 MB of file space is a separate scheduled step.
  • A lower cap reclaims more: 16 KB → ~816 MB, 8 KB → ~1,014 MB, against 3,477 rows touched at 32 KB vs 16,751 at 8 KB.

Operator note

The first production backfill run used 100 rows per transaction and held SQLite's single write lock long enough that two runtime event appends were dropped (thread.token-usage.updated, task.progress — telemetry only; no thread content, message, or user action). Defaults are now 25 rows / 250 ms, with the reason recorded in the file header. Our own busy timeout does not help there: it governs how long we wait, not how long the server waits on us.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

tusharbhardwaj-bk and others added 6 commits August 17, 2026 09:39
A phone has no right button, so every action behind a row's onContextMenu
was unreachable on mobile web. A press held 450ms opens the same menu at
the same position.

The gesture lives in a fork-owned module so the call sites stay one-liners:
- movement past 10px or a second finger cancels it, so scrolling still scrolls
- the click the platform synthesises on lift is swallowed once, or lifting a
  finger would activate the row (or the menu item painted under the fingertip)
- Chrome on Android fires its own contextmenu at ~500ms; whichever fires
  first wins and the other is dropped, so the user never gets two menus
- mouse pointers are ignored entirely, leaving desktop right-click untouched

Verified against a production build in headless Chromium with real touch
emulation (390x844): the menu opens, survives the lift, dismisses on an
outside tap, stays closed for a scrolling press, and a short tap still
opens the row.
`OrchestrationThreadActivity.payload` is `Schema.Unknown` — whatever the
provider adapter produced, verbatim — and it is written twice: into the
append-only event log, and again into `projection_thread_activities` when the
projector applies the event. Nothing between the adapter and the disk bounded
its size.

On bkt3 that reached 2.8 GB of a 3.6 GB database. Measuring where the bytes
sit, per JSON path across rows over 20 KB:

  241 MB  $.data.item.aggregatedOutput          (command stdout/stderr)
  219 MB  $.data.item.result.content[].text     (MCP tool result text)
   24 MB  ...result.structuredContent.thread.activities[]...aggregatedOutput

That third path is the pathological one: T3's own MCP tools return thread
activities, so a `t3_get_session` call embeds other activities' captured output
inside its own result, which is then stored as a new activity. The database
feeds on itself, and every round trip is re-logged through the WAL.

Cap individual strings rather than dropping fields — unlike a never-rendered
blob, this content is shown in the timeline, and the head of a command's output
is the part anyone reads. Past the cap we keep a marker recording what went, so
a truncated payload is self-describing rather than silently short.

Capping at the decider, where the event is constructed, covers both tables from
one place. Payloads already under the caps come back as the same reference, so
the ~98% common case neither copies nor re-serializes.

The caps are idempotent by construction: the marker is charged against the
budget so a capped value lands at the cap, never above it. `compact-activity-
payloads.ts` re-reads rows it may already have rewritten, and a non-idempotent
cap would eat real output on every pass.

The backfill imports the same `capActivityPayload`, so it cannot drift from the
runtime. It rewrites rows in place — no row, no sequence, and no thread history
is deleted, so the projection bootstrap replay and the WebSocket reconnect
replay are structurally untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQLite in WAL mode admits exactly one writer, so every transaction the backfill
takes is one the server cannot take. The first production run used 100 rows per
transaction — roughly 5 MB of rewrites — and held the write lock long enough
that two `runtime` event appends exceeded the server's busy timeout and were
dropped: `thread.token-usage.updated` and `task.progress`.

Our own busy timeout does not help there. It governs how long WE wait for a
lock, not how long the server waits on us. The only lever that protects the
server is keeping each transaction short, so drop the default batch to 25 rows
and raise the sleep between batches to 250ms, and record why in the header so
the next person does not tune them back up against a busy instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught two things the local scoped checks did not.

The root `scripts/` directory is its own TypeScript project, so importing
`apps/server/src/orchestration/activityPayloadCap.ts` from there fails: the file
is not in that project's file list. Duplicating the cap into the script would
have satisfied the compiler and defeated the point — the backfill must apply
byte-for-byte the same cap the server applies, or the two drift and a "compacted"
row stops matching what the runtime would write. Moving the script under
`apps/server/scripts/`, next to `migrate-dev-db.ts` and `t3-sqlite-state.ts`,
keeps the shared import and puts it with the other operational scripts.

Also declare the `globalTimers` diagnostic alongside the existing suppressions:
this is a standalone Node script with no Effect runtime, so its inter-batch
sleep is a plain `setTimeout`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every reopened Plannotator review presented the permission-mode dialog
("Auto-accept Edits", "Bypass Permissions", …) and the two feature
announcements, as if the review surface had never been opened before.

Two independent causes, both fixed here.

The preference bridge stored `plannotator-*` preferences in
`document.cookie`. That is inert on any origin whose scheme is not
cookieable, and the desktop renderer is served from `t3code://app`: the
write silently succeeds and the jar reads back empty, so the parent
computed an empty `#t3-preferences` fragment on every open and nothing the
reviewer chose ever survived. Keep the preferences in `localStorage`,
which every T3 client can write, still reading the cookie jar so choices
made before this store shipped are carried over, and still writing the
cookie so a browser served by the review's own origin keeps seeding the
injected shim across an in-iframe reload.

Even with a working store, the *first* review in any client opened on a
dialog that cannot decide anything: T3 owns the post-approval permission
mode — `applyDecision` issues `thread.interaction-mode.set` itself and
never reads Plannotator's choice — and the announcements advertise
standalone Plannotator features the embedded surface cannot act on. Seed
the flags that gate them so a review opens on the plan. Only the gating
flags are seeded, never the mode itself, so T3 picks no automation level
on the reviewer's behalf; a client-stored value overrides every seed, and
Plannotator's own Settings still changes any of them.

Verified against Plannotator 0.20.1 through the real proxy rewrite: an
unseeded review renders "Permission Mode Preservation" with the
"Auto-accept Edits" options, a seeded one renders the plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Aug 17, 2026
@github-actions

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 11.5 KiB — 15.1 KiB ✅
Codex Thread snapshot wire — 5.7 KiB — 7.3 KiB ✅
Codex Live turn WebSocket wire — 5.7 KiB — 7.8 KiB ✅
Codex Live turn WebSocket decoded — 50.4 KiB — 66.4 KiB ✅
Codex Live turn messages — 11 — 21 ✅
Claude Total thread wire — 11.4 KiB — 15.1 KiB ✅
Claude Thread snapshot wire — 5.7 KiB — 7.3 KiB ✅
Claude Live turn WebSocket wire — 5.7 KiB — 7.8 KiB ✅
Claude Live turn WebSocket decoded — 51.2 KiB — 66.4 KiB ✅
Claude Live turn messages — 11 — 21 ✅

Baseline: unavailable · PR result: e78dde7 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 95.6 KiB
  • Claude decoded thread snapshot: 96.3 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

feat(web): open row context menus with a touch long-press
@github-actions github-actions Bot added size:XXL and removed size:XL labels Aug 17, 2026
@tusharbhardwaj-bk
tusharbhardwaj-bk merged commit 1534ee3 into bkmain Aug 17, 2026
16 checks passed

This branch had an error being deployed

1 failed deployment
bk-desktop-staging — a3bafa5e Deployed Aug 17, 2026 by tusharbhardwaj-bk via Publish #15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant