From d63b33ea22ab88df57840f46b5e3691460a2df70 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 5 Nov 2026 17:23:09 +0000 Subject: [PATCH 01/12] fix: stop the event log at its size limit and name the cause of an oversized log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host checked the event log size only when it opened the file. A session that printed a lot grew events.jsonl past MAX_EVENT_LOG_SIZE (50 MB), and after that every command that reads the log failed. `record export` printed only "Failed to export session ... as asciicast." and hid the cause. EventLog now never writes the file past its limit: - Ordinary records stop EVENT_LOG_TAIL_RESERVE_BYTES (4096) short of the limit. - The first record that does not fit appends one `marker` record whose label starts with "agent-tty: event log limit reached", inside the reserve. - After that the log stores only the `exit` record. Every other event is acknowledged with the seq of the last stored record and takes no seq, so seq values stay contiguous. The program keeps running and input handlers, waited runs and resize keep working. - EventLog.open takes an optional maxBytes (default and upper bound: MAX_EVENT_LOG_SIZE) so tests can use a small limit. A log that is already over the limit (written by an older host) now throws EventLogTooLargeError. `record export` (EXPORT_ERROR) and offline replay (REPLAY_ERROR, used by snapshot, screenshot, wait and record diff) name the log size and the limit in the message and in details. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_ --- README.md | 2 +- docs/TROUBLESHOOTING.md | 6 + src/cli/commands/README.md | 2 +- src/cli/commands/record-export.ts | 19 +- src/host/README.md | 9 +- src/host/eventLog.ts | 182 +++++++++++++-- src/replay/README.md | 2 +- src/replay/offlineReplay.ts | 15 +- src/storage/README.md | 4 +- src/storage/eventLogCodec.ts | 26 ++- test/integration/record-export.test.ts | 56 ++++- test/unit/commands/record-export.test.ts | 49 +++- test/unit/host/eventLog.test.ts | 277 ++++++++++++++++++++++- test/unit/replay/offlineReplay.test.ts | 29 ++- test/unit/storage/eventLogCodec.test.ts | 16 ++ 15 files changed, 655 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index f54981b0..9565f016 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ The `agent-tty` CLI (`src/cli/main.ts`) starts one detached host process per ses - The CLI JSON envelope is the stable automation surface. `src/protocol/envelope.ts` defines it, and `src/cli/exitCodes.ts` maps known error codes to fixed exit codes. Unmapped codes, such as `SKILL_NOT_FOUND`, exit 1. [src/protocol/envelope.ts:CommandEnvelope] [src/cli/exitCodes.ts:exitCodeForError] - Every `.object(...)` schema in `src/protocol/schemas.ts` and `src/protocol/messages.ts` ends in `.strict()`, so unknown keys fail validation. [src/protocol/schemas.ts:SessionRecordSchema] [src/protocol/messages.ts:RpcRequestSchema] -- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB). `EventLog.append` checks no byte size. It caps only the buffer at `MAX_EVENT_BUFFER_ENTRIES` (250,000 entries), so a live session can grow the file past 50 MB and then fail to reopen. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE] +- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB) with `EventLogTooLargeError`. `EventLog.append` never writes the file past that size. Ordinary records stop `EVENT_LOG_TAIL_RESERVE_BYTES` (4,096 bytes) short of it. The first record that does not fit makes the log append one `marker` record that starts with `EVENT_LOG_LIMIT_LABEL_PREFIX`, and from then on the log stores only the `exit` record. The program keeps running, and the host acknowledges every other event with the `seq` of the last stored record, so later output, input, resizes, signals and markers appear in no snapshot, screenshot or export. `EventLog.append` also caps the buffer at `MAX_EVENT_BUFFER_ENTRIES` (250,000 entries) and throws past it. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [src/host/eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:EventLogTooLargeError] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE] - Session, socket and artifact paths come from guarded builders that reject path separators and `.` or `..` session IDs. [src/storage/sessionPaths.ts:sessionDir] [src/storage/artifactPaths.ts:artifactPath] - `session.json`, the artifact manifest and the Home registry are written through `writeTextFileAtomic`: a temp file with mode `0o600`, then a rename. [src/storage/manifests.ts:writeTextFileAtomic] - `gc` deletes only sessions whose status is collectable (`exited`, `failed` or `destroyed`) after `reconcileSession`, and it re-reads the manifest before each delete. [src/cli/commands/gc.ts:gcSessions] [src/protocol/sessionStatusPolicy.ts:getSessionStatusPolicy] diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 304c6533..d5dc1f18 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -96,3 +96,9 @@ If it still appears: - The program running inside the session set the marker itself. To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh's styled default) when creating the session. + +## A Recording Ends Early, or an Export Names the Event Log Limit + +Each session records its terminal output in `events.jsonl`, which holds at most 52,428,800 bytes (50 MB). A session that prints more stops recording at that size. The host writes one marker that begins `agent-tty: event log limit reached`, and after it records only the program's exit. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker, and a `wait` for text that appears later times out. An asciicast export ends with the marker. To keep a long session recordable, send its verbose output to a file instead of the terminal. + +A command that fails with a message such as `its event log is 81336613 bytes, over the 52428800-byte limit that commands can read` names a log that an older version of agent-tty wrote past the limit. No command can read that log. `inspect` and `destroy` still work on the session. diff --git a/src/cli/commands/README.md b/src/cli/commands/README.md index f2863672..7c817387 100644 --- a/src/cli/commands/README.md +++ b/src/cli/commands/README.md @@ -34,7 +34,7 @@ This directory holds one handler module per agent-tty CLI command. `src/cli/main - `wait` rejects a mix of legacy flags (`--exit`, `--idle-ms`) and render flags. `--text` and `--regex` are mutually exclusive. The default timeout is `DEFAULT_WAIT_TIMEOUT_MS` (30,000 ms), which the `main.ts` help text names. An `--idle-ms` at or above the effective timeout (explicit or default), or a `--screen-stable-ms` whose `screenStableMinimumMs` is not shorter than it, throws `INVALID_DURATION`. `--timeout 0` skips the check. [wait.ts:runWaitCommand] [wait.ts:assertWindowFitsTimeout] [main.ts:main] - A timed-out `wait` throws `WAIT_TIMEOUT` with the wait result attached, so its error envelope keeps `result`. Without `--json`, and only when the wait lasted its whole timeout (`timeoutMs > 0`, measured by the CLI), the message names the timeout and, for the default, how to change it. With `--json`, the message stays `Wait timed out before its condition was met.` [wait.ts:throwIfTimedOut] [wait.ts:timeoutMessage] - `snapshot` and `screenshot` try the live RPC only when the manifest status is `running`. They fall back to offline replay only on `HOST_UNREACHABLE` and rethrow other errors. [snapshot.ts:runSnapshotCommand] [screenshot.ts:runScreenshotCommand] -- `record export` accepts only the formats in `RecordExportFormatSchema` (`asciicast`, `webm`). [record-export.ts:runRecordExportCommand] +- `record export` accepts only the formats in `RecordExportFormatSchema` (`asciicast`, `webm`). An event log over the size limit fails with `EXPORT_ERROR`, a message that names the log size and the limit, and `eventLogBytes` and `maxEventLogBytes` in `details`. [record-export.ts:runRecordExportCommand] - `batch` parses the whole plan with `parseBatchPlan` before it resolves the session target. [batch.ts:runBatchCommand] - `create` polls readiness up to `READINESS_MAX_ATTEMPTS` (50) times, `READINESS_POLL_INTERVAL_MS` (100 ms) apart. [create.ts:waitForReadyHost] - `doctor` reports the `node-runtime` check as failed when the Node version is outside the `engines.node` range of `package.json`. It keeps running the other checks, reports `ok: false` and sets `process.exitCode = 1`. [doctor.ts:runNodeRuntimeCheck] [doctor.ts:runDoctorCommand] diff --git a/src/cli/commands/record-export.ts b/src/cli/commands/record-export.ts index 786d69b3..c470991c 100644 --- a/src/cli/commands/record-export.ts +++ b/src/cli/commands/record-export.ts @@ -14,7 +14,10 @@ import { generateWebmExport, type WebmExportResult, } from '../../export/webm.js'; -import { readEventLogRecords } from '../../storage/eventLogCodec.js'; +import { + EventLogTooLargeError, + readEventLogRecords, +} from '../../storage/eventLogCodec.js'; import { hashProfile, resolveProfile } from '../../renderer/profiles.js'; import { CliError } from '../errors.js'; import { checkedSessionDir } from '../sessionGuards.js'; @@ -444,6 +447,20 @@ export async function runRecordExportCommand( throw error; } + if (error instanceof EventLogTooLargeError) { + throw makeCliError(ERROR_CODES.EXPORT_ERROR, { + message: `Failed to export session "${options.sessionId}" as ${format}: its event log is ${String(error.size)} bytes, over the ${String(error.maxSize)}-byte limit that commands can read.`, + details: { + sessionId: options.sessionId, + format, + out: options.out, + eventLogBytes: error.size, + maxEventLogBytes: error.maxSize, + }, + cause: error, + }); + } + throw makeCliError(ERROR_CODES.EXPORT_ERROR, { message: `Failed to export session "${options.sessionId}" as ${format}.`, details: { diff --git a/src/host/README.md b/src/host/README.md index b8181e4a..15425f98 100644 --- a/src/host/README.md +++ b/src/host/README.md @@ -30,8 +30,8 @@ - `captureHost(pid)` returns a `HostRecord` (`version`, `pid`, `bootId`, `startTicks`, `pidNamespace`, `timeNamespace`, `machineId`) on Linux when `/proc` shows the process and the boot id, else `null`. `machineId` is the first of `/etc/machine-id` and `/var/lib/dbus/machine-id` that holds 32 hex characters, else `null`. A record without `timeNamespace` and `machineId`, which a build between #260 and #267 wrote, still reads, and only the checks of those two fields are skipped. `writeHostRecord(sessionDirectory, record)` writes it as `host.json`, and `readHostRecord(sessionDirectory)` returns it, or `null` when it is missing or unreadable. `hostRecordError(sessionDirectory)` returns the error when the file exists but its read or its check fails, else `null`, so that the `HOST_TIMEOUT` of `destroySession` for an unshared group names `host.json` and that error instead of another pid namespace, and says to make the file readable only for EACCES or EPERM. `judgeHost(pid, record)` returns `dead` only when no process has the pid, a zombie has it, or the record names another boot id or start time, `alive` when both match, and `unknown` otherwise: also off Linux for a pid that exists, for a CLI in another pid namespace or on another machine than the record names, and, instead of a start time mismatch, for a CLI in another time namespace. [hostRecord.ts:captureHost] [hostRecord.ts:writeHostRecord] [hostRecord.ts:readHostRecord] [hostRecord.ts:hostRecordError] [hostRecord.ts:judgeHost] - `SessionState` holds the host's in-memory `SessionRecord` (type from `src/protocol/schemas.ts`). Methods: `snapshot`, `setChildPid`, `setDimensions`, `setFailureOrigin`, `requestDestroy`, `recordDestroyed` and `recordExit`. [sessionState.ts:SessionState] - `createSerialManifestWriter(path, snapshot, write?)` returns a function that writes `snapshot()` to `path`. The default `write` is `writeManifest` (`src/storage/manifests.ts`). `runHost` sends every manifest write through one such function. [manifestWriter.ts:createSerialManifestWriter] [hostMain.ts:runHost] -- `EventLog` is the append-only JSONL event log with an in-memory buffer. Methods: `static open(filePath)`, `append(type, payload)` (returns the new `seq`), `startAppend('input_run', payload)` (returns `{ seq, written }` without an `await`), `getEvents`, `getEventsSince(afterSeq)`, `readAll` and `close`. [eventLog.ts:EventLog] -- `MAX_EVENT_BUFFER_ENTRIES` is 250,000. `statEventLogBytes(filePath)` returns the file size or `undefined` when the file does not exist. `countEventLogEntries(filePath)` counts non-blank lines. [eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [eventLog.ts:statEventLogBytes] [eventLog.ts:countEventLogEntries] +- `EventLog` is the append-only JSONL event log with an in-memory buffer. Methods: `static open(filePath, options?)`, `append(type, payload)` (returns the `seq` of the stored record), `startAppend('input_run', payload)` (returns `{ seq, written }` without an `await`), `getEvents`, `getEventsSince(afterSeq)`, `readAll` and `close`. [eventLog.ts:EventLog] +- `MAX_EVENT_BUFFER_ENTRIES` is 250,000. `EVENT_LOG_TAIL_RESERVE_BYTES` is 4,096. `EVENT_LOG_LIMIT_LABEL_PREFIX` starts the label of the stop marker. `statEventLogBytes(filePath)` returns the file size or `undefined` when the file does not exist. `countEventLogEntries(filePath)` counts non-blank lines. [eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [eventLog.ts:statEventLogBytes] [eventLog.ts:countEventLogEntries] - `createMouseRpcHandler(deps)` returns the `mouse` handler `(request, rendererName)`, which resolves a `MouseResult` (`reported`, `bytesWritten`, `seq`). `MouseRpcDependencies` injects the renderer manager (in `runHost` a second `HostRendererManager` that only mouse input uses, so a render on another renderer or profile never replaces the encoder's backend and its same-cell motion state), the profile, the libghostty-vt probe, the replay loader, the commandable check, the PTY writer and the `input_mouse` append. The handler keeps the held buttons between requests. [mouseRpc.ts:createMouseRpcHandler] [mouseRpc.ts:MouseRpcDependencies] - `prepareHeldMouseButtons(currentHeld, input)` returns the next held set, `anyButtonPressed` and the `dragButton` that a button-free `move` reports, without changing `currentHeld`. [mouseInput.ts:prepareHeldMouseButtons] - `buildReplayInput(sessionId, manifest, events, ...)` builds a validated `ReplayInput` (type from `src/renderer/types.ts`) with the initial size and target sequence number. [replay.ts:buildReplayInput] @@ -44,7 +44,8 @@ ## Invariants -- `EventLog.open` derives the next `seq` from the last record (0 for an empty file), using `assertEventLogSize` and `parseEventLogContent` from `src/storage/eventLogCodec.ts`. `append` asserts that the new `seq` equals the buffer length and validates the record with `EventRecordSchema`, so sequence numbers stay contiguous. [eventLog.ts:EventLog] +- `EventLog.open` derives the next `seq` from the last record (0 for an empty file), using `assertEventLogSize` and `parseEventLogContent` from `src/storage/eventLogCodec.ts`. `options.maxBytes` sets the size limit of the file. It defaults to `MAX_EVENT_LOG_SIZE`, must exceed `EVENT_LOG_TAIL_RESERVE_BYTES` and cannot exceed `MAX_EVENT_LOG_SIZE`, so the writer never passes what readers accept. Tests inject a small limit. `append` asserts that the new `seq` equals the buffer length and validates the record with `EventRecordSchema`, so sequence numbers stay contiguous. [eventLog.ts:EventLog] [eventLog.ts:EventLogOptions] +- At the size limit `EventLog.append` stops the log. Ordinary records may fill the file up to the limit minus `EVENT_LOG_TAIL_RESERVE_BYTES`. The first record that does not fit makes the log append one `marker` record with the label `agent-tty: event log limit reached ( bytes); later events are not recorded`, inside the reserve. From then on the log stores only the `exit` record, which also fits in the reserve, so the file never passes the limit. A record that the log does not store takes no `seq`. `append` resolves with the `seq` of the last stored record, and `startAppend` returns it as `seq`. Handlers, waited runs and `resize` therefore keep working, and the program keeps running. Its later output, input, resizes, signals and markers are in no snapshot, screenshot or export, because those replay the log. A live `wait` for text that appears after the stop times out. Once a write has failed, a dropped append rejects like any other. The buffer limit `MAX_EVENT_BUFFER_ENTRIES` is separate and still throws. [eventLog.ts:EventLog] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] - `EventLog.append` validates each payload with a strict Zod schema for its event type. It throws when the buffer holds `MAX_EVENT_BUFFER_ENTRIES` records, after `close()`, and after an earlier write failed (the write queue stays rejected). A failed write removes the failed records from the buffer. [eventLog.ts:EventLog] - `SessionState` enforces its transitions: `setChildPid` and `setDimensions` need status `running`, and the child PID is set once. `SessionState` has no method that sets `hostPid`. `requestDestroy` moves `running`, `exited` or `failed` to `destroying`. `recordDestroyed` needs `destroying`. `recordExit` needs `running` or `exiting` and no earlier exit. [sessionState.ts:SessionState] - `runHost` asserts that the manifest `sessionId` matches its argument and that the manifest `hostPid` equals its own `process.pid`. `waitForManifestFromCreate` asserts that `creatorPid` is a positive integer. [hostMain.ts:runHost] [hostMain.ts:waitForManifestFromCreate] @@ -83,7 +84,7 @@ - `runHost` checks for the `create-failed` marker with `existsSync` before it spawns the program, and returns when it finds it. It checks again right after its start-up manifest write, before it listens, and then shuts down. From there on, `watchCreateFailed` checks for the marker every `CREATE_FAILED_POLL_INTERVAL_MS` (100 ms) while the creator process lives and once after it exited (off Windows, a `process.ppid` other than the creator's pid means that the creator exited; on Windows, a liveness probe that throws counts as exited; the watch stops after `CREATE_FAILED_WATCH_MAX_MS`, 60 s, in any case), and starts the shutdown when it finds the marker. [hostMain.ts:runHost] - `runHost` rethrows a PTY-ingestion, event-log append, exit-handling or shutdown error in `process.nextTick`. The uncaught exception crashes the host process. [hostMain.ts:rethrowAsync] - `resolveHostRendererName` reads `process.env.AGENT_TTY_HOST_RENDERER` and `process.env.AGENT_TTY_RENDERER`. [hostMain.ts:resolveHostRendererName] -- `EventLog.open` opens the file in append mode and runs `chmod 0o600`. `append` writes with `fileHandle.appendFile`, one call at a time. `close` calls `fileHandle.sync()` and then closes the file. [eventLog.ts:EventLog] +- `EventLog.open` opens the file in append mode and runs `chmod 0o600`. `append` writes with `fileHandle.appendFile`, one call at a time, and never takes the file past its size limit. `close` calls `fileHandle.sync()` and then closes the file. [eventLog.ts:EventLog] - `RpcServer.listen` uses `net.createServer`, listens on the socket path and runs `chmod 0o600` on the socket. `close` closes the server and unlinks the socket. [rpcServer.ts:RpcServer] - `sendRpc` connects with `net.connect` and writes one JSON line. [rpcClient.ts:sendRpc] - `HostRendererManager` boots, replays and disposes backends from the injected factory. In `runHost` that factory is `createRendererBackend` (`src/renderer/registry.ts`), with the session's creation size as the libghostty-vt initial size. `screenshotPath` creates `/screenshots` with `mkdirSync`. [renderer.ts:HostRendererManager] diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index 96b8c351..ff0514bc 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -19,6 +19,7 @@ import { } from '../protocol/schemas.js'; import { assertEventLogSize, + MAX_EVENT_LOG_SIZE, parseEventLogContent, } from '../storage/eventLogCodec.js'; import { invariant } from '../util/assert.js'; @@ -118,6 +119,31 @@ export interface StartedAppend { */ export const MAX_EVENT_BUFFER_ENTRIES = 250_000; +/** + * Room that ordinary records leave free at the end of the file. The stop + * record and the `exit` record are written inside it, so the file never + * passes its limit. Both records are far smaller than this. + */ +export const EVENT_LOG_TAIL_RESERVE_BYTES = 4096; + +/** Start of the label of the marker that records why the log stopped. */ +export const EVENT_LOG_LIMIT_LABEL_PREFIX = + 'agent-tty: event log limit reached'; + +export interface EventLogOptions { + /** + * Size limit of the file in bytes. Defaults to `MAX_EVENT_LOG_SIZE`, which + * readers enforce, and cannot exceed it. Tests inject a small limit. + */ + readonly maxBytes?: number; +} + +interface PreparedRecord { + readonly record: EventRecord; + readonly line: string; + readonly bytes: number; +} + function assertFilePath(filePath: string): void { invariant(filePath.length > 0, 'filePath must be a non-empty string'); } @@ -255,16 +281,37 @@ export class EventLog { private eventBuffer: EventRecord[] = []; + // Bytes of the file once every queued write has landed. A failed write does + // not give its bytes back: the poisoned writeQueue rejects every later + // append anyway. + private fileBytes: number; + + // Set once the log has stopped taking records. See `queueAppend`. + private stopped = false; + private constructor( filePath: string, private readonly fileHandle: FileHandle, private nextSeq: number, + fileBytes: number, + private readonly maxBytes: number, eventBuffer: EventRecord[] = [], private isClosed = false, ) { invariant(filePath.length > 0, 'filePath must be a non-empty string'); invariant(Number.isInteger(nextSeq), 'nextSeq must be an integer'); invariant(nextSeq >= 0, 'nextSeq must be non-negative'); + invariant( + Number.isInteger(fileBytes) && fileBytes >= 0, + 'fileBytes must be a non-negative integer', + ); + invariant( + Number.isInteger(maxBytes) && + maxBytes > EVENT_LOG_TAIL_RESERVE_BYTES && + maxBytes <= MAX_EVENT_LOG_SIZE, + 'maxBytes must leave room beyond the tail reserve and must not exceed MAX_EVENT_LOG_SIZE', + ); + this.fileBytes = fileBytes; invariant( nextSeq === eventBuffer.length, 'nextSeq must match buffered event count', @@ -272,8 +319,12 @@ export class EventLog { this.eventBuffer = eventBuffer; } - static async open(filePath: string): Promise { + static async open( + filePath: string, + options: EventLogOptions = {}, + ): Promise { assertFilePath(filePath); + const maxBytes = options.maxBytes ?? MAX_EVENT_LOG_SIZE; const fileHandle = await open(filePath, 'a'); try { @@ -294,7 +345,14 @@ export class EventLog { invariant(nextSeq >= 0, 'derived next seq must be non-negative'); } - return new EventLog(filePath, fileHandle, nextSeq, eventBuffer); + return new EventLog( + filePath, + fileHandle, + nextSeq, + fileStats.size, + maxBytes, + eventBuffer, + ); } catch (error) { await fileHandle.close(); throw error; @@ -341,6 +399,24 @@ export class EventLog { return this.queueAppend(type, payload); } + /** + * Appends a record while the log has room, and otherwise stops the log: + * + * - Ordinary records may fill the file up to `maxBytes` minus + * `EVENT_LOG_TAIL_RESERVE_BYTES`. + * - The first record that does not fit stops the log. The log appends one + * stop marker inside the reserve (see `recordStop`), and from then on it + * records nothing but the `exit` record, which also fits in the reserve. + * - A stopped log raises no error. It acknowledges a record that it does + * not store with the `seq` of the last stored record, so input handlers, + * run completion and resize keep working, and the program keeps running. + * Its later output, input, resizes, signals and markers are absent from + * the log, and so from every snapshot, screenshot and export, which + * replay the log. + * + * `seq` values stay contiguous because a record that is not stored never + * takes one. + */ private queueAppend( type: EventLogEventType, payload: EventLogPayload, @@ -348,45 +424,113 @@ export class EventLog { invariant(!this.isClosed, 'cannot append to a closed event log'); const validatedPayload = validatePayload(type, payload); + if (this.stopped && type !== 'exit') { + // A runaway program keeps printing: do not build records to drop. + return this.acknowledgeUnstored(); + } + + const prepared = this.prepareRecord(type, validatedPayload); + if (this.hasRoomFor(type, prepared.bytes)) { + return this.enqueue(prepared); + } + + if (!this.stopped) { + this.recordStop(); + } + return this.acknowledgeUnstored(); + } + + private prepareRecord( + type: EventLogEventType, + validatedPayload: EventLogPayload, + ): PreparedRecord { const seq = this.nextSeq; - invariant( - seq === this.nextSeq, - 'event seq must match the expected next seq', - ); invariant(seq >= 0, 'event seq must be non-negative'); - this.nextSeq += 1; - const record = { + const parsedRecord = EventRecordSchema.safeParse({ seq, ts: new Date().toISOString(), type, payload: validatedPayload, + }); + invariant( + parsedRecord.success, + 'event record must match EventRecordSchema', + ); + + const line = `${JSON.stringify(parsedRecord.data)}\n`; + return { + record: parsedRecord.data, + line, + bytes: Buffer.byteLength(line, 'utf8'), }; + } + + private hasRoomFor(type: EventLogEventType, bytes: number): boolean { + const room = this.maxBytes - this.fileBytes; + if (type === 'exit') { + // The last record of a session may use the reserve. + return bytes <= room; + } + + return bytes <= room - EVENT_LOG_TAIL_RESERVE_BYTES; + } + + /** + * Stops the log and records why, once. The marker is the only existing + * event type that fits: it carries a label, replayers ignore it, and the + * asciicast export shows it as a marker. It goes into the reserve, so it + * fits unless a log that this host did not write already filled the + * reserve before it opened. + */ + private recordStop(): void { + this.stopped = true; + + const stop = this.prepareRecord('marker', { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(this.maxBytes)} bytes); later events are not recorded`, + }); + if (stop.bytes > this.maxBytes - this.fileBytes) { + return; + } + // A failed write reaches the caller through the poisoned writeQueue. + this.enqueue(stop).written.catch(() => undefined); + } + + private acknowledgeUnstored(): StartedAppend { + const seq = this.nextSeq - 1; invariant( - record.seq === seq, - 'event record seq must match the reserved seq', + seq >= 0, + 'a log that does not store a record must hold an earlier record', ); - const parsedRecord = EventRecordSchema.safeParse(record); + // Chained on the writeQueue so that the caller sees an earlier failed + // write, and so that the seq is durable once this settles. + return { seq, written: this.writeQueue.then(() => seq) }; + } + + private enqueue({ record, line, bytes }: PreparedRecord): StartedAppend { + const seq = record.seq; invariant( - parsedRecord.success, - 'event record must match EventRecordSchema', + seq === this.nextSeq, + 'event seq must match the expected next seq', ); - if (this.eventBuffer.length >= MAX_EVENT_BUFFER_ENTRIES) { - this.nextSeq = seq; - } invariant( this.eventBuffer.length < MAX_EVENT_BUFFER_ENTRIES, `event buffer exceeds ${String(MAX_EVENT_BUFFER_ENTRIES)} entries; session event log is too large`, ); invariant( - parsedRecord.data.seq === this.eventBuffer.length, + seq === this.eventBuffer.length, 'event record seq must match the buffered event count', ); - this.eventBuffer.push(parsedRecord.data); + invariant( + this.fileBytes + bytes <= this.maxBytes, + 'event log append must stay inside the size limit', + ); + this.nextSeq += 1; + this.eventBuffer.push(record); + this.fileBytes += bytes; - const line = `${JSON.stringify(parsedRecord.data)}\n`; const writePromise = this.writeQueue.then(async () => { try { await this.fileHandle.appendFile(line, 'utf8'); diff --git a/src/replay/README.md b/src/replay/README.md index 087d5b7e..849da9d7 100644 --- a/src/replay/README.md +++ b/src/replay/README.md @@ -14,7 +14,7 @@ - `run` must be a function. `sessionDir` must be a non-empty absolute path. The session ID is `basename(sessionDir)` and must be non-empty. [offlineReplay.ts:withOfflineReplayRenderer] [offlineReplay.ts:getSessionId] - The manifest must exist. A missing manifest fails an `invariant`. [offlineReplay.ts:withOfflineReplayRenderer] - The default renderer is `DEFAULT_RENDERER_NAME`, checked with `resolveRendererName` (both from `src/renderer/names.ts`). The default profile is `'reference-dark'`, resolved with `resolveProfile` (from `src/renderer/profiles.ts`). The default backend factory is `createRendererBackend` (from `src/renderer/registry.ts`). [offlineReplay.ts:withOfflineReplayRenderer] [offlineReplay.ts:createBackend] -- A missing event log counts as zero events. Other event-log read errors, replay-input errors, profile errors and boot or replay errors become a `CliError` with code `REPLAY_ERROR` (from `src/protocol/errors.ts`). [offlineReplay.ts:readOfflineReplayInput] [offlineReplay.ts:withOfflineReplayRenderer] +- A missing event log counts as zero events. Other event-log read errors, replay-input errors, profile errors and boot or replay errors become a `CliError` with code `REPLAY_ERROR` (from `src/protocol/errors.ts`). A log over the size limit gives a message that names its size and the limit, with `eventLogPath`, `eventLogBytes` and `maxEventLogBytes` in `details`. [offlineReplay.ts:readOfflineReplayInput] [offlineReplay.ts:withOfflineReplayRenderer] - `backend.replayTo` always runs, also for an empty Event Log (`targetSeq` -1). [offlineReplay.ts:withOfflineReplayRenderer] - After the backend is created, `backend.dispose()` runs in a `finally` block, on success and on failure. [offlineReplay.ts:withOfflineReplayRenderer] diff --git a/src/replay/offlineReplay.ts b/src/replay/offlineReplay.ts index a19538ff..90548051 100644 --- a/src/replay/offlineReplay.ts +++ b/src/replay/offlineReplay.ts @@ -1,7 +1,10 @@ import { basename, isAbsolute, resolve } from 'node:path'; import { buildReplayInput } from '../host/replay.js'; -import { readEventLogRecords } from '../storage/eventLogCodec.js'; +import { + EventLogTooLargeError, + readEventLogRecords, +} from '../storage/eventLogCodec.js'; import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; import type { EventRecord, SessionRecord } from '../protocol/schemas.js'; import type { RendererBackend } from '../renderer/backend.js'; @@ -99,6 +102,16 @@ async function readOfflineReplayInput( } catch (error) { if (isEnoentError(error)) { events = []; + } else if (error instanceof EventLogTooLargeError) { + throw makeCliError(ERROR_CODES.REPLAY_ERROR, { + message: `Failed to read event log: ${eventLogFile} is ${String(error.size)} bytes, over the ${String(error.maxSize)}-byte limit that commands can read.`, + details: { + eventLogPath: eventLogFile, + eventLogBytes: error.size, + maxEventLogBytes: error.maxSize, + }, + cause: error, + }); } else { throw makeCliError(ERROR_CODES.REPLAY_ERROR, { message: `Failed to read event log: ${eventLogFile}`, diff --git a/src/storage/README.md b/src/storage/README.md index b8ac27df..ae15134b 100644 --- a/src/storage/README.md +++ b/src/storage/README.md @@ -17,7 +17,7 @@ - `resolveExplicitOutputPath(outputPath, sessionsRoot)` resolves an `--out` path for `record export` and `screenshot`. A relative path resolves against `process.cwd()`. Missing parent folders are created one at a time below the nearest existing ancestor. Before the first `mkdir`, the real path of the folders to create is checked against `sessionsRoot`, and each created folder must then resolve to its own path. The parent folder must be a directory, and be writable and searchable (`W_OK | X_OK`). The result's `path` uses the parent's real path, and `parentDev` and `parentIno` hold the parent folder's device and inode. A result inside the real path of `sessionsRoot` (when it does not exist, the real path of its nearest existing ancestor, joined with the missing rest) throws `EXPORT_ERROR`. `assertOutputParentUnchanged(output)` throws `EXPORT_ERROR` when the parent's real path, device or inode no longer match, or when the parent can no longer be read (for example, it was removed). `copyFileReplacing(sourcePath, output)` copies to `.tmp-`, runs that check, and renames the temp file over `path`. `record export --out` runs the same check before its rename. Node has no `openat` or `renameat`, so a swap of the parent or an ancestor between the check and the rename is not caught. A swap after the temp write leaves the temp file in the original folder, which now has another path, and a swap that is undone before the check leaves it in the other folder. Neither can replace a file there. For the same reason, a swap of an ancestor between the real-path check and a `mkdir` can create one empty folder in the other place before the next check stops the command. [outputPath.ts:resolveExplicitOutputPath] [outputPath.ts:assertOutputParentUnchanged] [outputPath.ts:copyFileReplacing] - `readValidatedJsonFile`, `writeValidatedJsonFile`, `writeValidatedJsonFileSync` and `writeTextFileAtomic`, with their option interfaces, are the generic validated JSON and text file helpers. [manifests.ts:readValidatedJsonFile] [manifests.ts:writeValidatedJsonFile] [manifests.ts:writeValidatedJsonFileSync] [manifests.ts:writeTextFileAtomic] - `readManifest`, `readManifestIfExists` and `writeManifest` read and write a `SessionRecord` (schema from `src/protocol/schemas.ts`). `readManifestIfExists` returns `null` when the file does not exist. [manifests.ts:readManifest] [manifests.ts:readManifestIfExists] [manifests.ts:writeManifest] -- `MAX_EVENT_LOG_SIZE` is 52,428,800 bytes (50 MB). `assertEventLogSize(size)` checks a size against it. [eventLogCodec.ts:MAX_EVENT_LOG_SIZE] [eventLogCodec.ts:assertEventLogSize] +- `MAX_EVENT_LOG_SIZE` is 52,428,800 bytes (50 MB). `assertEventLogSize(size)` checks a size against it and throws `EventLogTooLargeError` (with `size` and `maxSize`) above it. The host writer stops at this size (see `src/host/README.md`), so only a log from an older host can exceed it. [eventLogCodec.ts:MAX_EVENT_LOG_SIZE] [eventLogCodec.ts:assertEventLogSize] [eventLogCodec.ts:EventLogTooLargeError] - `parseEventLogContent(text)` parses JSONL text into `EventRecord[]`. `validateEventRecords(values)` validates records that are already loaded. `readEventLogRecords(filePath)` checks the file size, reads the file and parses it. `EventRecordSchema` comes from `src/protocol/schemas.ts`. [eventLogCodec.ts:parseEventLogContent] [eventLogCodec.ts:validateEventRecords] [eventLogCodec.ts:readEventLogRecords] - `ArtifactEntrySchema` and `ArtifactManifestSchema`, with types `ArtifactEntry` and `ArtifactManifest`, describe `/artifacts/manifest.json`. [artifactManifest.ts:ArtifactEntrySchema] [artifactManifest.ts:ArtifactManifestSchema] - `readArtifactManifest(sessionDir)` returns the manifest, or an empty version 1 manifest when the file does not exist. `writeArtifactManifest` validates and writes it. [artifactManifest.ts:readArtifactManifest] [artifactManifest.ts:writeArtifactManifest] @@ -35,7 +35,7 @@ - `sessionDir` requires an absolute `home` and a session ID that is not empty, `.` or `..` and has no `/` or `\`. The result must be a direct child of `/sessions`, and `session.json` and `events.jsonl` must be direct children of the session directory. [sessionPaths.ts:sessionDir] [sessionPaths.ts:manifestPath] - `socketPath` asserts that the socket directory is a direct child of the socket root, that the two IDs have 8 and 12 characters, and that the session directory's parent is `/sessions`. [sessionPaths.ts:socketPath] - `artifactPath` rejects a file name that is empty or has `/` or `\`, and asserts that the result is directly inside `/artifacts`. File-name builders replace unsafe characters in the profile name with `-`. `screenshotFilename`, `snapshotFilename` and `recordingFilename` require an integer `seq` of at least -1, and `videoFilename` requires a non-negative integer `seq`. [artifactPaths.ts:artifactPath] [artifactPaths.ts:screenshotFilename] [artifactPaths.ts:recordingFilename] -- `assertEventLogSize` requires an integer from 0 to `MAX_EVENT_LOG_SIZE`. `readEventLogRecords` checks the size with `stat` before it reads the file. [eventLogCodec.ts:assertEventLogSize] [eventLogCodec.ts:readEventLogRecords] +- `assertEventLogSize` requires an integer from 0 to `MAX_EVENT_LOG_SIZE`, and an integer above it throws `EventLogTooLargeError`, not an `AssertionError`. `readEventLogRecords` checks the size with `stat` before it reads the file. [eventLogCodec.ts:assertEventLogSize] [eventLogCodec.ts:readEventLogRecords] - `parseEventLogContent` and `validateEventRecords` require each record to match `EventRecordSchema`, the first `seq` to be 0, and each next `seq` to be the previous one plus 1. Blank lines are skipped. [eventLogCodec.ts:parseEventLogContent] [eventLogCodec.ts:validateEventRecords] - Read and write paths in `manifests.ts` must be non-empty and absolute. Invalid JSON and schema failures throw `MANIFEST_VALIDATION_ERROR`. Other read errors throw `STORAGE_READ_ERROR`. Write failures throw `STORAGE_WRITE_ERROR`, except that `writeManifest`, `writeDestroyMarker` and `writeCreateFailedMarker` throw `SESSION_NOT_FOUND` when the session directory is gone. `writeDestroyMarker` and `writeCreateFailedMarker` share `writeEmptyMarker`, which writes an empty file through a temp file and a rename, like `writeManifest`, so it replaces a symlink at that path instead of writing through it, and it creates no folder. [manifests.ts:readValidatedJsonFile] [manifests.ts:writeTextFileAtomic] [manifests.ts:writeManifest] [manifests.ts:writeDestroyMarker] [manifests.ts:writeCreateFailedMarker] - `ArtifactEntrySchema` and `ArtifactManifestSchema` are `.strict()`. `kind` is `screenshot`, `snapshot`, `recording` or `video`. `capturedAtSeq` is an integer of at least -1 (-1 for a capture before the first event). The manifest `version` is the literal `1`. The manifest and entry `sessionId` must equal the session directory name. [artifactManifest.ts:ArtifactEntrySchema] [artifactManifest.ts:ArtifactManifestSchema] diff --git a/src/storage/eventLogCodec.ts b/src/storage/eventLogCodec.ts index 176d79a8..174bd1a8 100644 --- a/src/storage/eventLogCodec.ts +++ b/src/storage/eventLogCodec.ts @@ -5,13 +5,31 @@ import { invariant } from '../util/assert.js'; export const MAX_EVENT_LOG_SIZE = 50 * 1024 * 1024; +/** + * A log file larger than the limit. This is a state of the file, not a bug in + * the caller, so commands can catch it and name the cause. The host writer + * never produces such a file: it stops appending at the limit. + */ +export class EventLogTooLargeError extends Error { + public readonly size: number; + public readonly maxSize: number; + + public constructor(size: number, maxSize: number) { + super( + `event log file exceeds size limit (${String(size)} bytes, max ${String(maxSize)})`, + ); + this.name = 'EventLogTooLargeError'; + this.size = size; + this.maxSize = maxSize; + } +} + export function assertEventLogSize(size: number): void { invariant(Number.isInteger(size), 'event log size must be an integer'); invariant(size >= 0, 'event log size must be non-negative'); - invariant( - size <= MAX_EVENT_LOG_SIZE, - `event log file exceeds size limit (${String(size)} bytes, max ${String(MAX_EVENT_LOG_SIZE)})`, - ); + if (size > MAX_EVENT_LOG_SIZE) { + throw new EventLogTooLargeError(size, MAX_EVENT_LOG_SIZE); + } } function parseEventLogLine(line: string, lineNumber: number): EventRecord { diff --git a/test/integration/record-export.test.ts b/test/integration/record-export.test.ts index b015366f..2e0828b0 100644 --- a/test/integration/record-export.test.ts +++ b/test/integration/record-export.test.ts @@ -1,5 +1,12 @@ import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, readFile, realpath } from 'node:fs/promises'; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + truncate, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import process from 'node:process'; @@ -7,7 +14,8 @@ import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { readArtifactManifest } from '../../src/storage/artifactManifest.js'; -import { sessionDir } from '../../src/storage/sessionPaths.js'; +import { MAX_EVENT_LOG_SIZE } from '../../src/storage/eventLogCodec.js'; +import { eventLogPath, sessionDir } from '../../src/storage/sessionPaths.js'; import { cleanupHome, createSession, @@ -520,6 +528,50 @@ describe('record export integration', { timeout: 120_000 }, () => { expect(await readFile(out, 'utf8')).toContain('done'); }); + it('names the size limit when the event log is too large to read', async () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + "printf 'done\\n'; exit 0", + ]); + waitForExit(testHome, sessionId); + // A log that a host of an older version grew past the limit. A sparse + // file keeps the test fast: the size check runs before the read. + await truncate( + eventLogPath(sessionDir(testHome, sessionId)), + MAX_EVENT_LOG_SIZE + 1, + ); + + const exportResult = runCli( + ['record', 'export', sessionId, '--format', 'asciicast', '--json'], + { AGENT_TTY_HOME: testHome }, + 15_000, + ); + expect(exportResult.status).toBe(7); + const exportEnvelope = JSON.parse(exportResult.stdout) as ErrorEnvelope; + expect(exportEnvelope.error.code).toBe('EXPORT_ERROR'); + expect(exportEnvelope.error.message).toMatch( + new RegExp( + `^Failed to export session "${sessionId}" as asciicast: its event log is \\d+ bytes, over the ${String(MAX_EVENT_LOG_SIZE)}-byte limit that commands can read\\.$`, + ), + ); + expect(exportEnvelope.error.details?.maxEventLogBytes).toBe( + MAX_EVENT_LOG_SIZE, + ); + + const snapshotResult = runCli( + ['snapshot', sessionId, '--json'], + { AGENT_TTY_HOME: testHome }, + 15_000, + ); + expect(snapshotResult.status).toBe(10); + const snapshotEnvelope = JSON.parse(snapshotResult.stdout) as ErrorEnvelope; + expect(snapshotEnvelope.error.code).toBe('REPLAY_ERROR'); + expect(snapshotEnvelope.error.message).toContain( + `-byte limit that commands can read.`, + ); + }); + it('rejects invalid export formats', () => { const result = runCli( ['record', 'export', 'session-01', '--format', 'bogus', '--json'], diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index 079a35c7..5c460a69 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -14,11 +14,13 @@ import process from 'node:process'; import type * as FsPromises from 'node:fs/promises'; +import type * as EventLogCodecModule from '../../../src/storage/eventLogCodec.js'; import type * as HomeModule from '../../../src/storage/home.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ERROR_CODES, makeCliError } from '../../../src/protocol/errors.js'; +import { EventLogTooLargeError } from '../../../src/storage/eventLogCodec.js'; const mocks = vi.hoisted(() => ({ emitSuccess: vi.fn(), @@ -48,7 +50,8 @@ vi.mock('../../../src/cli/output.js', () => ({ emitSuccess: mocks.emitSuccess, })); -vi.mock('../../../src/storage/eventLogCodec.js', () => ({ +vi.mock('../../../src/storage/eventLogCodec.js', async (importOriginal) => ({ + ...(await importOriginal()), readEventLogRecords: mocks.readEventLogRecords, })); @@ -1220,6 +1223,50 @@ describe('record export command', () => { expect(mocks.emitSuccess).not.toHaveBeenCalled(); }); + it.each(['asciicast', 'webm'] as const)( + 'names the size limit when the event log is too large to export as %s', + async (format) => { + mocks.readEventLogRecords.mockRejectedValue( + new EventLogTooLargeError(78_623_361, 52_428_800), + ); + + await expect( + runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.EXPORT_ERROR, + message: `Failed to export session "session-01" as ${format}: its event log is 78623361 bytes, over the 52428800-byte limit that commands can read.`, + details: { + sessionId: 'session-01', + format, + eventLogBytes: 78_623_361, + maxEventLogBytes: 52_428_800, + }, + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }, + ); + + it('keeps the generic message for other event log read failures', async () => { + mocks.readEventLogRecords.mockRejectedValue(new Error('EIO')); + + await expect( + runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'asciicast', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.EXPORT_ERROR, + message: 'Failed to export session "session-01" as asciicast.', + }); + }); + it('writes explicit relative output paths within the current working directory', async () => { const workspaceDirectory = await createTemporaryDirectory( 'agent-tty-record-export-workspace-', diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index ece8a232..71be6ff9 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -3,6 +3,7 @@ import { readFile, realpath, rm, + stat, truncate, writeFile, } from 'node:fs/promises'; @@ -11,13 +12,20 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateAsciicast } from '../../../src/export/asciicast.js'; import { countEventLogEntries, + EVENT_LOG_LIMIT_LABEL_PREFIX, + EVENT_LOG_TAIL_RESERVE_BYTES, EventLog, MAX_EVENT_BUFFER_ENTRIES, statEventLogBytes, } from '../../../src/host/eventLog.js'; -import { MAX_EVENT_LOG_SIZE } from '../../../src/storage/eventLogCodec.js'; +import type { SessionRecord } from '../../../src/protocol/schemas.js'; +import { + MAX_EVENT_LOG_SIZE, + readEventLogRecords, +} from '../../../src/storage/eventLogCodec.js'; let tempDir = ''; let eventLogPath = ''; @@ -525,3 +533,270 @@ describe('EventLog', () => { ); }); }); + +describe('EventLog size limit', () => { + // Small enough to reach with a few dozen appends, large enough to leave + // room beyond the tail reserve. + const LIMIT = EVENT_LOG_TAIL_RESERVE_BYTES * 2; + + beforeEach(async () => { + // oxfmt-ignore + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-event-log-limit-'))); + eventLogPath = join(tempDir, 'events.jsonl'); + await writeFile(eventLogPath, '', 'utf8'); + }); + + afterEach(async () => { + if (tempDir.length > 0) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + async function fileBytes(): Promise { + return (await stat(eventLogPath)).size; + } + + async function fillUntilStopped(eventLog: EventLog): Promise { + for (let index = 0; index < 1000; index += 1) { + await eventLog.append('output', { data: `line ${String(index)}\r\n` }); + if (eventLog.getEvents().some((event) => event.type === 'marker')) { + return; + } + } + expect.unreachable('the log never stopped'); + } + + function stopRecords(eventLog: EventLog): readonly unknown[] { + return eventLog + .getEvents() + .filter( + (event) => + event.type === 'marker' && + event.payload.label.startsWith(EVENT_LOG_LIMIT_LABEL_PREFIX), + ); + } + + it('stops at the limit: one stop record, later events dropped, file within the limit', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await fillUntilStopped(eventLog); + const seqAtStop = eventLog.getEvents().at(-1)?.seq; + expect(seqAtStop).toBeGreaterThan(0); + + // Every later append resolves, and none of them takes a seq. + await expect( + eventLog.append('output', { data: 'x'.repeat(100) }), + ).resolves.toBe(seqAtStop); + await expect( + eventLog.append('input_text', { data: 'typed' }), + ).resolves.toBe(seqAtStop); + await expect( + eventLog.append('resize', { cols: 100, rows: 30 }), + ).resolves.toBe(seqAtStop); + await expect( + eventLog.append('signal', { signal: 'SIGINT' }), + ).resolves.toBe(seqAtStop); + await expect(eventLog.append('marker', { label: 'late' })).resolves.toBe( + seqAtStop, + ); + expect( + eventLog.startAppend('input_run', { + command: 'ls', + noWait: true, + }).seq, + ).toBe(seqAtStop); + + // The exit record still fits in the reserve. + const exitSeq = await eventLog.append('exit', { + exitCode: 0, + exitSignal: null, + }); + expect(exitSeq).toBe((seqAtStop ?? 0) + 1); + await expect(eventLog.append('output', { data: 'after' })).resolves.toBe( + exitSeq, + ); + await eventLog.readAll(); + + const events = eventLog.getEvents(); + expect(stopRecords(eventLog)).toHaveLength(1); + expect(events.map((event) => event.seq)).toEqual( + events.map((_, index) => index), + ); + expect(events.at(-2)).toMatchObject({ + type: 'marker', + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(LIMIT)} bytes); later events are not recorded`, + }, + }); + expect(events.at(-1)?.type).toBe('exit'); + expect(events.map((event) => event.type)).not.toContain('input_text'); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('keeps ordinary records out of the tail reserve', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await fillUntilStopped(eventLog); + await eventLog.readAll(); + + // Everything but the stop record fits below the reserve. + const stopSeq = eventLog.getEvents().at(-1)?.seq ?? 0; + const withoutStop = eventLog + .getEvents() + .slice(0, stopSeq) + .reduce( + (total, event) => + total + Buffer.byteLength(`${JSON.stringify(event)}\n`), + 0, + ); + expect(withoutStop).toBeLessThanOrEqual( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES, + ); + } finally { + await eventLog.close(); + } + }); + + it('stops on a single record that is larger than the room', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await eventLog.append('output', { data: 'prompt$ ' }); + await expect( + eventLog.append('output', { data: 'x'.repeat(LIMIT) }), + ).resolves.toBe(1); + await eventLog.readAll(); + + expect(eventLog.getEvents().map((event) => event.type)).toEqual([ + 'output', + 'marker', + ]); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('writes the exit record into the reserve when the log has not stopped', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + const lineBytes = ( + seq: number, + type: 'output' | 'exit', + payload: object, + ): number => + Buffer.byteLength( + `${JSON.stringify({ seq, ts: new Date().toISOString(), type, payload })}\n`, + ); + const output = { data: 'y'.repeat(20) }; + const exit = { exitCode: 0, exitSignal: null }; + + try { + // Fill the file up to the reserve line, without stopping the log. + let seq = 0; + while ( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES - (await fileBytes()) >= + lineBytes(seq, 'output', output) + ) { + await eventLog.append('output', output); + seq += 1; + } + expect( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES - (await fileBytes()), + ).toBeLessThan(lineBytes(seq, 'exit', exit)); + expect(stopRecords(eventLog)).toHaveLength(0); + + await eventLog.append('exit', exit); + await eventLog.readAll(); + + expect(eventLog.getEvents().at(-1)?.type).toBe('exit'); + expect(stopRecords(eventLog)).toHaveLength(0); + expect(await fileBytes()).toBeGreaterThan( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES, + ); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('leaves a log that readers accept and the asciicast export shows the stop', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await fillUntilStopped(eventLog); + await eventLog.append('exit', { exitCode: 0, exitSignal: null }); + } finally { + await eventLog.close(); + } + + const events = await readEventLogRecords(eventLogPath); + expect(events.map((event) => event.seq)).toEqual( + events.map((_, index) => index), + ); + + const manifest: SessionRecord = { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:00.000Z', + status: 'exited', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: null, + childPid: null, + exitCode: 0, + exitSignal: null, + }; + const cast = generateAsciicast('session-01', manifest, events); + expect(cast.markerCount).toBe(1); + const markerLine = cast.contents + .trim() + .split('\n') + .map((line) => JSON.parse(line) as unknown[]) + .find((line) => line[1] === 'm'); + expect(markerLine?.[2]).toContain(EVENT_LOG_LIMIT_LABEL_PREFIX); + }); + + it('reports a failed write to the caller of a dropped append', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + const eventLogInternals = eventLog as unknown as { + fileHandle: { + appendFile: (data: string, encoding: BufferEncoding) => Promise; + }; + writeQueue: Promise; + }; + + try { + await fillUntilStopped(eventLog); + vi.spyOn(eventLogInternals.fileHandle, 'appendFile').mockRejectedValue( + new Error('EIO'), + ); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).rejects.toThrow('EIO'); + await expect( + eventLog.append('output', { data: 'dropped' }), + ).rejects.toThrow('EIO'); + vi.restoreAllMocks(); + eventLogInternals.writeQueue = Promise.resolve(); + } finally { + await eventLog.close(); + } + }); + + it('refuses a limit that readers would not accept or that has no room', async () => { + await expect( + EventLog.open(eventLogPath, { maxBytes: MAX_EVENT_LOG_SIZE + 1 }), + ).rejects.toThrow('maxBytes must leave room'); + await expect( + EventLog.open(eventLogPath, { maxBytes: EVENT_LOG_TAIL_RESERVE_BYTES }), + ).rejects.toThrow('maxBytes must leave room'); + }); +}); diff --git a/test/unit/replay/offlineReplay.test.ts b/test/unit/replay/offlineReplay.test.ts index a39203ce..a83b9cc1 100644 --- a/test/unit/replay/offlineReplay.test.ts +++ b/test/unit/replay/offlineReplay.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, realpath, rm, truncate, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; @@ -13,6 +13,7 @@ import type { import { withOfflineReplayRenderer } from '../../../src/replay/offlineReplay.js'; import type { RendererBackend } from '../../../src/renderer/backend.js'; import type { ReplayInput, ReplayState } from '../../../src/renderer/types.js'; +import { MAX_EVENT_LOG_SIZE } from '../../../src/storage/eventLogCodec.js'; import { writeManifest } from '../../../src/storage/manifests.js'; import { eventLogPath, @@ -559,6 +560,32 @@ describe('withOfflineReplayRenderer', () => { expect(backend.disposed).toBe(true); }); + it('names the size limit when the event log is too large', async () => { + const { sessionDir } = await createSessionFixture(); + const eventsFile = eventLogPath(sessionDir); + await truncate(eventsFile, MAX_EVENT_LOG_SIZE + 1); + const backend = createMockBackend(); + + const replayPromise = withOfflineReplayRenderer( + { sessionDir }, + () => Promise.resolve('unreachable'), + { backendFactory: () => backend }, + ); + + await expect(replayPromise).rejects.toMatchObject({ + name: 'CliError', + code: ERROR_CODES.REPLAY_ERROR, + message: `Failed to read event log: ${eventsFile} is ${String(MAX_EVENT_LOG_SIZE + 1)} bytes, over the ${String(MAX_EVENT_LOG_SIZE)}-byte limit that commands can read.`, + details: { + eventLogPath: eventsFile, + eventLogBytes: MAX_EVENT_LOG_SIZE + 1, + maxEventLogBytes: MAX_EVENT_LOG_SIZE, + }, + }); + expect(backend.booted).toBe(false); + expect(backend.disposed).toBe(true); + }); + it('wraps non-contiguous event log sequences as REPLAY_ERROR and still disposes the backend', async () => { const { sessionDir } = await createSessionFixture({ events: [ diff --git a/test/unit/storage/eventLogCodec.test.ts b/test/unit/storage/eventLogCodec.test.ts index 16c8492d..e6e5d893 100644 --- a/test/unit/storage/eventLogCodec.test.ts +++ b/test/unit/storage/eventLogCodec.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { EventRecord } from '../../../src/protocol/schemas.js'; import { assertEventLogSize, + EventLogTooLargeError, MAX_EVENT_LOG_SIZE, parseEventLogContent, readEventLogRecords, @@ -84,6 +85,21 @@ describe('event log codec', () => { ); }); + it('throws a typed error that carries the size and the limit', async () => { + expect(() => assertEventLogSize(MAX_EVENT_LOG_SIZE + 1)).toThrow( + EventLogTooLargeError, + ); + + await writeFile(eventLogPath, '', 'utf8'); + await truncate(eventLogPath, MAX_EVENT_LOG_SIZE + 1); + + await expect(readEventLogRecords(eventLogPath)).rejects.toMatchObject({ + name: 'EventLogTooLargeError', + size: MAX_EVENT_LOG_SIZE + 1, + maxSize: MAX_EVENT_LOG_SIZE, + }); + }); + it('parses empty event log content as no records', () => { expect(parseEventLogContent('')).toEqual([]); }); From 56fb19b6274a78981e113b6d0cb57d99a82ebce1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 5 Nov 2026 19:18:06 +0000 Subject: [PATCH 02/12] fix: stop the event log at its entry cap like at its byte cap The 250,000-entry in-memory cap threw an AssertionError from EventLog.enqueue. The host turned it into an uncaught exception, died, and killed the program. Now the cap stops the log the same way the byte cap does: one stop marker whose label names the limit (( bytes) or ( events)), later events dropped with the last stored seq, and the exit record still stored. Also pin the stopped early return, the stop marker size checks, the size of an existing file at open, the offline replay generic message, and the tail reserve size, and document what the stop means for live wait and mouse input. --- README.md | 2 +- docs/TROUBLESHOOTING.md | 11 +- src/host/README.md | 8 +- src/host/eventLog.ts | 86 +++++-- test/unit/host/eventLog.test.ts | 318 ++++++++++++++++++++++++- test/unit/replay/offlineReplay.test.ts | 21 ++ 6 files changed, 415 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 9565f016..ea5e93c3 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ The `agent-tty` CLI (`src/cli/main.ts`) starts one detached host process per ses - The CLI JSON envelope is the stable automation surface. `src/protocol/envelope.ts` defines it, and `src/cli/exitCodes.ts` maps known error codes to fixed exit codes. Unmapped codes, such as `SKILL_NOT_FOUND`, exit 1. [src/protocol/envelope.ts:CommandEnvelope] [src/cli/exitCodes.ts:exitCodeForError] - Every `.object(...)` schema in `src/protocol/schemas.ts` and `src/protocol/messages.ts` ends in `.strict()`, so unknown keys fail validation. [src/protocol/schemas.ts:SessionRecordSchema] [src/protocol/messages.ts:RpcRequestSchema] -- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB) with `EventLogTooLargeError`. `EventLog.append` never writes the file past that size. Ordinary records stop `EVENT_LOG_TAIL_RESERVE_BYTES` (4,096 bytes) short of it. The first record that does not fit makes the log append one `marker` record that starts with `EVENT_LOG_LIMIT_LABEL_PREFIX`, and from then on the log stores only the `exit` record. The program keeps running, and the host acknowledges every other event with the `seq` of the last stored record, so later output, input, resizes, signals and markers appear in no snapshot, screenshot or export. `EventLog.append` also caps the buffer at `MAX_EVENT_BUFFER_ENTRIES` (250,000 entries) and throws past it. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [src/host/eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:EventLogTooLargeError] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE] +- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB) with `EventLogTooLargeError`. `EventLog.append` never writes the file past that size and never holds more than `MAX_EVENT_BUFFER_ENTRIES` (250,000) entries. Ordinary records stop `EVENT_LOG_TAIL_RESERVE_BYTES` (4,096 bytes) short of the size and `EVENT_LOG_TAIL_RESERVE_ENTRIES` (2) short of the entry cap. The first record that does not fit makes the log append one `marker` record that starts with `EVENT_LOG_LIMIT_LABEL_PREFIX` and ends with the limit that stopped the log (` bytes` or ` events`), and from then on the log stores only the `exit` record. The program keeps running, and the host acknowledges every other event with the `seq` of the last stored record, so later output, input, resizes, signals and markers appear in no snapshot, screenshot or export. Live features that replay the log see the program as it was at the stop: `wait --screen-stable-ms` can report a stable screen while the program keeps printing, and a mouse request encodes against the mouse modes in the stored log. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [src/host/eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:EventLogTooLargeError] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE] - Session, socket and artifact paths come from guarded builders that reject path separators and `.` or `..` session IDs. [src/storage/sessionPaths.ts:sessionDir] [src/storage/artifactPaths.ts:artifactPath] - `session.json`, the artifact manifest and the Home registry are written through `writeTextFileAtomic`: a temp file with mode `0o600`, then a rename. [src/storage/manifests.ts:writeTextFileAtomic] - `gc` deletes only sessions whose status is collectable (`exited`, `failed` or `destroyed`) after `reconcileSession`, and it re-reads the manifest before each delete. [src/cli/commands/gc.ts:gcSessions] [src/protocol/sessionStatusPolicy.ts:getSessionStatusPolicy] diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d5dc1f18..dbae1dbf 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -99,6 +99,15 @@ To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh' ## A Recording Ends Early, or an Export Names the Event Log Limit -Each session records its terminal output in `events.jsonl`, which holds at most 52,428,800 bytes (50 MB). A session that prints more stops recording at that size. The host writes one marker that begins `agent-tty: event log limit reached`, and after it records only the program's exit. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker, and a `wait` for text that appears later times out. An asciicast export ends with the marker. To keep a long session recordable, send its verbose output to a file instead of the terminal. +Each session records its terminal output in `events.jsonl`. The log stops at the first of two limits: 52,428,800 bytes (50 MB), or 250,000 events. A program that prints a lot reaches the byte limit. A program that prints in many tiny chunks reaches the event limit long before 50 MB. When the log stops, the host writes one marker and after it records only the program's exit. The marker begins `agent-tty: event log limit reached` and ends with the limit that stopped the log: `(52428800 bytes)` or `(250000 events)`. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker. An asciicast export ends with the marker. + +Commands on a live session read the stored log too, so after the marker they see the session as it was at the marker: + +- `wait --text` and `wait --regex` for text that appears later time out. +- `wait --screen-stable-ms` can report a stable screen while the program keeps printing, because the stored screen no longer changes. +- `mouse` encodes against the mouse modes in the stored log. A mode that the program turned on after the marker is not known, so the event succeeds with `reported: false` and writes nothing. A mode that the program turned off after the marker is still on in the stored log, so the program receives mouse bytes that it no longer expects. +- `wait --idle-ms` and `wait --exit` watch the live program and are not affected. + +To keep a long session recordable, send its verbose output to a file instead of the terminal, or print in larger chunks. A command that fails with a message such as `its event log is 81336613 bytes, over the 52428800-byte limit that commands can read` names a log that an older version of agent-tty wrote past the limit. No command can read that log. `inspect` and `destroy` still work on the session. diff --git a/src/host/README.md b/src/host/README.md index 15425f98..aabbdd21 100644 --- a/src/host/README.md +++ b/src/host/README.md @@ -31,7 +31,7 @@ - `SessionState` holds the host's in-memory `SessionRecord` (type from `src/protocol/schemas.ts`). Methods: `snapshot`, `setChildPid`, `setDimensions`, `setFailureOrigin`, `requestDestroy`, `recordDestroyed` and `recordExit`. [sessionState.ts:SessionState] - `createSerialManifestWriter(path, snapshot, write?)` returns a function that writes `snapshot()` to `path`. The default `write` is `writeManifest` (`src/storage/manifests.ts`). `runHost` sends every manifest write through one such function. [manifestWriter.ts:createSerialManifestWriter] [hostMain.ts:runHost] - `EventLog` is the append-only JSONL event log with an in-memory buffer. Methods: `static open(filePath, options?)`, `append(type, payload)` (returns the `seq` of the stored record), `startAppend('input_run', payload)` (returns `{ seq, written }` without an `await`), `getEvents`, `getEventsSince(afterSeq)`, `readAll` and `close`. [eventLog.ts:EventLog] -- `MAX_EVENT_BUFFER_ENTRIES` is 250,000. `EVENT_LOG_TAIL_RESERVE_BYTES` is 4,096. `EVENT_LOG_LIMIT_LABEL_PREFIX` starts the label of the stop marker. `statEventLogBytes(filePath)` returns the file size or `undefined` when the file does not exist. `countEventLogEntries(filePath)` counts non-blank lines. [eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [eventLog.ts:statEventLogBytes] [eventLog.ts:countEventLogEntries] +- `MAX_EVENT_BUFFER_ENTRIES` is 250,000. `EVENT_LOG_TAIL_RESERVE_BYTES` is 4,096. `EVENT_LOG_TAIL_RESERVE_ENTRIES` is 2. `EVENT_LOG_LIMIT_LABEL_PREFIX` starts the label of the stop marker. `statEventLogBytes(filePath)` returns the file size or `undefined` when the file does not exist. `countEventLogEntries(filePath)` counts non-blank lines. [eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [eventLog.ts:statEventLogBytes] [eventLog.ts:countEventLogEntries] - `createMouseRpcHandler(deps)` returns the `mouse` handler `(request, rendererName)`, which resolves a `MouseResult` (`reported`, `bytesWritten`, `seq`). `MouseRpcDependencies` injects the renderer manager (in `runHost` a second `HostRendererManager` that only mouse input uses, so a render on another renderer or profile never replaces the encoder's backend and its same-cell motion state), the profile, the libghostty-vt probe, the replay loader, the commandable check, the PTY writer and the `input_mouse` append. The handler keeps the held buttons between requests. [mouseRpc.ts:createMouseRpcHandler] [mouseRpc.ts:MouseRpcDependencies] - `prepareHeldMouseButtons(currentHeld, input)` returns the next held set, `anyButtonPressed` and the `dragButton` that a button-free `move` reports, without changing `currentHeld`. [mouseInput.ts:prepareHeldMouseButtons] - `buildReplayInput(sessionId, manifest, events, ...)` builds a validated `ReplayInput` (type from `src/renderer/types.ts`) with the initial size and target sequence number. [replay.ts:buildReplayInput] @@ -44,9 +44,9 @@ ## Invariants -- `EventLog.open` derives the next `seq` from the last record (0 for an empty file), using `assertEventLogSize` and `parseEventLogContent` from `src/storage/eventLogCodec.ts`. `options.maxBytes` sets the size limit of the file. It defaults to `MAX_EVENT_LOG_SIZE`, must exceed `EVENT_LOG_TAIL_RESERVE_BYTES` and cannot exceed `MAX_EVENT_LOG_SIZE`, so the writer never passes what readers accept. Tests inject a small limit. `append` asserts that the new `seq` equals the buffer length and validates the record with `EventRecordSchema`, so sequence numbers stay contiguous. [eventLog.ts:EventLog] [eventLog.ts:EventLogOptions] -- At the size limit `EventLog.append` stops the log. Ordinary records may fill the file up to the limit minus `EVENT_LOG_TAIL_RESERVE_BYTES`. The first record that does not fit makes the log append one `marker` record with the label `agent-tty: event log limit reached ( bytes); later events are not recorded`, inside the reserve. From then on the log stores only the `exit` record, which also fits in the reserve, so the file never passes the limit. A record that the log does not store takes no `seq`. `append` resolves with the `seq` of the last stored record, and `startAppend` returns it as `seq`. Handlers, waited runs and `resize` therefore keep working, and the program keeps running. Its later output, input, resizes, signals and markers are in no snapshot, screenshot or export, because those replay the log. A live `wait` for text that appears after the stop times out. Once a write has failed, a dropped append rejects like any other. The buffer limit `MAX_EVENT_BUFFER_ENTRIES` is separate and still throws. [eventLog.ts:EventLog] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] -- `EventLog.append` validates each payload with a strict Zod schema for its event type. It throws when the buffer holds `MAX_EVENT_BUFFER_ENTRIES` records, after `close()`, and after an earlier write failed (the write queue stays rejected). A failed write removes the failed records from the buffer. [eventLog.ts:EventLog] +- `EventLog.open` derives the next `seq` from the last record (0 for an empty file), using `assertEventLogSize` and `parseEventLogContent` from `src/storage/eventLogCodec.ts`. `options.maxBytes` sets the size limit of the file. It defaults to `MAX_EVENT_LOG_SIZE`, must exceed `EVENT_LOG_TAIL_RESERVE_BYTES` and cannot exceed `MAX_EVENT_LOG_SIZE`, so the writer never passes what readers accept. `options.maxEntries` sets the limit of the number of events. It defaults to `MAX_EVENT_BUFFER_ENTRIES`, must exceed `EVENT_LOG_TAIL_RESERVE_ENTRIES` and cannot exceed `MAX_EVENT_BUFFER_ENTRIES`. Tests inject small limits. `append` asserts that the new `seq` equals the buffer length and validates the record with `EventRecordSchema`, so sequence numbers stay contiguous. [eventLog.ts:EventLog] [eventLog.ts:EventLogOptions] +- At either limit, the file size or the number of events, `EventLog.append` stops the log. Ordinary records may fill the file up to `maxBytes` minus `EVENT_LOG_TAIL_RESERVE_BYTES`, and the buffer up to `maxEntries` minus `EVENT_LOG_TAIL_RESERVE_ENTRIES` (one entry for the stop record and one for the `exit` record). The first record that does not fit makes the log append one `marker` record with the label `agent-tty: event log limit reached (); later events are not recorded`, inside the reserves. `` is ` bytes` when the file size stopped the log and ` events` when the event count stopped it, so a reader can tell the two apart. A record that passes both limits is reported as the byte limit. From then on the log stores only the `exit` record, which also fits in the reserves, so the file never passes the byte limit and the buffer never passes the entry limit, and the host does not die from either. A record that the log does not store takes no `seq`. `append` resolves with the `seq` of the last stored record, and `startAppend` returns it as `seq`. Handlers, waited runs and `resize` therefore keep working, and the program keeps running. Its later output, input, resizes, signals and markers are in no snapshot, screenshot or export, because those replay the log. Every live feature that replays the log sees the program as it was at the stop: a live `wait` for text that appears after the stop times out, `wait --screen-stable-ms` can report a stable screen while the program keeps printing, and a mouse request after the stop encodes against the mouse modes that the stored log shows, not against modes that the program enabled or disabled after the stop (`reported` is false when the stored modes have mouse tracking off). `wait --idle-ms` and `wait --exit` follow the live program and are not affected. Once a write has failed, a dropped append rejects like any other. [eventLog.ts:EventLog] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [mouseRpc.ts:createMouseRpcHandler] +- `EventLog.append` validates each payload with a strict Zod schema for its event type. It throws after `close()` and after an earlier write failed (the write queue stays rejected). It does not throw at a size limit or at `MAX_EVENT_BUFFER_ENTRIES`: the log stops instead. A failed write removes the failed records from the buffer. [eventLog.ts:EventLog] - `SessionState` enforces its transitions: `setChildPid` and `setDimensions` need status `running`, and the child PID is set once. `SessionState` has no method that sets `hostPid`. `requestDestroy` moves `running`, `exited` or `failed` to `destroying`. `recordDestroyed` needs `destroying`. `recordExit` needs `running` or `exiting` and no earlier exit. [sessionState.ts:SessionState] - `runHost` asserts that the manifest `sessionId` matches its argument and that the manifest `hostPid` equals its own `process.pid`. `waitForManifestFromCreate` asserts that `creatorPid` is a positive integer. [hostMain.ts:runHost] [hostMain.ts:waitForManifestFromCreate] - The function from `createSerialManifestWriter` starts each write after the previous write settles. Each write takes the snapshot when it starts, not when it was requested. A failed write rejects only its own caller, and the next write still runs. [manifestWriter.ts:createSerialManifestWriter] diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index ff0514bc..adaf1081 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -116,6 +116,7 @@ export interface StartedAppend { /** * Maximum number of events retained in the in-memory buffer. * At ~200 bytes per event object, 250k events ≈ 50MB — consistent with the file size limit. + * The log stops at this count the same way it stops at its byte limit. */ export const MAX_EVENT_BUFFER_ENTRIES = 250_000; @@ -126,7 +127,17 @@ export const MAX_EVENT_BUFFER_ENTRIES = 250_000; */ export const EVENT_LOG_TAIL_RESERVE_BYTES = 4096; -/** Start of the label of the marker that records why the log stopped. */ +/** + * Entries that ordinary records leave free in the buffer: one for the stop + * record and one for the `exit` record. + */ +export const EVENT_LOG_TAIL_RESERVE_ENTRIES = 2; + +/** + * Start of the label of the marker that records why the log stopped. The rest + * of the label names the limit that was reached: `( bytes)` for the file + * size, `( events)` for the entry count. + */ export const EVENT_LOG_LIMIT_LABEL_PREFIX = 'agent-tty: event log limit reached'; @@ -136,8 +147,16 @@ export interface EventLogOptions { * readers enforce, and cannot exceed it. Tests inject a small limit. */ readonly maxBytes?: number; + /** + * Limit of the number of events. Defaults to `MAX_EVENT_BUFFER_ENTRIES` and + * cannot exceed it. Tests inject a small limit. + */ + readonly maxEntries?: number; } +/** The limit that stopped the log. */ +type StopReason = 'bytes' | 'entries'; + interface PreparedRecord { readonly record: EventRecord; readonly line: string; @@ -295,6 +314,7 @@ export class EventLog { private nextSeq: number, fileBytes: number, private readonly maxBytes: number, + private readonly maxEntries: number, eventBuffer: EventRecord[] = [], private isClosed = false, ) { @@ -311,6 +331,12 @@ export class EventLog { maxBytes <= MAX_EVENT_LOG_SIZE, 'maxBytes must leave room beyond the tail reserve and must not exceed MAX_EVENT_LOG_SIZE', ); + invariant( + Number.isInteger(maxEntries) && + maxEntries > EVENT_LOG_TAIL_RESERVE_ENTRIES && + maxEntries <= MAX_EVENT_BUFFER_ENTRIES, + 'maxEntries must leave room beyond the tail reserve and must not exceed MAX_EVENT_BUFFER_ENTRIES', + ); this.fileBytes = fileBytes; invariant( nextSeq === eventBuffer.length, @@ -325,6 +351,7 @@ export class EventLog { ): Promise { assertFilePath(filePath); const maxBytes = options.maxBytes ?? MAX_EVENT_LOG_SIZE; + const maxEntries = options.maxEntries ?? MAX_EVENT_BUFFER_ENTRIES; const fileHandle = await open(filePath, 'a'); try { @@ -351,6 +378,7 @@ export class EventLog { nextSeq, fileStats.size, maxBytes, + maxEntries, eventBuffer, ); } catch (error) { @@ -403,7 +431,8 @@ export class EventLog { * Appends a record while the log has room, and otherwise stops the log: * * - Ordinary records may fill the file up to `maxBytes` minus - * `EVENT_LOG_TAIL_RESERVE_BYTES`. + * `EVENT_LOG_TAIL_RESERVE_BYTES`, and the buffer up to `maxEntries` minus + * `EVENT_LOG_TAIL_RESERVE_ENTRIES`. * - The first record that does not fit stops the log. The log appends one * stop marker inside the reserve (see `recordStop`), and from then on it * records nothing but the `exit` record, which also fits in the reserve. @@ -430,12 +459,13 @@ export class EventLog { } const prepared = this.prepareRecord(type, validatedPayload); - if (this.hasRoomFor(type, prepared.bytes)) { + const stopReason = this.findStopReason(type, prepared.bytes); + if (stopReason === undefined) { return this.enqueue(prepared); } if (!this.stopped) { - this.recordStop(); + this.recordStop(stopReason); } return this.acknowledgeUnstored(); } @@ -466,30 +496,48 @@ export class EventLog { }; } - private hasRoomFor(type: EventLogEventType, bytes: number): boolean { - const room = this.maxBytes - this.fileBytes; - if (type === 'exit') { - // The last record of a session may use the reserve. - return bytes <= room; - } + /** + * Returns the limit that a record of `bytes` bytes would pass, or + * `undefined` when it fits. The last record of a session, `exit`, may use + * the reserves. When both limits are passed, the byte limit is the reason. + */ + private findStopReason( + type: EventLogEventType, + bytes: number, + ): StopReason | undefined { + const reserveBytes = type === 'exit' ? 0 : EVENT_LOG_TAIL_RESERVE_BYTES; + const reserveEntries = type === 'exit' ? 0 : EVENT_LOG_TAIL_RESERVE_ENTRIES; - return bytes <= room - EVENT_LOG_TAIL_RESERVE_BYTES; + if (bytes > this.maxBytes - this.fileBytes - reserveBytes) { + return 'bytes'; + } + if (this.eventBuffer.length + 1 > this.maxEntries - reserveEntries) { + return 'entries'; + } + return undefined; } /** * Stops the log and records why, once. The marker is the only existing * event type that fits: it carries a label, replayers ignore it, and the - * asciicast export shows it as a marker. It goes into the reserve, so it - * fits unless a log that this host did not write already filled the - * reserve before it opened. + * asciicast export shows it as a marker. Its label names the limit that was + * reached: bytes or events. It goes into the reserves, so it fits unless a + * log that this host did not write already filled them before it opened. */ - private recordStop(): void { + private recordStop(reason: StopReason): void { this.stopped = true; + const limit = + reason === 'bytes' + ? `${String(this.maxBytes)} bytes` + : `${String(this.maxEntries)} events`; const stop = this.prepareRecord('marker', { - label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(this.maxBytes)} bytes); later events are not recorded`, + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${limit}); later events are not recorded`, }); - if (stop.bytes > this.maxBytes - this.fileBytes) { + if ( + stop.bytes > this.maxBytes - this.fileBytes || + this.eventBuffer.length + 1 > this.maxEntries + ) { return; } @@ -516,8 +564,8 @@ export class EventLog { 'event seq must match the expected next seq', ); invariant( - this.eventBuffer.length < MAX_EVENT_BUFFER_ENTRIES, - `event buffer exceeds ${String(MAX_EVENT_BUFFER_ENTRIES)} entries; session event log is too large`, + this.eventBuffer.length < this.maxEntries, + `event buffer exceeds ${String(this.maxEntries)} entries; session event log is too large`, ); invariant( seq === this.eventBuffer.length, diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index 71be6ff9..f3b740ef 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -17,6 +17,7 @@ import { countEventLogEntries, EVENT_LOG_LIMIT_LABEL_PREFIX, EVENT_LOG_TAIL_RESERVE_BYTES, + EVENT_LOG_TAIL_RESERVE_ENTRIES, EventLog, MAX_EVENT_BUFFER_ENTRIES, statEventLogBytes, @@ -442,7 +443,7 @@ describe('EventLog', () => { ); }); - it('rejects appends when the in-memory buffer reaches the runtime cap', async () => { + it('stops at the in-memory entry cap instead of throwing', async () => { const eventLog = await EventLog.open(eventLogPath); const eventLogInternals = eventLog as unknown as { eventBuffer: unknown[]; @@ -450,21 +451,48 @@ describe('EventLog', () => { }; try { + // Stand in for a log that holds all but the two reserved entries. eventLogInternals.eventBuffer = new Array( - MAX_EVENT_BUFFER_ENTRIES, + MAX_EVENT_BUFFER_ENTRIES - 2, ) as unknown[]; - eventLogInternals.nextSeq = MAX_EVENT_BUFFER_ENTRIES; + eventLogInternals.nextSeq = MAX_EVENT_BUFFER_ENTRIES - 2; await expect( eventLog.append('output', { data: 'overflow' }), - ).rejects.toThrow( - `event buffer exceeds ${String(MAX_EVENT_BUFFER_ENTRIES)} entries; session event log is too large`, - ); + ).resolves.toBe(MAX_EVENT_BUFFER_ENTRIES - 2); + await expect( + eventLog.append('output', { data: 'dropped' }), + ).resolves.toBe(MAX_EVENT_BUFFER_ENTRIES - 2); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(MAX_EVENT_BUFFER_ENTRIES - 1); + await expect( + eventLog.append('output', { data: 'after the exit' }), + ).resolves.toBe(MAX_EVENT_BUFFER_ENTRIES - 1); expect(eventLogInternals.eventBuffer).toHaveLength( MAX_EVENT_BUFFER_ENTRIES, ); expect(eventLogInternals.nextSeq).toBe(MAX_EVENT_BUFFER_ENTRIES); + + const written = (await readFile(eventLogPath, 'utf8')) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + seq: number; + type: string; + payload: { label?: string }; + }, + ); + expect(written.map((record) => [record.seq, record.type])).toEqual([ + [MAX_EVENT_BUFFER_ENTRIES - 2, 'marker'], + [MAX_EVENT_BUFFER_ENTRIES - 1, 'exit'], + ]); + expect(written[0]?.payload.label).toBe( + `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(MAX_EVENT_BUFFER_ENTRIES)} events); later events are not recorded`, + ); } finally { await eventLog.close(); } @@ -576,6 +604,46 @@ describe('EventLog size limit', () => { ); } + function outputLine(seq: number, data: string): string { + return `${JSON.stringify({ + seq, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data }, + })}\n`; + } + + /** Writes `count` records, as a log that an earlier host left behind. */ + async function prefillEntries(count: number): Promise { + let contents = ''; + for (let seq = 0; seq < count; seq += 1) { + contents += outputLine(seq, 'earlier'); + } + await writeFile(eventLogPath, contents, 'utf8'); + } + + /** Writes records that make a file of exactly `totalBytes` bytes. */ + async function prefillBytes(totalBytes: number): Promise { + let contents = ''; + let seq = 0; + while ( + totalBytes - Buffer.byteLength(contents) >= + 2 * (Buffer.byteLength(outputLine(seq, '')) + 100) + ) { + contents += outputLine(seq, 'a'.repeat(100)); + seq += 1; + } + const lastData = + totalBytes - + Buffer.byteLength(contents) - + Buffer.byteLength(outputLine(seq, '')); + contents += outputLine(seq, 'z'.repeat(lastData)); + seq += 1; + expect(Buffer.byteLength(contents)).toBe(totalBytes); + await writeFile(eventLogPath, contents, 'utf8'); + return seq; + } + it('stops at the limit: one stop record, later events dropped, file within the limit', async () => { const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); @@ -682,6 +750,234 @@ describe('EventLog size limit', () => { } }); + it('stores nothing after the stop, not even a record that would fit', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await eventLog.append('output', { data: 'prompt$ ' }); + // About 6000 bytes do not fit in the room below the tail reserve. + await expect( + eventLog.append('output', { data: 'x'.repeat(6000) }), + ).resolves.toBe(1); + + // These are small enough to fit, but the log has stopped. + await expect(eventLog.append('output', { data: 'ok' })).resolves.toBe(1); + await expect( + eventLog.append('input_text', { data: 'typed' }), + ).resolves.toBe(1); + + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(2); + await eventLog.readAll(); + + expect(eventLog.getEvents().map((event) => event.type)).toEqual([ + 'output', + 'marker', + 'exit', + ]); + } finally { + await eventLog.close(); + } + }); + + it('counts the size of an existing file toward the limit when it opens', async () => { + const records = await prefillBytes( + LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES - 10, + ); + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + // About 80 bytes: it fits an empty file, but not the 10 bytes of room + // that the existing records leave below the reserve. + await expect(eventLog.append('output', { data: 'late' })).resolves.toBe( + records, + ); + await eventLog.readAll(); + + const events = eventLog.getEvents(); + expect(events).toHaveLength(records + 1); + expect(events.at(-1)).toMatchObject({ + type: 'marker', + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(LIMIT)} bytes); later events are not recorded`, + }, + }); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('writes no stop record that would pass the byte limit', async () => { + // A log that another host filled up to the limit, tail reserve included. + const records = await prefillBytes(LIMIT - 30); + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await expect(eventLog.append('output', { data: 'late' })).resolves.toBe( + records - 1, + ); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(records - 1); + await eventLog.readAll(); + + expect(eventLog.getEvents()).toHaveLength(records); + expect(await fileBytes()).toBe(LIMIT - 30); + } finally { + await eventLog.close(); + } + }); + + it('keeps a tail reserve that fits the stop record and the largest exit record', () => { + const lineBytes = (record: object): number => + Buffer.byteLength(`${JSON.stringify(record)}\n`); + // `seq` stays below the entry limit. The host writes an integer exit + // code and `String(signal)` of a non-negative integer. + const stop = lineBytes({ + seq: MAX_EVENT_BUFFER_ENTRIES - 2, + ts: '2026-03-19T12:00:00.000Z', + type: 'marker', + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(MAX_EVENT_LOG_SIZE)} bytes); later events are not recorded`, + }, + }); + const exit = lineBytes({ + seq: MAX_EVENT_BUFFER_ENTRIES - 1, + ts: '2026-03-19T12:00:00.000Z', + type: 'exit', + payload: { + exitCode: -Number.MAX_SAFE_INTEGER, + exitSignal: String(Number.MAX_SAFE_INTEGER), + }, + }); + + expect(stop + exit).toBeLessThanOrEqual(EVENT_LOG_TAIL_RESERVE_BYTES); + }); + + it('stops at the entry limit: one stop record that names the events', async () => { + const maxEntries = 20; + const eventLog = await EventLog.open(eventLogPath, { maxEntries }); + + try { + // Ordinary records fill every entry but the two reserved ones. + for (let index = 0; index < maxEntries; index += 1) { + await eventLog.append('output', { data: `line ${String(index)}\r\n` }); + } + const stopSeq = maxEntries - EVENT_LOG_TAIL_RESERVE_ENTRIES; + expect(eventLog.getEvents().at(-1)?.seq).toBe(stopSeq); + + await expect( + eventLog.append('input_text', { data: 'typed' }), + ).resolves.toBe(stopSeq); + await expect( + eventLog.append('resize', { cols: 100, rows: 30 }), + ).resolves.toBe(stopSeq); + await expect(eventLog.append('marker', { label: 'late' })).resolves.toBe( + stopSeq, + ); + + // The exit record still has its entry. + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(stopSeq + 1); + await expect(eventLog.append('output', { data: 'after' })).resolves.toBe( + stopSeq + 1, + ); + await eventLog.readAll(); + + const events = eventLog.getEvents(); + expect(events).toHaveLength(maxEntries); + expect(events.map((event) => event.type)).toEqual([ + ...Array.from({ length: stopSeq }, () => 'output'), + 'marker', + 'exit', + ]); + expect(events.at(-2)).toMatchObject({ + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(maxEntries)} events); later events are not recorded`, + }, + }); + } finally { + await eventLog.close(); + } + + const stored = await readEventLogRecords(eventLogPath); + expect(stored.map((event) => event.seq)).toEqual( + stored.map((_, index) => index), + ); + expect(stored).toHaveLength(maxEntries); + }); + + it('writes the exit record into the entry reserve when the log has not stopped', async () => { + const maxEntries = 20; + const eventLog = await EventLog.open(eventLogPath, { maxEntries }); + + try { + for ( + let index = 0; + index < maxEntries - EVENT_LOG_TAIL_RESERVE_ENTRIES; + index += 1 + ) { + await eventLog.append('output', { data: 'y' }); + } + expect(stopRecords(eventLog)).toHaveLength(0); + + await eventLog.append('exit', { exitCode: 0, exitSignal: null }); + await eventLog.readAll(); + + expect(eventLog.getEvents().at(-1)?.type).toBe('exit'); + expect(stopRecords(eventLog)).toHaveLength(0); + } finally { + await eventLog.close(); + } + }); + + it('writes no stop record that would pass the entry limit', async () => { + // A log that another host filled up to the limit, reserve included. + const maxEntries = 10; + await prefillEntries(maxEntries); + const eventLog = await EventLog.open(eventLogPath, { maxEntries }); + + try { + await expect(eventLog.append('output', { data: 'late' })).resolves.toBe( + maxEntries - 1, + ); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(maxEntries - 1); + await eventLog.readAll(); + + expect(eventLog.getEvents()).toHaveLength(maxEntries); + } finally { + await eventLog.close(); + } + }); + + it('names the byte limit when a record passes both limits', async () => { + const maxEntries = 10; + await prefillEntries(maxEntries - EVENT_LOG_TAIL_RESERVE_ENTRIES); + const eventLog = await EventLog.open(eventLogPath, { + maxBytes: LIMIT, + maxEntries, + }); + + try { + await eventLog.append('output', { data: 'x'.repeat(LIMIT) }); + await eventLog.readAll(); + + expect(eventLog.getEvents().at(-1)).toMatchObject({ + type: 'marker', + payload: { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (${String(LIMIT)} bytes); later events are not recorded`, + }, + }); + } finally { + await eventLog.close(); + } + }); + it('writes the exit record into the reserve when the log has not stopped', async () => { const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); const lineBytes = ( @@ -798,5 +1094,15 @@ describe('EventLog size limit', () => { await expect( EventLog.open(eventLogPath, { maxBytes: EVENT_LOG_TAIL_RESERVE_BYTES }), ).rejects.toThrow('maxBytes must leave room'); + await expect( + EventLog.open(eventLogPath, { + maxEntries: MAX_EVENT_BUFFER_ENTRIES + 1, + }), + ).rejects.toThrow('maxEntries must leave room'); + await expect( + EventLog.open(eventLogPath, { + maxEntries: EVENT_LOG_TAIL_RESERVE_ENTRIES, + }), + ).rejects.toThrow('maxEntries must leave room'); }); }); diff --git a/test/unit/replay/offlineReplay.test.ts b/test/unit/replay/offlineReplay.test.ts index a83b9cc1..f4cd976f 100644 --- a/test/unit/replay/offlineReplay.test.ts +++ b/test/unit/replay/offlineReplay.test.ts @@ -586,6 +586,27 @@ describe('withOfflineReplayRenderer', () => { expect(backend.disposed).toBe(true); }); + it('keeps the generic message for other event log read failures', async () => { + const { sessionDir } = await createSessionFixture({ + eventsContents: '{"seq":0', + }); + const eventsFile = eventLogPath(sessionDir); + const backend = createMockBackend(); + + const replayPromise = withOfflineReplayRenderer( + { sessionDir }, + () => Promise.resolve('unreachable'), + { backendFactory: () => backend }, + ); + + await expect(replayPromise).rejects.toMatchObject({ + name: 'CliError', + code: ERROR_CODES.REPLAY_ERROR, + message: `Failed to read event log: ${eventsFile}`, + details: {}, + }); + }); + it('wraps non-contiguous event log sequences as REPLAY_ERROR and still disposes the backend', async () => { const { sessionDir } = await createSessionFixture({ events: [ From 6e27e93f8f369a237454553d351169cbc7f2c2a5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 5 Nov 2026 19:23:51 +0000 Subject: [PATCH 03/12] fix: sync the lifecycle coverage record with the event log open options EventLog.open now takes options (maxBytes, maxEntries) and passes the file size and both limits to the constructor. Its file steps are unchanged and in the same order (open for append, chmod, stat, size check, read and parse), so the model's joined EventLog.open and createPty step still describes the code. Only the fingerprint of EventLog.open changes. --- .agents/skills/verify-session-lifecycle/coverage.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-session-lifecycle/coverage.json b/.agents/skills/verify-session-lifecycle/coverage.json index 814eaa86..f5c5a768 100644 --- a/.agents/skills/verify-session-lifecycle/coverage.json +++ b/.agents/skills/verify-session-lifecycle/coverage.json @@ -133,7 +133,7 @@ "src/host/sessionState.ts#SessionState.requestDestroy": "f66074e815527ff1", "src/host/sessionState.ts#SessionState.recordDestroyed": "495b3a20c56b6330", "src/host/sessionState.ts#SessionState.recordExit": "a4f3f6706ae953c3", - "src/host/eventLog.ts#EventLog.open": "2bc70b7c6b669f20", + "src/host/eventLog.ts#EventLog.open": "14d11225d3e4f1c6", "src/host/rpcServer.ts#RpcServer.listen": "aa62e1cd89eceaa4", "src/host/rpcServer.ts#RpcServer.close": "2d8092cceb4b8e13", "src/storage/manifests.ts#readValidatedJsonFile": "6287eb627b09bf80", From bdf03dbeb12c75a17e7de40cd1026ad4e79f92b1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 5 Nov 2026 23:46:25 +0000 Subject: [PATCH 04/12] test: cover the svg export of an event log over the size limit The size message comes from the shared catch in runRecordExportCommand, which the svg branch reaches through the same readEventLogRecords call as asciicast and webm. Add svg to the format table of the too-large test. --- test/unit/commands/record-export.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index 77c87a8f..779703e9 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -1631,7 +1631,7 @@ describe('record export command', () => { expect(mocks.emitSuccess).not.toHaveBeenCalled(); }); - it.each(['asciicast', 'webm'] as const)( + it.each(['asciicast', 'webm', 'svg'] as const)( 'names the size limit when the event log is too large to export as %s', async (format) => { mocks.readEventLogRecords.mockRejectedValue( From 8d3998d00c98d4761da0676a2fccb50bd6aa90ce Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Nov 2026 01:02:32 +0000 Subject: [PATCH 05/12] fix: keep a reopened event log stopped and refuse a full log of blank lines A log that holds its stop marker now starts stopped when it opens, so a small record cannot follow the marker and the log cannot write a second one. A log of blank lines that already leaves less than the tail reserve free holds no record to report a seq for, so open refuses it instead of letting the first dropped append fail an invariant. --- src/host/eventLog.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index adaf1081..8631b655 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -242,6 +242,14 @@ function deriveNextSeq(records: readonly EventRecord[]): number { return lastRecord.seq + 1; } +/** True for the marker that `recordStop` writes when the log stops. */ +function isStopMarker(record: EventRecord): boolean { + return ( + record.type === 'marker' && + record.payload.label.startsWith(EVENT_LOG_LIMIT_LABEL_PREFIX) + ); +} + /** * Returns the event log file size in bytes, or `undefined` when the file * does not exist (ENOENT). Non-ENOENT errors propagate to the caller. @@ -305,9 +313,6 @@ export class EventLog { // append anyway. private fileBytes: number; - // Set once the log has stopped taking records. See `queueAppend`. - private stopped = false; - private constructor( filePath: string, private readonly fileHandle: FileHandle, @@ -316,6 +321,9 @@ export class EventLog { private readonly maxBytes: number, private readonly maxEntries: number, eventBuffer: EventRecord[] = [], + // Set once the log has stopped taking records. See `queueAppend`. A log + // that holds its stop marker starts stopped. + private stopped = false, private isClosed = false, ) { invariant(filePath.length > 0, 'filePath must be a non-empty string'); @@ -372,6 +380,20 @@ export class EventLog { invariant(nextSeq >= 0, 'derived next seq must be non-negative'); } + // A file of blank lines holds no record. When its size is already inside + // the tail reserve, the stop marker may not fit, and a dropped append + // would have no earlier record whose `seq` it could report. This host + // never writes such a file, so refuse it instead of starting a log that + // could not stop. + if ( + eventBuffer.length === 0 && + fileStats.size > maxBytes - EVENT_LOG_TAIL_RESERVE_BYTES + ) { + throw new Error( + `event log ${filePath} holds no record, and its ${String(fileStats.size)} bytes leave less than ${String(EVENT_LOG_TAIL_RESERVE_BYTES)} bytes free under the ${String(maxBytes)}-byte limit`, + ); + } + return new EventLog( filePath, fileHandle, @@ -380,6 +402,7 @@ export class EventLog { maxBytes, maxEntries, eventBuffer, + eventBuffer.some(isStopMarker), ); } catch (error) { await fileHandle.close(); From c916668d87b6acf1f84dfb062ed63e1c6f39e3fc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Nov 2026 01:02:32 +0000 Subject: [PATCH 06/12] test: cover multibyte byte counting, reopening a stopped log and the stop label text - records of 3-byte characters stay within the byte limit - a reopened stopped log drops small records and writes one stop record - a full log of blank lines is refused, and one up to the reserve stops - the stop label is asserted as literal text for both limits --- test/unit/host/eventLog.test.ts | 164 ++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index f3b740ef..b9e1d211 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -781,6 +781,170 @@ describe('EventLog size limit', () => { } }); + it('counts bytes, not characters, toward the limit', async () => { + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + // 100 characters of 3 bytes each: a record is about 300 bytes longer than + // its character count says. + const block = '█'.repeat(100); + + try { + for ( + let index = 0; + index < 100 && stopRecords(eventLog).length === 0; + index += 1 + ) { + await eventLog.append('output', { data: block }); + } + expect(stopRecords(eventLog)).toHaveLength(1); + await eventLog.append('exit', { exitCode: 0, exitSignal: null }); + await eventLog.readAll(); + } finally { + await eventLog.close(); + } + + const lines = (await readFile(eventLogPath, 'utf8')).trimEnd().split('\n'); + const byteLength = (text: string): number => + Buffer.byteLength(`${text}\n`, 'utf8'); + const stopIndex = lines.findIndex((line) => + line.includes(EVENT_LOG_LIMIT_LABEL_PREFIX), + ); + expect(stopIndex).toBeGreaterThan(0); + // Ordinary records stay out of the reserve, measured in bytes. + expect( + lines + .slice(0, stopIndex) + .reduce((sum, line) => sum + byteLength(line), 0), + ).toBeLessThanOrEqual(LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + }); + + it('stays stopped when it opens a log that holds its stop record', async () => { + const first = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await first.append('output', { data: 'prompt$ ' }); + // About 6000 bytes do not fit in the room below the tail reserve. The + // log stops far from the reserve boundary, so a small record would fit. + await first.append('output', { data: 'x'.repeat(6000) }); + await first.readAll(); + expect(stopRecords(first)).toHaveLength(1); + } finally { + await first.close(); + } + + const reopened = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + // A small record still gets no seq, and the log writes no second stop. + await expect(reopened.append('output', { data: 'ok' })).resolves.toBe(1); + await expect( + reopened.append('input_text', { data: 'typed' }), + ).resolves.toBe(1); + // The exit record stays admissible. + await expect( + reopened.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(2); + await reopened.readAll(); + + expect(reopened.getEvents().map((event) => event.type)).toEqual([ + 'output', + 'marker', + 'exit', + ]); + expect(stopRecords(reopened)).toHaveLength(1); + } finally { + await reopened.close(); + } + + const stored = await readEventLogRecords(eventLogPath); + expect(stored.map((event) => event.type)).toEqual([ + 'output', + 'marker', + 'exit', + ]); + }); + + it('refuses a log of blank lines that leaves no room for a stop record', async () => { + // No record, so a dropped append would have no seq to report. + await writeFile(eventLogPath, '\n'.repeat(LIMIT), 'utf8'); + await expect( + EventLog.open(eventLogPath, { maxBytes: LIMIT }), + ).rejects.toThrow('holds no record'); + + await writeFile( + eventLogPath, + '\n'.repeat(LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES + 1), + 'utf8', + ); + await expect( + EventLog.open(eventLogPath, { maxBytes: LIMIT }), + ).rejects.toThrow('holds no record'); + }); + + it('stops with the stop record as the first record of a log of blank lines up to the reserve', async () => { + await writeFile( + eventLogPath, + '\n'.repeat(LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES), + 'utf8', + ); + const eventLog = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + + try { + await expect( + eventLog.append('output', { data: 'x'.repeat(LIMIT) }), + ).resolves.toBe(0); + await expect( + eventLog.append('exit', { exitCode: 0, exitSignal: null }), + ).resolves.toBe(1); + await eventLog.readAll(); + + expect(eventLog.getEvents().map((event) => event.type)).toEqual([ + 'marker', + 'exit', + ]); + expect(await fileBytes()).toBeLessThanOrEqual(LIMIT); + } finally { + await eventLog.close(); + } + }); + + it('words the stop record as the docs quote it, for both limits', async () => { + expect(EVENT_LOG_LIMIT_LABEL_PREFIX).toBe( + 'agent-tty: event log limit reached', + ); + + const bytesLog = await EventLog.open(eventLogPath, { maxBytes: 8192 }); + try { + await fillUntilStopped(bytesLog); + expect(stopRecords(bytesLog)).toMatchObject([ + { + payload: { + label: + 'agent-tty: event log limit reached (8192 bytes); later events are not recorded', + }, + }, + ]); + } finally { + await bytesLog.close(); + } + + await writeFile(eventLogPath, '', 'utf8'); + const entriesLog = await EventLog.open(eventLogPath, { maxEntries: 20 }); + try { + for (let index = 0; index < 20; index += 1) { + await entriesLog.append('output', { data: 'y' }); + } + expect(stopRecords(entriesLog)).toMatchObject([ + { + payload: { + label: + 'agent-tty: event log limit reached (20 events); later events are not recorded', + }, + }, + ]); + } finally { + await entriesLog.close(); + } + }); + it('counts the size of an existing file toward the limit when it opens', async () => { const records = await prefillBytes( LIMIT - EVENT_LOG_TAIL_RESERVE_BYTES - 10, From 9f60639d435f2a032f8df8a3c8cf56792df3a78f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Nov 2026 01:02:32 +0000 Subject: [PATCH 07/12] fix: name the creation size in the asciicast header The header took the manifest's current size, which a resize changes. Replay starts from creationCols/creationRows (falling back to cols/rows), so a resized session got a header that named a size its first output was not written at. A resize that a stopped log dropped made this visible: the cast said 100x30 while svg and webm used 80x24. The header now uses the same initial size as buildReplayInput, and the r lines move the player later. --- src/export/asciicast.ts | 12 +++-- test/unit/export/asciicast.test.ts | 81 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/export/asciicast.ts b/src/export/asciicast.ts index 46feda5c..c4dfa99d 100644 --- a/src/export/asciicast.ts +++ b/src/export/asciicast.ts @@ -57,8 +57,12 @@ export function generateAsciicast( manifest.sessionId === sessionId, 'manifest sessionId must match the export sessionId', ); - invariant(manifest.cols > 0, 'manifest cols must be positive'); - invariant(manifest.rows > 0, 'manifest rows must be positive'); + // The header names the size that the first output was written at: the + // creation size, as in `buildReplayInput`. Later sizes arrive as `r` lines. + const initialCols = manifest.creationCols ?? manifest.cols; + const initialRows = manifest.creationRows ?? manifest.rows; + invariant(initialCols > 0, 'initial cols must be positive'); + invariant(initialRows > 0, 'initial rows must be positive'); const fallbackTimestampMs = parseTimestamp( manifest.createdAt, @@ -82,8 +86,8 @@ export function generateAsciicast( const header: AsciicastHeader = { version: 2, - width: manifest.cols, - height: manifest.rows, + width: initialCols, + height: initialRows, timestamp: Math.floor(firstTimestampMs / 1000), title: sessionId, sessionId, diff --git a/test/unit/export/asciicast.test.ts b/test/unit/export/asciicast.test.ts index ff7d21ac..d6319468 100644 --- a/test/unit/export/asciicast.test.ts +++ b/test/unit/export/asciicast.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { generateAsciicast } from '../../../src/export/asciicast.js'; +import { buildReplayInput } from '../../../src/host/replay.js'; import type { EventRecord, SessionRecord, @@ -207,6 +208,86 @@ describe('generateAsciicast', () => { expect(result.durationMs).toBe(1000); }); + it('names the creation size in the header and the later size in an r line', () => { + // The session started at 80x24 and was resized to 100x30, so the manifest + // holds the final size. The first output was written at 80x24. + const manifest = createManifest({ + cols: 100, + rows: 30, + creationCols: 80, + creationRows: 24, + }); + const events: EventRecord[] = [ + { + seq: 0, + ts: '2026-03-19T12:00:01.000Z', + type: 'output', + payload: { data: 'before' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:02.000Z', + type: 'resize', + payload: { cols: 100, rows: 30 }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:03.000Z', + type: 'output', + payload: { data: 'after' }, + }, + ]; + + const result = generateAsciicast('session-01', manifest, events); + + expect(result.header).toMatchObject({ width: 80, height: 24 }); + expect(parseAsciicastLines(result.contents)).toEqual([ + result.header, + [0, 'o', 'before'], + [1, 'r', '100x30'], + [2, 'o', 'after'], + ]); + + // Replay starts from the same size. + const replay = buildReplayInput('session-01', manifest, events); + expect([result.header.width, result.header.height]).toEqual([ + replay.initialCols, + replay.initialRows, + ]); + }); + + it('names the creation size when a resize after a stopped log was dropped', () => { + // The log stopped before the resize, so it holds no `r` line. The header + // still names the size that the stored output was written at. + const manifest = createManifest({ + cols: 100, + rows: 30, + creationCols: 80, + creationRows: 24, + }); + + const result = generateAsciicast('session-01', manifest, [ + { + seq: 0, + ts: '2026-03-19T12:00:01.000Z', + type: 'output', + payload: { data: 'stored' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:02.000Z', + type: 'marker', + payload: { + label: + 'agent-tty: event log limit reached (52428800 bytes); later events are not recorded', + }, + }, + ]); + + expect(result.header).toMatchObject({ width: 80, height: 24 }); + expect(result.resizeEventCount).toBe(0); + }); + it('produces a header-only cast for empty event logs', () => { const manifest = createManifest({ createdAt: '2026-03-19T12:34:56.000Z', From cff3565f91ceee4acf7c3a923c0947f886dd0c20 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Nov 2026 01:05:22 +0000 Subject: [PATCH 08/12] test: keep other markers from reading as the stop record on open --- test/unit/host/eventLog.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index b9e1d211..77dc2786 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -862,6 +862,30 @@ describe('EventLog size limit', () => { ]); }); + it('does not treat other markers as the stop record when it opens a log', async () => { + const first = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await first.append('output', { data: 'prompt$ ' }); + await first.append('marker', { label: 'checkpoint' }); + } finally { + await first.close(); + } + + const reopened = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await expect(reopened.append('output', { data: 'ok' })).resolves.toBe(2); + await reopened.readAll(); + + expect(reopened.getEvents().map((event) => event.type)).toEqual([ + 'output', + 'marker', + 'output', + ]); + } finally { + await reopened.close(); + } + }); + it('refuses a log of blank lines that leaves no room for a stop record', async () => { // No record, so a dropped append would have no seq to report. await writeFile(eventLogPath, '\n'.repeat(LIMIT), 'utf8'); From 5430efd7246f37769c5cfcf4e86288e74a11f648 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Nov 2026 01:05:22 +0000 Subject: [PATCH 09/12] docs: quote the stop label as written, and describe reopening and the asciicast header size The README and the troubleshooting page said the stop label ends with the limit that stopped the log. The label is 'agent-tty: event log limit reached ( bytes); later events are not recorded', so the limit sits in the middle. The host README now covers a reopened stopped log and the refusal of a full log of blank lines. The export README names the creation size as the asciicast header size. --- README.md | 2 +- docs/TROUBLESHOOTING.md | 2 +- src/export/README.md | 4 ++-- src/host/README.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ea5e93c3..e145d453 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ The `agent-tty` CLI (`src/cli/main.ts`) starts one detached host process per ses - The CLI JSON envelope is the stable automation surface. `src/protocol/envelope.ts` defines it, and `src/cli/exitCodes.ts` maps known error codes to fixed exit codes. Unmapped codes, such as `SKILL_NOT_FOUND`, exit 1. [src/protocol/envelope.ts:CommandEnvelope] [src/cli/exitCodes.ts:exitCodeForError] - Every `.object(...)` schema in `src/protocol/schemas.ts` and `src/protocol/messages.ts` ends in `.strict()`, so unknown keys fail validation. [src/protocol/schemas.ts:SessionRecordSchema] [src/protocol/messages.ts:RpcRequestSchema] -- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB) with `EventLogTooLargeError`. `EventLog.append` never writes the file past that size and never holds more than `MAX_EVENT_BUFFER_ENTRIES` (250,000) entries. Ordinary records stop `EVENT_LOG_TAIL_RESERVE_BYTES` (4,096 bytes) short of the size and `EVENT_LOG_TAIL_RESERVE_ENTRIES` (2) short of the entry cap. The first record that does not fit makes the log append one `marker` record that starts with `EVENT_LOG_LIMIT_LABEL_PREFIX` and ends with the limit that stopped the log (` bytes` or ` events`), and from then on the log stores only the `exit` record. The program keeps running, and the host acknowledges every other event with the `seq` of the last stored record, so later output, input, resizes, signals and markers appear in no snapshot, screenshot or export. Live features that replay the log see the program as it was at the stop: `wait --screen-stable-ms` can report a stable screen while the program keeps printing, and a mouse request encodes against the mouse modes in the stored log. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [src/host/eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:EventLogTooLargeError] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE] +- The event log is append-only with contiguous sequence numbers. `EventLog.append` asserts that each new `seq` equals the buffer length. `src/storage/eventLogCodec.ts` requires `seq` to start at 0 and rise by 1, and rejects opening or reading a log larger than `MAX_EVENT_LOG_SIZE` (50 MB) with `EventLogTooLargeError`. `EventLog.append` never writes the file past that size and never holds more than `MAX_EVENT_BUFFER_ENTRIES` (250,000) entries. Ordinary records stop `EVENT_LOG_TAIL_RESERVE_BYTES` (4,096 bytes) short of the size and `EVENT_LOG_TAIL_RESERVE_ENTRIES` (2) short of the entry cap. The first record that does not fit makes the log append one `marker` record whose label starts with `EVENT_LOG_LIMIT_LABEL_PREFIX` and reads `agent-tty: event log limit reached ( bytes); later events are not recorded` (` events` in place of ` bytes` when the entry cap stopped the log), and from then on the log stores only the `exit` record. The program keeps running, and the host acknowledges every other event with the `seq` of the last stored record, so later output, input, resizes, signals and markers appear in no snapshot, screenshot or export. Live features that replay the log see the program as it was at the stop: `wait --screen-stable-ms` can report a stable screen while the program keeps printing, and a mouse request encodes against the mouse modes in the stored log. [src/host/eventLog.ts:EventLog] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [src/host/eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [src/host/eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [src/host/eventLog.ts:MAX_EVENT_BUFFER_ENTRIES] [src/storage/eventLogCodec.ts:parseEventLogContent] [src/storage/eventLogCodec.ts:assertEventLogSize] [src/storage/eventLogCodec.ts:EventLogTooLargeError] [src/storage/eventLogCodec.ts:MAX_EVENT_LOG_SIZE] - Session, socket and artifact paths come from guarded builders that reject path separators and `.` or `..` session IDs. [src/storage/sessionPaths.ts:sessionDir] [src/storage/artifactPaths.ts:artifactPath] - `session.json`, the artifact manifest and the Home registry are written through `writeTextFileAtomic`: a temp file with mode `0o600`, then a rename. [src/storage/manifests.ts:writeTextFileAtomic] - `gc` deletes only sessions whose status is collectable (`exited`, `failed` or `destroyed`) after `reconcileSession`, and it re-reads the manifest before each delete. [src/cli/commands/gc.ts:gcSessions] [src/protocol/sessionStatusPolicy.ts:getSessionStatusPolicy] diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index dbae1dbf..061a3cb5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -99,7 +99,7 @@ To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh' ## A Recording Ends Early, or an Export Names the Event Log Limit -Each session records its terminal output in `events.jsonl`. The log stops at the first of two limits: 52,428,800 bytes (50 MB), or 250,000 events. A program that prints a lot reaches the byte limit. A program that prints in many tiny chunks reaches the event limit long before 50 MB. When the log stops, the host writes one marker and after it records only the program's exit. The marker begins `agent-tty: event log limit reached` and ends with the limit that stopped the log: `(52428800 bytes)` or `(250000 events)`. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker. An asciicast export ends with the marker. +Each session records its terminal output in `events.jsonl`. The log stops at the first of two limits: 52,428,800 bytes (50 MB), or 250,000 events. A program that prints a lot reaches the byte limit. A program that prints in many tiny chunks reaches the event limit long before 50 MB. When the log stops, the host writes one marker and after it records only the program's exit. The marker reads `agent-tty: event log limit reached (52428800 bytes); later events are not recorded` when the byte limit stopped the log, and `agent-tty: event log limit reached (250000 events); later events are not recorded` when the event limit did. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker. An asciicast export ends with the marker. Commands on a live session read the stored log too, so after the marker they see the session as it was at the marker: diff --git a/src/export/README.md b/src/export/README.md index 894a8800..876ffdfa 100644 --- a/src/export/README.md +++ b/src/export/README.md @@ -6,7 +6,7 @@ This directory converts a session's event log into recording exports. `generateA ## Interface -- `AsciicastHeader`: `version: 2`, `width`, `height`, `timestamp`, `title`, optional `sessionId` and `toolVersion`, and `env.TERM` [asciicast.ts:AsciicastHeader]. +- `AsciicastHeader`: `version: 2`, `width`, `height`, `timestamp`, `title`, optional `sessionId` and `toolVersion`, and `env.TERM`. `width` and `height` are the creation size of the session (`manifest.creationCols` and `manifest.creationRows`, or `manifest.cols` and `manifest.rows` when they are absent), the size that replay starts from. A later `resize` event becomes an `r` line, so a resized session keeps its first size in the header [asciicast.ts:AsciicastHeader]. - `AsciicastExport`: `contents`, `header`, `capturedAtSeq`, `durationMs`, `outputEventCount`, `resizeEventCount` and `markerCount` [asciicast.ts:AsciicastExport]. - `generateAsciicast(sessionId, manifest, events, toolVersion?)` returns an `AsciicastExport`. It runs synchronously [asciicast.ts:generateAsciicast]. - `WebmExportOptions`: `sessionId`, `sessionDir`, `manifest`, `events`, `outputPath`, and optional `targetSeq`, `profileName`, `timingMode` and `rendererName` [webm.ts:WebmExportOptions]. @@ -17,7 +17,7 @@ This directory converts a session's event log into recording exports. `generateA ## Invariants -- `generateAsciicast` asserts a non-empty `sessionId` equal to `manifest.sessionId`, positive `manifest.cols` and `manifest.rows`, and a non-empty `toolVersion` when given [asciicast.ts:generateAsciicast]. `manifest.createdAt` must parse as a valid timestamp even when there are events. The header uses it when `events` is empty [asciicast.ts:parseTimestamp]. +- `generateAsciicast` asserts a non-empty `sessionId` equal to `manifest.sessionId`, a positive initial width and height (`manifest.creationCols ?? manifest.cols` and `manifest.creationRows ?? manifest.rows`, as in `buildReplayInput`), and a non-empty `toolVersion` when given [asciicast.ts:generateAsciicast]. `manifest.createdAt` must parse as a valid timestamp even when there are events. The header uses it when `events` is empty [asciicast.ts:parseTimestamp]. - Event timestamps must parse to finite numbers [asciicast.ts:parseTimestamp]. They must not decrease from one event to the next [asciicast.ts:generateAsciicast]. - Event mapping: `output` becomes `o`, `resize` becomes `r` with `COLSxROWS`, and `marker` becomes `m`. Input, run, signal and exit events are skipped. An unknown type reaches `unreachable()` [asciicast.ts:generateAsciicast]. - `generateWebmExport` asserts a non-empty `sessionId`, absolute `sessionDir` and `outputPath`, and positive initial dimensions. An empty event list gives a blank video with `capturedAtSeq` -1 and `durationMs` 0 [webm.ts:generateWebmExport]. diff --git a/src/host/README.md b/src/host/README.md index aabbdd21..31d4e292 100644 --- a/src/host/README.md +++ b/src/host/README.md @@ -44,7 +44,7 @@ ## Invariants -- `EventLog.open` derives the next `seq` from the last record (0 for an empty file), using `assertEventLogSize` and `parseEventLogContent` from `src/storage/eventLogCodec.ts`. `options.maxBytes` sets the size limit of the file. It defaults to `MAX_EVENT_LOG_SIZE`, must exceed `EVENT_LOG_TAIL_RESERVE_BYTES` and cannot exceed `MAX_EVENT_LOG_SIZE`, so the writer never passes what readers accept. `options.maxEntries` sets the limit of the number of events. It defaults to `MAX_EVENT_BUFFER_ENTRIES`, must exceed `EVENT_LOG_TAIL_RESERVE_ENTRIES` and cannot exceed `MAX_EVENT_BUFFER_ENTRIES`. Tests inject small limits. `append` asserts that the new `seq` equals the buffer length and validates the record with `EventRecordSchema`, so sequence numbers stay contiguous. [eventLog.ts:EventLog] [eventLog.ts:EventLogOptions] +- `EventLog.open` derives the next `seq` from the last record (0 for an empty file), using `assertEventLogSize` and `parseEventLogContent` from `src/storage/eventLogCodec.ts`. `options.maxBytes` sets the size limit of the file. It defaults to `MAX_EVENT_LOG_SIZE`, must exceed `EVENT_LOG_TAIL_RESERVE_BYTES` and cannot exceed `MAX_EVENT_LOG_SIZE`, so the writer never passes what readers accept. `options.maxEntries` sets the limit of the number of events. It defaults to `MAX_EVENT_BUFFER_ENTRIES`, must exceed `EVENT_LOG_TAIL_RESERVE_ENTRIES` and cannot exceed `MAX_EVENT_BUFFER_ENTRIES`. Tests inject small limits. `append` asserts that the new `seq` equals the buffer length and validates the record with `EventRecordSchema`, so sequence numbers stay contiguous. A log that already holds its stop `marker` (a label that starts with `EVENT_LOG_LIMIT_LABEL_PREFIX`) starts stopped, so a reopened log stores only the `exit` record and writes no second stop record. A file of blank lines holds no record. When its size leaves less than `EVENT_LOG_TAIL_RESERVE_BYTES` free under `maxBytes`, `open` throws, because a stop record might not fit and a dropped append would have no earlier `seq` to report. [eventLog.ts:EventLog] [eventLog.ts:EventLogOptions] [eventLog.ts:EVENT_LOG_LIMIT_LABEL_PREFIX] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] - At either limit, the file size or the number of events, `EventLog.append` stops the log. Ordinary records may fill the file up to `maxBytes` minus `EVENT_LOG_TAIL_RESERVE_BYTES`, and the buffer up to `maxEntries` minus `EVENT_LOG_TAIL_RESERVE_ENTRIES` (one entry for the stop record and one for the `exit` record). The first record that does not fit makes the log append one `marker` record with the label `agent-tty: event log limit reached (); later events are not recorded`, inside the reserves. `` is ` bytes` when the file size stopped the log and ` events` when the event count stopped it, so a reader can tell the two apart. A record that passes both limits is reported as the byte limit. From then on the log stores only the `exit` record, which also fits in the reserves, so the file never passes the byte limit and the buffer never passes the entry limit, and the host does not die from either. A record that the log does not store takes no `seq`. `append` resolves with the `seq` of the last stored record, and `startAppend` returns it as `seq`. Handlers, waited runs and `resize` therefore keep working, and the program keeps running. Its later output, input, resizes, signals and markers are in no snapshot, screenshot or export, because those replay the log. Every live feature that replays the log sees the program as it was at the stop: a live `wait` for text that appears after the stop times out, `wait --screen-stable-ms` can report a stable screen while the program keeps printing, and a mouse request after the stop encodes against the mouse modes that the stored log shows, not against modes that the program enabled or disabled after the stop (`reported` is false when the stored modes have mouse tracking off). `wait --idle-ms` and `wait --exit` follow the live program and are not affected. Once a write has failed, a dropped append rejects like any other. [eventLog.ts:EventLog] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_BYTES] [eventLog.ts:EVENT_LOG_TAIL_RESERVE_ENTRIES] [mouseRpc.ts:createMouseRpcHandler] - `EventLog.append` validates each payload with a strict Zod schema for its event type. It throws after `close()` and after an earlier write failed (the write queue stays rejected). It does not throw at a size limit or at `MAX_EVENT_BUFFER_ENTRIES`: the log stops instead. A failed write removes the failed records from the buffer. [eventLog.ts:EventLog] - `SessionState` enforces its transitions: `setChildPid` and `setDimensions` need status `running`, and the child PID is set once. `SessionState` has no method that sets `hostPid`. `requestDestroy` moves `running`, `exited` or `failed` to `destroying`. `recordDestroyed` needs `destroying`. `recordExit` needs `running` or `exiting` and no earlier exit. [sessionState.ts:SessionState] From e3d537f604ccde05c38fb852b6af44faa4066d95 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Nov 2026 01:09:01 +0000 Subject: [PATCH 10/12] fix: sync the lifecycle coverage record with the reopened event log EventLog.open now starts a log that holds its stop marker as stopped, and refuses a file of blank lines that has no room for a stop record. It still opens the file for append, chmods, stats, checks the size and reads and parses in that order, and any refusal closes the handle and throws, so the model's joined EventLog.open and createPty step still describes the code. --- .agents/skills/verify-session-lifecycle/coverage.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/verify-session-lifecycle/coverage.json b/.agents/skills/verify-session-lifecycle/coverage.json index 70088589..91f0e7ef 100644 --- a/.agents/skills/verify-session-lifecycle/coverage.json +++ b/.agents/skills/verify-session-lifecycle/coverage.json @@ -133,7 +133,7 @@ "src/host/sessionState.ts#SessionState.requestDestroy": "f66074e815527ff1", "src/host/sessionState.ts#SessionState.recordDestroyed": "495b3a20c56b6330", "src/host/sessionState.ts#SessionState.recordExit": "a4f3f6706ae953c3", - "src/host/eventLog.ts#EventLog.open": "14d11225d3e4f1c6", + "src/host/eventLog.ts#EventLog.open": "4d4a1c04ce17c37d", "src/host/rpcServer.ts#RpcServer.listen": "aa62e1cd89eceaa4", "src/host/rpcServer.ts#RpcServer.close": "2d8092cceb4b8e13", "src/storage/manifests.ts#readValidatedJsonFile": "6287eb627b09bf80", From 964de3c0a8246546f8e900614c45709083432fb8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Nov 2026 03:01:02 +0000 Subject: [PATCH 11/12] test: pin the blank-log refusal text, reopening after the exit record, and the stop label prefix --- test/unit/host/eventLog.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index 77dc2786..82e05863 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -854,6 +854,17 @@ describe('EventLog size limit', () => { await reopened.close(); } + // A log whose last record is the exit record after the stop also stays + // stopped. + const again = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await expect(again.append('output', { data: 'ok' })).resolves.toBe(2); + await again.readAll(); + expect(stopRecords(again)).toHaveLength(1); + } finally { + await again.close(); + } + const stored = await readEventLogRecords(eventLogPath); expect(stored.map((event) => event.type)).toEqual([ 'output', @@ -867,18 +878,23 @@ describe('EventLog size limit', () => { try { await first.append('output', { data: 'prompt$ ' }); await first.append('marker', { label: 'checkpoint' }); + // Names the limit, but without the stop record's prefix. + await first.append('marker', { + label: 'event log limit reached (8192 bytes)', + }); } finally { await first.close(); } const reopened = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); try { - await expect(reopened.append('output', { data: 'ok' })).resolves.toBe(2); + await expect(reopened.append('output', { data: 'ok' })).resolves.toBe(3); await reopened.readAll(); expect(reopened.getEvents().map((event) => event.type)).toEqual([ 'output', 'marker', + 'marker', 'output', ]); } finally { @@ -891,7 +907,9 @@ describe('EventLog size limit', () => { await writeFile(eventLogPath, '\n'.repeat(LIMIT), 'utf8'); await expect( EventLog.open(eventLogPath, { maxBytes: LIMIT }), - ).rejects.toThrow('holds no record'); + ).rejects.toThrow( + `event log ${eventLogPath} holds no record, and its ${String(LIMIT)} bytes leave less than ${String(EVENT_LOG_TAIL_RESERVE_BYTES)} bytes free under the ${String(LIMIT)}-byte limit`, + ); await writeFile( eventLogPath, From d9e2d3d94c355a68e6b66a1f6c3e2645af6cd5cc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 6 Nov 2026 03:09:06 +0000 Subject: [PATCH 12/12] fix: reserve the stop record's label prefix so a user marker cannot stop a reopened log --- docs/TROUBLESHOOTING.md | 2 +- src/host/eventLog.ts | 13 +++++++++++++ test/unit/host/eventLog.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 061a3cb5..319825e9 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -99,7 +99,7 @@ To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh' ## A Recording Ends Early, or an Export Names the Event Log Limit -Each session records its terminal output in `events.jsonl`. The log stops at the first of two limits: 52,428,800 bytes (50 MB), or 250,000 events. A program that prints a lot reaches the byte limit. A program that prints in many tiny chunks reaches the event limit long before 50 MB. When the log stops, the host writes one marker and after it records only the program's exit. The marker reads `agent-tty: event log limit reached (52428800 bytes); later events are not recorded` when the byte limit stopped the log, and `agent-tty: event log limit reached (250000 events); later events are not recorded` when the event limit did. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker. An asciicast export ends with the marker. +Each session records its terminal output in `events.jsonl`. The log stops at the first of two limits: 52,428,800 bytes (50 MB), or 250,000 events. A program that prints a lot reaches the byte limit. A program that prints in many tiny chunks reaches the event limit long before 50 MB. When the log stops, the host writes one marker and after it records only the program's exit. The marker reads `agent-tty: event log limit reached (52428800 bytes); later events are not recorded` when the byte limit stopped the log, and `agent-tty: event log limit reached (250000 events); later events are not recorded` when the event limit did. The program keeps running. Its later output, input, resizes and signals are not recorded, so snapshots, screenshots and recordings show the session as it was at the marker. An asciicast export ends with the marker. `mark` refuses a label that starts with `agent-tty: event log limit reached` with `INVALID_INPUT`, because only the host writes that marker. Commands on a live session read the stored log too, so after the marker they see the session as it was at the marker: diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index 8631b655..96482fdc 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -22,6 +22,7 @@ import { MAX_EVENT_LOG_SIZE, parseEventLogContent, } from '../storage/eventLogCodec.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; import { invariant } from '../util/assert.js'; import { hasErrorCode } from '../util/hasErrorCode.js'; @@ -443,6 +444,18 @@ export class EventLog { type: EventLogEventType, payload: EventLogPayload, ): Promise { + // Only `recordStop` writes the stop marker, so a reopened log can trust a + // marker with this prefix (see `isStopMarker`). + if ( + type === 'marker' && + (payload as MarkerEventPayload).label.startsWith( + EVENT_LOG_LIMIT_LABEL_PREFIX, + ) + ) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: `A marker label must not start with "${EVENT_LOG_LIMIT_LABEL_PREFIX}": agent-tty reserves it for the record that stops the event log.`, + }); + } return await this.queueAppend(type, payload).written; } diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts index 82e05863..bd5ca288 100644 --- a/test/unit/host/eventLog.test.ts +++ b/test/unit/host/eventLog.test.ts @@ -873,6 +873,33 @@ describe('EventLog size limit', () => { ]); }); + it('refuses a marker label that starts with the stop record prefix', async () => { + const log = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await log.append('output', { data: 'prompt$ ' }); + await expect( + log.append('marker', { + label: `${EVENT_LOG_LIMIT_LABEL_PREFIX} (8192 bytes); later events are not recorded`, + }), + ).rejects.toMatchObject({ + code: 'INVALID_INPUT', + message: `A marker label must not start with "${EVENT_LOG_LIMIT_LABEL_PREFIX}": agent-tty reserves it for the record that stops the event log.`, + }); + await expect(log.append('marker', { label: 'ok' })).resolves.toBe(1); + } finally { + await log.close(); + } + + const reopened = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); + try { + await expect(reopened.append('output', { data: 'more' })).resolves.toBe( + 2, + ); + } finally { + await reopened.close(); + } + }); + it('does not treat other markers as the stop record when it opens a log', async () => { const first = await EventLog.open(eventLogPath, { maxBytes: LIMIT }); try {