Repository navigation
Bound activity payload bytes, and reclaim what the uncapped ones already wrote - #113
Merged
Merged
Conversation
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>
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 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.
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
tusharbhardwaj-bk
had a problem deploying
to
bk-desktop-staging
August 17, 2026 18:38 — with
GitHub Actions
Failure
tusharbhardwaj-bk
had a problem deploying
to
bk-desktop-staging
August 17, 2026 19:18 — with
GitHub Actions
Failure
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
bkt3'sstate.sqlitegrew 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:
freelist_countThe bloat is unbounded tool output.
OrchestrationThreadActivity.payloadisSchema.Unknown— whatever the provider adapter produced, verbatim — and it is stored twice: in the append-only event log, and again inprojection_thread_activities. Bytes by JSON path across rows over 20 KB:That third path is the pathological one: T3's own MCP tools return thread activities, so a
t3_get_sessioncall 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.tscaps strings at 32 KiB, arrays at 1,000 items, depth at 24 — applied atdecider.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.tsrewrites rows written before the cap existed. It imports the server's owncapActivityPayload, 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:
Already applied to both live databases:
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
bkt3's load is what will actually exercise it.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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.