diff --git a/CHANGELOG.md b/CHANGELOG.md index e950af1..a3f2aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ new version heading in the same commit. ## [Unreleased] +## [0.292.4] — 2026-08-03 +### Changed +- **`GET /api/sessions` clips the `task` prompt IN THE QUERY** instead of `SELECT *`-ing the full text + and throwing it away. The list has always shipped `task` clipped to `LIST_CLIP` (240) — but the server + still pulled every session's *complete* prompt out of SQLite first (**up to 53 KB/row on instawp; 2.1 MB + materialised per poll**) only for `server.ts` to clip it. `listSessions`/`listArchivedSessions` now take + an optional `taskClip`; when set (the list endpoint only) the SELECT projects `substr(task,1,241) AS task` + via a schema-derived column list, so SQLite stops materialising the overflow text. Measured on a live + instawp snapshot (950 rows): task bytes **2.10 MB → 201 KB**, the raw query **5.23 → 3.53 ms (−33%)**, and + full `listSessions(owner)` **13.3 → 11.1 ms (−17%)** per poll, plus ~1.9 MB less string allocation each + 1.5 s tick. Byte-identical output — the existing `clipText` still runs as the ellipsis-preserving finisher + on the ≤241-char string (verified across all 950 rows, 746 of them >240 chars). Internal callers that read + the whole prompt (`sessionsForAgent`, the Cockpit context) pass no clip and keep the full `SELECT *`. + `src/terminal.ts`, `src/server.ts`. Follow-on to #530/#532/#533; the structural fix (pagination) is still open. + ## [0.292.3] — 2026-08-03 ### Fixed - **Cockpit `ask`: "which agent can help me build a feature?" dumped the whole roster instead of diff --git a/package-lock.json b/package-lock.json index 12c4b4f..1ef3bb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.292.3", + "version": "0.292.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.292.3", + "version": "0.292.4", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index f2ab01a..4292b0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.292.3", + "version": "0.292.4", "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 dd3a28a..129ce2b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2513,8 +2513,12 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: // a view that never renders it in full. The console uses `task` in exactly two places: the client-side // search haystack, and a `line-clamp-2` fallback caption when `title` is empty. `LIST_CLIP` chars // serve both. Anything needing the whole prompt reads the session detail, not the list. + // The clip now happens IN THE QUERY (`listSessions(me, LIST_CLIP)` → `substr(task,1,241)`), so SQLite + // stops materialising the full prompt — up to ~53 KB/row — only for us to throw it away: 2.1 MB → 0.2 MB + // per poll, ~33% off the query. `clipText` below is kept as the ellipsis-preserving finisher (it now + // operates on the ≤241-char string, so the wire output is byte-identical to before). if (method === 'GET' && p === '/api/sessions') { - const rows = url.searchParams.get('archived') === '1' ? tm.listArchivedSessions(me) : tm.listSessions(me); + const rows = url.searchParams.get('archived') === '1' ? tm.listArchivedSessions(me, LIST_CLIP) : tm.listSessions(me, LIST_CLIP); return sendJson(res, 200, rows.map((s) => (s.task ? { ...s, task: clipText(s.task, LIST_CLIP) } : s))); } if (method === 'POST' && p === '/api/sessions') { diff --git a/src/terminal.ts b/src/terminal.ts index 285b376..8871048 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -684,19 +684,36 @@ export class TerminalManager { } } + /** term_sessions column names, read once (PRAGMA at boot-stable schema) for the clipped projection. */ + private termCols?: string[]; + /** + * SELECT column list for a sessions query. `undefined` clip → `*` (verbatim, full `task`, for callers + * that read the whole prompt — e.g. `sessionsForAgent`). A numeric clip → every column verbatim EXCEPT + * `task`, which becomes `substr(task,1,clip+1)` so the LIST path stops materialising the full prompt + * (up to ~53 KB/row on instawp; 2.1 MB → 0.2 MB per poll, ~33% off the query) out of SQLite just for + * `server.ts` to clip it to 240. The `+1` lets the downstream `clipText()` still detect truncation and + * keep the ellipsis, so the wire output is byte-identical. + */ + private sessionSelectCols(taskClip?: number): string { + if (!taskClip) return '*'; + const cols = (this.termCols ??= this.db.prepare('PRAGMA table_info(term_sessions)').all<{ name: string }>().map((c) => c.name)); + const n = Math.max(1, Math.floor(taskClip)) + 1; // integer, in-code constant — safe to inline + return cols.map((c) => (c === 'task' ? `substr(task,1,${n}) AS task` : `"${c}"`)).join(', '); + } /** * Sessions visible to `viewer`. owner/admin (or an omitted viewer — internal callers) see all; a * regular member sees only sessions they spawned, plus sessions fired by an automation they created. + * `taskClip` (list endpoint only) fetches `task` pre-truncated to that many chars — see sessionSelectCols. */ - listSessions(viewer?: Member): Session[] { + listSessions(viewer?: Member, taskClip?: number): 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)); + return this.withRowCache(() => this.listSessionsUncached(viewer, taskClip)); } - private listSessionsUncached(viewer?: Member): Session[] { + private listSessionsUncached(viewer?: Member, taskClip?: number): 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(); + const rows = this.db.prepare(`SELECT ${this.sessionSelectCols(taskClip)} FROM term_sessions WHERE archived_at IS NULL ORDER BY created_at DESC`).all(); // Lazy liveness: a row stays 'running' until its tmux session is gone. A running row whose pane // vanished with NO end signal (no `report`/`markEnded`/`stopSession`) died abruptly — kill/OOM/ // reboot — so it's a `crashed`, not a clean end. Grace-period new rows (tmux may not have finished @@ -741,9 +758,9 @@ 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[] { + listArchivedSessions(viewer?: Member, taskClip?: number): Session[] { 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 rows = this.db.prepare(`SELECT ${this.sessionSelectCols(taskClip)} 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) })); });