Stop shipping megabytes of skill prompts in every snapshot refresh

The whole app turned to molasses because snapshot.load() shipped the
entire skill_runtime_snapshots table - unbounded, compiled_prompt
included - over IPC on every refresh. A real profile carried 142 rows
totaling 144MB (1.1MB each from the pre-cap CEO compile-all era), so
every coalesced refresh serialized ~144MB of JSON. The forced-humanizer
rework made it worse by adding a row per run for every agent.

- Drop skillRuntimeSnapshots, skillRevisions, skillTests, and
  skillBindings from the renderer snapshot: no renderer surface reads
  them; skill internals are main-process state. Only the lightweight
  skillDefinitions catalogue still ships.
- Project provider_requests without raw_response_json (4.1MB across the
  newest 100 rows; RunsPage only reads responseText)
- Migration 051 purges the bug-era compiled prompts from existing DBs
- resolveRuntimeSnapshot is now idempotent per run: a run re-queued by
  startup recovery reuses its frozen snapshot instead of dying on the
  uniqueness guard and burning real retry budget
- Regression test pins the contract: a 2MB database row must not add
  bytes to the snapshot (payload asserted under 400KB)
This commit is contained in:
ZacharyZhang-NY committed 2026-07-08 23:20:56 -04:00
1 parent 4ab20e679c
commit 1f89ea7089
10 files changed
+117 -34

No files matched your search

@@ -0,0 +1,10 @@
// Rows written before the compiled-prompt cap carried the entire skill
// catalogue (~1.1MB each; 144MB observed in a real profile). Nothing reads a
// finished run's compiled prompt back, and an interrupted run simply
// recompiles under the cap on recovery, so the table drains cleanly.
export const purgeRuntimeSnapshotBloatSchema = `
delete from skill_runtime_snapshots;
insert into schema_migrations(version, name, applied_at)
values (51, 'purge_runtime_snapshot_bloat', datetime('now'));
`;
+2
View File
@@ -49,6 +49,7 @@ import { authConfigPurgeSchema } from "./migrations/047-auth-config-purge";
import { uiPreferencesSchema } from "./migrations/048-ui-preferences";
import { avatarsSchema } from "./migrations/049-avatars";
import { financeSalesDepartmentsSchema } from "./migrations/050-finance-sales-departments";
import { purgeRuntimeSnapshotBloatSchema } from "./migrations/051-purge-runtime-snapshot-bloat";
interface Migration {
version: number;
@@ -107,6 +108,7 @@ const MIGRATIONS: Migration[] = [
{ version: 48, name: "ui_preferences", sql: uiPreferencesSchema },
{ version: 49, name: "avatars", sql: avatarsSchema },
{ version: 50, name: "finance_sales_departments", sql: financeSalesDepartmentsSchema },
{ version: 51, name: "purge_runtime_snapshot_bloat", sql: purgeRuntimeSnapshotBloatSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+4 -1
View File
@@ -178,9 +178,12 @@ export class SkillService {
* compiled prompt instead of silently overflowing the model's context.
*/
resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string {
// Idempotent per run: a run interrupted after resolving (crash, quit) is
// re-queued by startup recovery and must reuse its frozen skill set, not
// die on a uniqueness error that burns real retry budget.
const existingSnapshot = this.db.prepare("select id from skill_runtime_snapshots where agent_id = ? and run_id = ?")
.get(input.agentId, input.runId) as { id: string } | undefined;
if (existingSnapshot) throw new Error("Skill runtime snapshot already exists for this run.");
if (existingSnapshot) return existingSnapshot.id;
const forcedRows = this.db.prepare(`
select
sd.id as skill_id,
+9 -7
View File
@@ -62,8 +62,6 @@ export class SnapshotService {
);
const providerUsageWindows = list<ProviderUsageWindowRow>(this.db, "provider_usage_windows", "window_start desc");
const providerRateLimits = buildProviderRateLimits(providers, providerUsageWindows);
const skillBindings = list<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
const files = list<AppSnapshot["files"][number]>(this.db, "file_objects", "created_at desc")
.map((file) => ({ ...file, pinned: toBool(file.pinned) }));
const legalHolds = list<AppSnapshot["legalHolds"][number]>(this.db, "legal_holds", "created_at desc")
@@ -115,14 +113,18 @@ export class SnapshotService {
providerAuthProfiles,
providerAuthSessions,
providerCatalog: PROVIDER_CATALOG,
providerRequests: recentList(this.db, "provider_requests", "created_at desc", 100),
// Explicit projection: raw_response_json alone weighs megabytes across
// 100 rows and no renderer surface reads it.
providerRequests: all<Row>(this.db.prepare(`
select id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256,
response_text, state, error_message, input_char_count, output_char_count,
execution_mode, policy_decision, external_response_id, stream_event_count,
cancelled_at, created_at, completed_at
from provider_requests order by created_at desc limit 100
`)).map((row) => camel(row) as unknown as AppSnapshot["providerRequests"][number]),
providerRateLimits,
providerRateLimitObservations,
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
skillRevisions: list(this.db, "skill_revisions", "published_at desc"),
skillBindings,
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
skillTests: list(this.db, "skill_tests", "last_run_at desc, id asc"),
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"),
browserSessionContainers: list<AppSnapshot["browserSessionContainers"][number]>(this.db, "browser_session_containers", "created_at asc")
+5 -1
View File
@@ -10,6 +10,11 @@ export const PROVIDER_REQUEST_STATE = {
export type ProviderRequestState = typeof PROVIDER_REQUEST_STATE[keyof typeof PROVIDER_REQUEST_STATE];
/**
* Snapshot projection of a provider request. The raw provider response body
* stays in the database (task_runs diagnostics read it there) — shipping it
* to the renderer ballooned every snapshot by megabytes for nothing.
*/
export interface ProviderRequestRecord {
id: string;
orgId: string;
@@ -19,7 +24,6 @@ export interface ProviderRequestRecord {
model: string;
promptSha256: string;
responseText: string | null;
rawResponseJson: string | null;
state: ProviderRequestState;
errorMessage: string | null;
inputCharCount: number;
+4 -8
View File
@@ -34,11 +34,7 @@ import type {
ProviderCatalogEntry,
ProviderRecord,
ScheduledPostRecord,
SkillBindingRecord,
SkillDefinitionRecord,
SkillRevisionRecord,
SkillRuntimeSnapshotRecord,
SkillTestRecord,
SprintRecord,
TaskRecord,
TaskRunRecord,
@@ -103,11 +99,11 @@ export interface AppSnapshot {
providerRequests: ProviderRequestRecord[];
providerRateLimits: ProviderRateLimitStateRecord[];
providerRateLimitObservations: ProviderRateLimitObservationRecord[];
// The snapshot is the renderer's working set and rides IPC on every
// refresh. Skill internals (revisions, bindings, per-run runtime
// snapshots with compiled prompts) are main-process concerns and stay out
// of it; only the lightweight catalogue listing ships.
skillDefinitions: SkillDefinitionRecord[];
skillRevisions: SkillRevisionRecord[];
skillBindings: SkillBindingRecord[];
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
skillTests: SkillTestRecord[];
socialAccounts: SocialAccountRecord[];
socialAccountGrants: SocialAccountGrantRecord[];
browserSessionContainers: BrowserSessionContainerRecord[];