Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).**
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 21 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6759,10 +6759,25 @@ const COMPRESSIBLE = /^(?:text\/|application\/(?:json|javascript|xml)|image\/svg
*/
const GZIP_CACHE = new Map<string, Buffer>();
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;
Expand All @@ -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<string, string> = { 'content-type': contentType, 'cache-control': cacheControl, vary: 'accept-encoding' };
// Revalidation only makes sense for a successful GET; a 304 carries validators and no body.
Expand All @@ -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);
Expand Down Expand Up @@ -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);
});
});
}
Expand Down
88 changes: 78 additions & 10 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SessionRow>();
Expand Down Expand Up @@ -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<SessionRow>();
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<SessionRow>();
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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, Member>; autos?: Map<string, AutomationLookup> } | 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<T>(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<AutomationLookup>(id);
if (!c.autos) {
c.autos = new Map();
for (const a of this.db.prepare('SELECT id, name, type, created_by FROM automations').all<AutomationLookup & { id: string }>()) 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:<agent>`) — a Slack/Discord message addressed to an agent, no automation.
Expand All @@ -1183,7 +1251,7 @@ export class TerminalManager {
if (spawnedBy.startsWith('ask:')) return `Ask · ${spawnedBy.slice('ask:'.length)}${asSuffix}`;
// Async poke-back (`poke:<task>`) — 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;
}

Expand All @@ -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';
Expand All @@ -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;
}

Expand Down
Loading