diff --git a/CHANGELOG.md b/CHANGELOG.md index 987a0e8..b6a1c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ new version heading in the same commit. ## [Unreleased] +## [0.292.2] — 2026-08-03 +### Fixed +- **`listSessions` re-queried the members and automations tables once per row.** v0.291.6 made the + 1.5 s console poll cheap on the wire (a 304 with no body), but the server still paid the full rebuild + before it could decide to send that 304 — a 304 measured exactly as slow as the full response. The + rebuild's single largest cost turned out to be the four per-row helpers (`spawnedByLabel`, + `sourceKind`, `runAsLabel` and `canViewSpawn`), each of which issued its own point lookup per row: on + the live instawp tenant that was **~1900 SQLite queries per poll to resolve 14 members and 40 + automations**. The lookup tables are tiny and bounded; the row count (950 and growing) is not. + A `withRowCache` scope now loads each table **once per list call** and the helpers read from it — + two queries in place of ~1900. + - `tm.listSessions(owner)` **35 ms → 14 ms (−60%)**; internal/no-viewer 43 → 22 ms; archived 1.5 → 0.6 ms. + - End-to-end `GET /api/sessions` **51 ms → 32 ms**, and the idle-poll 304 path **50 ms → 30 ms** — + which is the one that runs 40×/minute per open tab. + + The cache lives only for the duration of one **synchronous** call (no await, so nothing can interleave) + and nothing inside the scope mutates either table, so it cannot go stale. Outside such a scope the + helpers take exactly the old direct-query path, leaving every other caller untouched. The scope is + re-entrant and restored in a `finally`, so an exception can't strand a stale cache on the instance. + + Complements #532, which stops the *client* re-rendering on an unchanged tick: together an idle poll now + neither rebuilds on the server nor re-renders in the browser. +### Changed +- **gzip level is now split by how often the same bytes get compressed.** A static asset is compressed + once per build and cached, so it keeps level 6. A live JSON payload is re-compressed on nearly every + poll (`/api/sessions` changes whenever any run does), so it drops to level 4: measured on that 1.2 MB + payload, **15.8 ms → 10.1 ms for 5.9% more bytes** (level 1 would be 6.5 ms but 18.8% more). The + compression time had become comparable to the entire list rebuild. + ## [0.292.1] — 2026-08-03 ### Changed - **The console's global 1.5 s poll now skips the re-render on unchanged ticks (client-side 304).** diff --git a/package-lock.json b/package-lock.json index c382408..ee84e39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.292.1", + "version": "0.292.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.292.1", + "version": "0.292.2", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 4206e2d..2bfc380 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.292.1", + "version": "0.292.2", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/server.ts b/src/server.ts index b5eae07..dd3a28a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6759,10 +6759,25 @@ const COMPRESSIBLE = /^(?:text\/|application\/(?:json|javascript|xml)|image\/svg */ const GZIP_CACHE = new Map(); const GZIP_CACHE_MAX = 64; -function gzipFor(key: string, raw: Buffer): Buffer { +/** + * Compression levels, split by how often the same bytes get compressed. + * + * A static asset is compressed ONCE per build and then served from `FILE_CACHE` forever, so its CPU + * cost is amortised to nothing and only the wire size matters — take the small level-6 win. + * + * A live JSON payload is the opposite. `/api/sessions` changes whenever any run does (8 concurrent runs + * on the live instawp tenant keep `updatedAt` moving), so nearly every 1.5s poll is a cache MISS and + * re-compresses ~1.2 MB. Measured on that payload: level 6 costs 15.8 ms, level 4 costs 10.1 ms for + * 5.9% more bytes, level 1 costs 6.5 ms for 18.8% more. Level 4 is the knee — it buys back a third of + * the compression time (comparable to the entire list rebuild) for bytes nobody notices on a poll that + * is usually a 304 anyway. + */ +const GZIP_LEVEL_STATIC = 6; +const GZIP_LEVEL_DYNAMIC = 4; +function gzipFor(key: string, raw: Buffer, level: number): Buffer { const hit = GZIP_CACHE.get(key); if (hit) return hit; - const out = zlib.gzipSync(raw, { level: 6 }); + const out = zlib.gzipSync(raw, { level }); if (GZIP_CACHE.size >= GZIP_CACHE_MAX) GZIP_CACHE.delete(GZIP_CACHE.keys().next().value as string); GZIP_CACHE.set(key, out); return out; @@ -6778,7 +6793,7 @@ function gzipFor(key: string, raw: Buffer): Buffer { * revalidate first" — never "serve it stale". So a 304 can only follow a fresh round-trip, and the * 1.5s poll degrades from a megabyte to a bodyless header when nothing changed. */ -function sendBody(res: http.ServerResponse, status: number, raw: Buffer, contentType: string, cacheControl: string): void { +function sendBody(res: http.ServerResponse, status: number, raw: Buffer, contentType: string, cacheControl: string, level = GZIP_LEVEL_DYNAMIC): void { const req: http.IncomingMessage | undefined = res.req; const headers: Record = { 'content-type': contentType, 'cache-control': cacheControl, vary: 'accept-encoding' }; // Revalidation only makes sense for a successful GET; a 304 carries validators and no body. @@ -6794,7 +6809,7 @@ function sendBody(res: http.ServerResponse, status: number, raw: Buffer, content let body = raw; if (raw.length >= COMPRESS_MIN && COMPRESSIBLE.test(contentType) && /\bgzip\b/.test(String(req?.headers['accept-encoding'] || ''))) { // Only cache keyed by a content hash; without an ETag (a POST reply, an error) compress one-off. - body = etag ? gzipFor(etag, raw) : zlib.gzipSync(raw, { level: 6 }); + body = etag ? gzipFor(etag, raw, level) : zlib.gzipSync(raw, { level }); headers['content-encoding'] = 'gzip'; } headers['content-length'] = String(body.length); @@ -6824,14 +6839,14 @@ function sendFile(res: http.ServerResponse, file: string, contentType: string): const cacheControl = isFingerprinted(file) ? 'public, max-age=31536000, immutable' : 'no-cache'; const key = `${file}:${st.mtimeMs}:${st.size}`; const hit = FILE_CACHE.get(key); - if (hit) return sendBody(res, 200, hit, contentType, cacheControl); + if (hit) return sendBody(res, 200, hit, contentType, cacheControl, GZIP_LEVEL_STATIC); fs.readFile(file, (err, data) => { if (err) return sendJson(res, 404, { error: `file not found: ${path.basename(file)}` }); if (st.size <= FILE_CACHE_MAX_BYTES) { if (FILE_CACHE.size >= 64) FILE_CACHE.delete(FILE_CACHE.keys().next().value as string); FILE_CACHE.set(key, data); } - sendBody(res, 200, data, contentType, cacheControl); + sendBody(res, 200, data, contentType, cacheControl, GZIP_LEVEL_STATIC); }); }); } diff --git a/src/terminal.ts b/src/terminal.ts index 1789a13..285b376 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -341,6 +341,13 @@ export interface FeedMessage { type GateStatus = 'pending' | 'allow' | 'deny'; type GateResult = { decision: 'allow' | 'deny' | 'pending'; gateId?: string; note?: string }; +/** The automation columns the per-row label/source/authz helpers read — see `TerminalManager.withRowCache`. */ +interface AutomationLookup { + name: string; + type: string; + created_by: string | null; +} + interface SessionRow { id: string; agent: string; @@ -682,6 +689,11 @@ export class TerminalManager { * regular member sees only sessions they spawned, plus sessions fired by an automation they created. */ listSessions(viewer?: Member): Session[] { + // Memoize the member/automation lookups for this call — the per-row helpers below would otherwise + // re-query them ~2x per row. See withRowCache(). + return this.withRowCache(() => this.listSessionsUncached(viewer)); + } + private listSessionsUncached(viewer?: Member): Session[] { // Archived sessions are hidden from the list (reversible soft-archive via the Insights declutter tile); // their rows survive for every by-id reference (task-reconcile, audit, cost). const rows = this.db.prepare('SELECT * FROM term_sessions WHERE archived_at IS NULL ORDER BY created_at DESC').all(); @@ -730,9 +742,11 @@ export class TerminalManager { /** The soft-archived sessions (hidden from `listSessions`) — for the "show archived / restore" view. * Same viewer-visibility rule as the live list; no liveness/cost work (they're terminal + settled). */ listArchivedSessions(viewer?: Member): Session[] { - const rows = this.db.prepare('SELECT * FROM term_sessions WHERE archived_at IS NOT NULL ORDER BY archived_at DESC').all(); - const visible = viewer ? rows.filter((r) => this.canViewRow(r.spawned_by, r.run_as, viewer)) : rows; - return visible.map((r) => ({ ...toSession(r), spawnedByLabel: this.spawnedByLabel(r.spawned_by, r.run_as), sourceKind: this.sourceKind(r.spawned_by), runAsLabel: this.runAsLabel(r.run_as), ratedByLabel: this.runAsLabel(r.rated_by) })); + return this.withRowCache(() => { + const rows = this.db.prepare('SELECT * FROM term_sessions WHERE archived_at IS NOT NULL ORDER BY archived_at DESC').all(); + const visible = viewer ? rows.filter((r) => this.canViewRow(r.spawned_by, r.run_as, viewer)) : rows; + return visible.map((r) => ({ ...toSession(r), spawnedByLabel: this.spawnedByLabel(r.spawned_by, r.run_as), sourceKind: this.sourceKind(r.spawned_by), runAsLabel: this.runAsLabel(r.run_as), ratedByLabel: this.runAsLabel(r.rated_by) })); + }); } /** Soft-archive a session — hide it from the list, keep the row + transcript (reversible). */ archiveSession(id: string, now = Date.now()): boolean { @@ -1042,7 +1056,7 @@ export class TerminalManager { if (!spawnedBy) return false; if (spawnedBy === viewer.id) return true; if (spawnedBy.startsWith('automation:')) { - const a = this.db.prepare('SELECT created_by FROM automations WHERE id = ?').get<{ created_by: string | null }>(spawnedBy.slice('automation:'.length)); + const a = this.lookupAutomation(spawnedBy.slice('automation:'.length)); return !!a?.created_by && a.created_by === viewer.id; } return false; @@ -1165,14 +1179,68 @@ export class TerminalManager { return this.backend.ttydPortFor(space) ?? null; } + /** + * Memoized lookup tables for the duration of ONE synchronous list call — see {@link withRowCache}. + * `null` outside such a call, which is what keeps every other caller byte-identical. + */ + private rowCache: { members?: Map; autos?: Map } | null = null; + + /** + * Run `fn` with the per-row member/automation lookups memoized. + * + * The row helpers below (`spawnedByLabel`, `sourceKind`, `runAsLabel`, `canViewSpawn`) each re-query + * SQLite per row. That's fine for one row and quadratic-feeling for a list: on the live instawp tenant + * `listSessions` walks 950 sessions and fired ~1900 point lookups to resolve a grand total of **14 + * members and 40 automations** — the lookup tables are tiny and bounded, the row count is not. Loading + * each table once per call replaces all of them with two queries, and was over half of `listSessions`' + * wall time (18 ms of 35 ms measured on that tenant's data). + * + * Safe because the whole scope is SYNCHRONOUS — no await, so no other request can interleave and no + * write can land mid-call — and nothing inside the scope mutates `members` or `automations` + * (`markCrashed`/`backfillCosts`/`stampInsights` write `term_sessions` and the audit log only). + * Re-entrant: a nested call reuses the outer scope rather than rebuilding, and the previous scope is + * always restored, so an exception can't strand a stale cache on the instance. + */ + private withRowCache(fn: () => T): T { + const outer = this.rowCache; + if (outer) return fn(); // already inside a scope — share it + this.rowCache = {}; + try { + return fn(); + } finally { + this.rowCache = outer; + } + } + /** `getMember`, served from the per-call cache when inside a {@link withRowCache} scope. */ + private lookupMember(id: string): Member | undefined { + const c = this.rowCache; + if (!c) return this.os.team.getMember(id); + if (!c.members) { + c.members = new Map(); + // `listMembers()` selects the same rows `getMember` does (no filter), so this is a complete index. + for (const m of this.os.team.listMembers()) c.members.set(m.id, m); + } + return c.members.get(id); + } + /** The automation row the label/source/authz helpers need, cached the same way. */ + private lookupAutomation(id: string): AutomationLookup | undefined { + const c = this.rowCache; + if (!c) return this.db.prepare('SELECT name, type, created_by FROM automations WHERE id = ?').get(id); + if (!c.autos) { + c.autos = new Map(); + for (const a of this.db.prepare('SELECT id, name, type, created_by FROM automations').all()) c.autos.set(a.id, a); + } + return c.autos.get(id); + } + /** Resolve a session's provenance (+ run-as) to a console-friendly label: member name/email, or * automation — and "Automation · X · as Alice" when it ran as a resolved member. */ private spawnedByLabel(spawnedBy: string | null, runAs?: string | null): string | undefined { - const asMember = runAs ? this.os.team.getMember(runAs) : undefined; + const asMember = runAs ? this.lookupMember(runAs) : undefined; const asSuffix = asMember && asMember.id !== spawnedBy ? ` · as ${asMember.name || asMember.email}` : ''; if (!spawnedBy) return asMember ? `as ${asMember.name || asMember.email}` : undefined; if (spawnedBy.startsWith('automation:')) { - const auto = this.db.prepare('SELECT name FROM automations WHERE id = ?').get<{ name: string }>(spawnedBy.slice('automation:'.length)); + const auto = this.lookupAutomation(spawnedBy.slice('automation:'.length)); return `${auto ? `Automation · ${auto.name}` : 'Automation'}${asSuffix}`; } // Generic chat-router run (`chat:`) — a Slack/Discord message addressed to an agent, no automation. @@ -1183,7 +1251,7 @@ export class TerminalManager { if (spawnedBy.startsWith('ask:')) return `Ask · ${spawnedBy.slice('ask:'.length)}${asSuffix}`; // Async poke-back (`poke:`) — this caller was resumed because a delegate it handed off finished. if (spawnedBy.startsWith('poke:')) return `Poke · ${spawnedBy.slice('poke:'.length)}${asSuffix}`; - const m = this.os.team.getMember(spawnedBy); + const m = this.lookupMember(spawnedBy); return m ? m.name || m.email : spawnedBy; } @@ -1196,7 +1264,7 @@ export class TerminalManager { if (spawnedBy.startsWith('task:')) return 'task'; if (spawnedBy.startsWith('chat:')) return 'chat'; if (spawnedBy.startsWith('automation:')) { - const auto = this.db.prepare('SELECT type FROM automations WHERE id = ?').get<{ type: string }>(spawnedBy.slice('automation:'.length)); + const auto = this.lookupAutomation(spawnedBy.slice('automation:'.length)); switch (auto?.type) { case 'cron': return 'cron'; case 'webhook': return 'webhook'; @@ -1208,14 +1276,14 @@ export class TerminalManager { } } // A bare principal: a console member spawned it manually, or an internal system principal. - return this.os.team.getMember(spawnedBy) ? 'manual' : 'system'; + return this.lookupMember(spawnedBy) ? 'manual' : 'system'; } /** The run-as member's display name (name → email), for the sessions-list Owner filter. Undefined * when the session has no run-as identity or the member no longer exists. */ private runAsLabel(runAs: string | null): string | undefined { if (!runAs) return undefined; - const m = this.os.team.getMember(runAs); + const m = this.lookupMember(runAs); return m ? m.name || m.email : undefined; }