fix(desktop): a finished agent terminal stops pinning its backlog for the life of the renderer

Every `terminal(background=true)` mints a new process id, and the agent-terminal
stream module never forgot one. `writers` was unregistered when a tab unmounted,
but `backlog`, `lastSnapshots`, `commandHeaders` and `seededCommands` are keyed
by process id and had no eviction at all, so every command an agent ever ran kept
its output until the window closed.

Replaying each process back through `registerAgentTerminalWriter` measures the
retention directly, without depending on GC: 256K chars held per finished process
(~512 KB, JS strings being UTF-16), growing linearly — 100 processes 25.6M chars,
200 51.2M, 400 102.4M (~195 MB). With this change it is flat at 1.79M chars
(~3.4 MB) for every one of those counts.

Bounded the way the other renderer caches already are (`lib/lru-cache`,
`chat/shiki-highlight-cache`): an entry ceiling and a total-character ceiling,
because one busy process can hold as much as twenty quiet ones. `backlog`
insertion order is the LRU clock, so a chunk write re-inserts to move its process
to the tail — a plain re-set keeps the original slot and would let the
oldest-first sweep evict a process that is still streaming.

A process whose terminal is MOUNTED is never evicted: that is on screen, not a
cache. Everything dropped is regenerable — the next `syncAgentTerminalSnapshot`
re-seeds the tab from the registry's rolling tail. The four maps are evicted
together because `lastSnapshots` is the delta fence for `backlog`; dropping one
without the other makes the next snapshot diff against a tail that is gone and
paint only the delta.

Each guarantee is pinned by a regression that fails when that guarantee alone is
reverted: no eviction, evicting a mounted process, losing the recency refresh,
and keeping a stale delta fence.
This commit is contained in:
John Paul Soliva
2026-09-21 14:06:51 +09:00
committed by brooklyn!
parent 5f23cac568
commit 0b9287c8cf
2 changed files with 199 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import {
registerAgentTerminalWriter,
syncAgentTerminalSnapshot,
writeAgentTerminalChunk
} from './agent-terminal-stream'
// Module-level state is process-keyed and shared across cases, so every test uses
// unique procIds.
const CHUNK = 'x'.repeat(64_000)
function runFinishedProc(id: string, chunks = 6): void {
for (let i = 0; i < chunks; i++) {
writeAgentTerminalChunk(id, CHUNK)
}
}
describe('agent terminal retention is bounded', () => {
it('a finished process does not pin its backlog for the life of the renderer', () => {
// 200 background commands, none mounted — the shape that grew to ~93 MB.
for (let i = 0; i < 200; i++) {
runFinishedProc(`bound-${i}`)
}
// The oldest must be gone: reopening it replays nothing until the registry
// snapshot re-seeds it.
let replayed = ''
const stop = registerAgentTerminalWriter('bound-0', chunk => (replayed += chunk))
stop()
expect(replayed).toBe('')
})
it('keeps the most recent process replayable', () => {
for (let i = 0; i < 200; i++) {
runFinishedProc(`recent-${i}`)
}
let replayed = ''
const stop = registerAgentTerminalWriter('recent-199', chunk => (replayed += chunk))
stop()
expect(replayed.length).toBeGreaterThan(0)
})
it('never evicts a process whose terminal is mounted', () => {
// A long-lived mounted tab, then a flood of unrelated finished processes.
const mounted = 'mounted-proc'
registerAgentTerminalWriter(mounted, () => {})
runFinishedProc(mounted)
for (let i = 0; i < 200; i++) {
runFinishedProc(`flood-${i}`)
}
// Still replayable: on-screen state is not a cache.
let replayed = ''
const stop = registerAgentTerminalWriter(mounted, chunk => (replayed += chunk))
stop()
expect(replayed.length).toBeGreaterThan(0)
})
it('recency is the LAST write, not the first', () => {
// Small chunks so the CHARACTER ceiling never fires: this case is purely about
// the entry ceiling and the order eviction walks. `backlog` insertion order is
// the LRU clock, and a plain re-set leaves a key in its ORIGINAL slot — so
// without the delete-then-set refresh, touching a process would not protect it.
const line = 'x'.repeat(64)
for (let i = 0; i < 20; i++) {
writeAgentTerminalChunk(`age-${i}`, `age-${i}-MARK ${line}\n`)
}
// Touch the OLDEST while it is still resident, then overflow the ceiling.
writeAgentTerminalChunk('age-0', 'TOUCHED\n')
for (let i = 20; i < 32; i++) {
writeAgentTerminalChunk(`age-${i}`, `age-${i}-MARK ${line}\n`)
}
const replay = (proc: string) => {
let out = ''
const stop = registerAgentTerminalWriter(proc, chunk => (out += chunk))
stop()
return out
}
// The touched process kept its ORIGINAL content (it was never dropped and
// re-created); its untouched neighbour from the same era is gone.
expect(replay('age-0')).toContain('age-0-MARK')
expect(replay('age-1')).toBe('')
})
it('an evicted process does not diff against a tail it no longer has', () => {
// `lastSnapshots` is the delta fence for `backlog`. If eviction dropped the
// backlog but KEPT the fence, the next snapshot takes the `startsWith(previous)`
// path and writes only the delta — so the screen shows `BBB` where the process
// actually printed `AAABBB`.
const proc = 'fence-proc'
writeAgentTerminalChunk(proc, 'AAA')
syncAgentTerminalSnapshot(proc, 'AAA')
for (let i = 0; i < 200; i++) {
runFinishedProc(`fence-flood-${i}`)
}
let screen = ''
const stop = registerAgentTerminalWriter(proc, chunk => {
screen = chunk.startsWith('\x1bc') ? chunk.slice(2) : screen + chunk
})
syncAgentTerminalSnapshot(proc, 'AAABBB')
stop()
expect(screen).toContain('AAABBB')
})
it('an evicted process re-seeds cleanly from the registry snapshot', () => {
// Eviction drops backlog AND its lastSnapshots delta fence together, so the
// next snapshot must reset the screen rather than diff against a missing tail.
for (let i = 0; i < 200; i++) {
runFinishedProc(`reseed-${i}`)
}
let screen = ''
const stop = registerAgentTerminalWriter('reseed-0', chunk => {
screen = chunk.startsWith('\x1bc') ? chunk.slice(2) : screen + chunk
})
syncAgentTerminalSnapshot('reseed-0', 'recovered output')
stop()
expect(screen).toContain('recovered output')
})
})

View File

@@ -14,6 +14,59 @@ const seededCommands = new Set<string>()
const MAX_BACKLOG = 256_000
// Every `terminal(background=true)` mints a NEW process id, and nothing here ever
// forgot one: a finished process kept its backlog and last snapshot for the life of
// the renderer. Retention grew linearly with the number of commands ever run —
// replaying every process through `registerAgentTerminalWriter` measured 256K chars
// held per process (~512 KB, JS strings being UTF-16), so 100 -> 25.6M chars, 200 ->
// 51.2M, 400 -> 102.4M (~195 MB). One shape of the unbounded renderer growth in #77311.
//
// Bounded like the other renderer caches (`lib/lru-cache`, `chat/shiki-highlight-cache`):
// an entry ceiling AND a total-character ceiling, since one busy process can hold as
// much as twenty quiet ones. A process whose terminal is MOUNTED is never evicted —
// that is on screen, not a cache. Everything dropped is regenerable: the next
// `syncAgentTerminalSnapshot` re-seeds the tab from the registry's rolling tail.
const MAX_TRACKED_PROCS = 24
const MAX_TOTAL_CHARS = 2_000_000
/** Forget one process entirely. The four maps are evicted TOGETHER: `lastSnapshots`
* is the delta fence for `backlog`, so dropping one without the other would make the
* next snapshot diff against a tail that is no longer there. */
function forgetProc(procId: string): void {
backlog.delete(procId)
commandHeaders.delete(procId)
lastSnapshots.delete(procId)
seededCommands.delete(procId)
}
/** Drop the least-recently-written unmounted processes until both ceilings hold.
* `backlog` insertion order is the LRU clock (writers re-insert on every chunk). */
function evictColdProcs(): void {
const total = () => {
let chars = 0
for (const [proc, text] of backlog) {
chars += text.length + (lastSnapshots.get(proc)?.length ?? 0)
}
return chars
}
if (backlog.size <= MAX_TRACKED_PROCS && total() <= MAX_TOTAL_CHARS) {
return
}
for (const proc of [...backlog.keys()]) {
if (backlog.size <= MAX_TRACKED_PROCS && total() <= MAX_TOTAL_CHARS) {
return
}
if (!writers.has(proc)) {
forgetProc(proc)
}
}
}
/** A live agent terminal registers its xterm write and replays the backlog.
* Returns an idempotent unregister. */
export function registerAgentTerminalWriter(procId: string, write: Writer): () => void {
@@ -40,8 +93,12 @@ export function writeAgentTerminalChunk(procId: string, chunk: string): void {
}
const next = (backlog.get(procId) ?? '') + chunk
// delete-then-set moves this process to the tail: a plain re-set would keep its
// original slot and make the oldest-first eviction below pick a live process.
backlog.delete(procId)
backlog.set(procId, next.length > MAX_BACKLOG ? next.slice(-MAX_BACKLOG) : next)
writers.get(procId)?.(chunk)
evictColdProcs()
}
/** Seed the tab with the command immediately, so an agent terminal never opens
@@ -75,6 +132,7 @@ export function syncAgentTerminalSnapshot(procId: string, output: string): void
if (output === previous || output === body || body.endsWith(output)) {
lastSnapshots.set(procId, output)
evictColdProcs()
return
}