summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/phase-4.md95
1 files changed, 67 insertions, 28 deletions
diff --git a/docs/phase-4.md b/docs/phase-4.md
index 6c4f9b9..9dac510 100644
--- a/docs/phase-4.md
+++ b/docs/phase-4.md
@@ -12,7 +12,7 @@ A session persistence system where pantograph saves and resumes conversations. A
- Restart `panto --resume` and continue the conversation exactly where you left off.
- Restart `panto --resume <id>` to resume a specific session.
- Inspect the JSONL session file to see every event that occurred, including which provider/model handled each turn.
-- Have pantograph recover gracefully from a crash — the corrupted trailing line is removed, and the session loads from the last valid entry.
+- Have pantograph recover gracefully from a crash — any corrupted trailing line is removed, and the session loads from the last valid entry.
## What is usable at the end
@@ -22,7 +22,7 @@ A session persistence system where pantograph saves and resumes conversations. A
| Resume most recent | `panto --resume` — continues the most recent session for the current working directory |
| Resume specific session | `panto --resume <id>` — opens the session with the given ID (or unique prefix) |
| List sessions | `panto sessions` — lists sessions for the current working directory |
-| Crash recovery | Kill `panto` mid-turn; restart with `--resume`; corrupted trailing line is removed, session loads from last valid entry |
+| Crash recovery | Kill `panto` mid-turn; restart with `--resume`; corrupted trailing line is truncated, session loads from last valid entry |
## What is explicitly out of scope
@@ -69,21 +69,27 @@ The event log is the authoritative record of a session. It captures everything t
### File Location
```
-~/.local/share/panto/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
+~/.local/share/panto/sessions/<encoded-cwd>/<uuid>.jsonl
```
Where `<encoded-cwd>` is the working directory with `/` replaced by `--`. This groups sessions by project directory, making it easy to find sessions for a given project.
+The filename is just the session's UUIDv7. Because UUIDv7 is time-ordered, sorting filenames lexicographically sorts sessions chronologically — no separate timestamp prefix is needed.
+
The base directory respects `XDG_DATA_HOME`, falling back to `~/.local/share` if unset (per the XDG Base Directory specification).
Example:
```
-~/.local/share/panto/sessions/--Users-travis-Code-pantograph--/2026-04-25T17-40-15-990Z_019dc5ba-53f6-71a5-ab8f-b1f8709c2572.jsonl
+~/.local/share/panto/sessions/--Users-travis-Code-pantograph--/019dc5ba-53f6-71a5-ab8f-b1f8709c2572.jsonl
```
### Crash Recovery
-On loading a session, if the last line of the file does not deserialize as a complete JSON object, it is deleted. The process does not refuse to start — it trims the corruption and loads from the last valid entry. This handles the common case where a process is killed mid-write.
+On loading a session, pantograph parses lines one by one from the top. As soon as a line fails to parse as a complete JSON object, **that line and every line after it are truncated from the file**. The session loads from the last fully valid entry.
+
+This is deliberately stricter than "skip malformed lines." Tool-use/tool-result pairs are stateful — a missing or partial entry in the middle would leave subsequent entries referring to a `toolUseId` that doesn't exist, which providers (notably Anthropic) reject outright. The only safe resume point is immediately before the first corruption.
+
+In practice the corruption window is the tail of the file (a crash mid-write), so truncation is effectively the same as "drop the partial last line." Defining the rule by position-of-first-error rather than "last line" handles edge cases (e.g., a partial write followed by a clean flush by another process) without ambiguity.
---
@@ -91,7 +97,7 @@ On loading a session, if the last line of the file does not deserialize as a com
### SessionHeader
-First line of the file. Metadata only — not part of the tree (no `id`/`parentId`).
+First (and only) header line of the file. Metadata only — not part of the tree (no `id`/`parentId`). Exactly one header per file, always at the top.
```json
{
@@ -107,8 +113,8 @@ First line of the file. Metadata only — not part of the tree (no `id`/`parentI
| Field | Type | Purpose |
|---|---|---|
-| `version` | number | Format version (1 for phase 4). Future format changes increment this. |
-| `id` | string | Session UUID. |
+| `version` | number | Format version. `1` for phase 4. |
+| `id` | string | Session UUIDv7. |
| `timestamp` | string | ISO 8601 timestamp of session creation. |
| `cwd` | string | Working directory where the session was started. |
| `provider` | string | Initial provider (`"openai"` or `"anthropic"`). |
@@ -116,6 +122,12 @@ First line of the file. Metadata only — not part of the tree (no `id`/`parentI
The `provider` and `model` fields serve as the baseline for model tracking. If no user messages have been submitted yet, the header's values are the active provider/model.
+### Versioning and migration
+
+The `version` field exists from day one so future format changes are explicit. Phase 4 is version 1.
+
+When a future pantograph version bumps the format to 2+, the load path runs `migrate(entries) -> bool`. If migration mutates anything, the entire file is rewritten once with the new version stamped on the header. Migration is eager: every v1 file encountered by a v2-capable pantograph is upgraded immediately on first load. There is never a mixed-version file.
+
### EntryBase
All entries (except the header) extend this base:
@@ -329,7 +341,7 @@ If the session was interrupted mid-turn, the log may contain an assistant messag
## Persistence Strategy
-**Append-only.** Entries are appended to the JSONL file as they occur. No full-file rewrite during normal operation. Each agent turn appends one or more entries:
+**Append-only.** Entries are appended to the JSONL file as they occur. No full-file rewrite during normal operation (except one-time format migration). Each agent turn appends one or more entries:
- A user message entry (when the user submits a prompt, or when tool results are assembled)
- Provider/model stamped on user message entries (top-level fields on every user message)
@@ -337,9 +349,16 @@ If the session was interrupted mid-turn, the log may contain an assistant messag
A single agent turn with tool calls appends multiple rounds of these entries.
-**File creation.** The session file is created when the session is initialized (header written). If `pantograph` starts and the user immediately quits without the session being initialized, no file is created.
+**Flush-per-entry.** Once the file exists, every completed entry is written and flushed to disk as soon as it is assembled. This maximizes recoverability — at most one in-flight entry is ever lost to a crash.
+
+**Deferred file creation.** The session file is **not** created at startup. Pantograph buffers the header and any pre-assistant entries in memory until the first assistant message completes. At that moment the buffered entries are flushed together and persistence switches to append-per-entry mode. If the user quits before the first assistant response, no file is created — the sessions directory stays clean of empty/single-prompt detritus.
-**No explicit save command.** Persistence is automatic. Every event is written to disk as it happens.
+This is tracked by a `flushed: bool` flag on the `SessionManager`:
+- `flushed = false`: entries accumulate in memory; nothing on disk yet.
+- First assistant message completes → write header + all buffered entries → set `flushed = true`.
+- `flushed = true`: every subsequent entry is appended and flushed immediately on completion.
+
+**No explicit save command.** Persistence is automatic.
---
@@ -358,8 +377,8 @@ Defines the on-disk types and their JSON serialization. These are separate from
```
SessionHeader = struct {
- version: u32,
- id: []const u8, // UUID string, owned
+ version: u32, // always 1 in phase 4
+ id: []const u8, // UUIDv7 string, owned
timestamp: []const u8, // ISO 8601, owned
cwd: []const u8, // owned
provider: []const u8, // owned
@@ -459,20 +478,24 @@ All `[]const u8` fields in on-disk types are owned copies. `deinit()` frees them
```
SessionManager = struct {
session_id: []const u8,
- session_file: ?[]const u8, // path to JSONL file
+ session_file: ?[]const u8, // path to JSONL file (set even before flush)
session_dir: []const u8, // directory containing session files for this cwd
cwd: []const u8,
entries: std.ArrayList(SessionEntry),
by_id: std.StringHashMap(*const SessionEntry),
leaf_id: ?[]const u8,
+ flushed: bool, // false until first assistant message persists
allocator: std.mem.Allocator,
// ── Creation ──
- /// Create a new session. Writes the header to disk.
+ /// Create a new session. Allocates a UUIDv7, computes the target file
+ /// path, but does NOT write to disk yet (see deferred-file-creation).
pub fn init(allocator, cwd, provider, model) !SessionManager
/// Open an existing session file, replay the log, rebuild state.
+ /// Truncates from the first corrupted line onward.
+ /// Runs format migration if needed and rewrites the file once.
pub fn open(allocator, path) !SessionManager
/// Find and open the most recent session for the given cwd.
@@ -481,14 +504,17 @@ SessionManager = struct {
pub fn deinit(self) void
- // ── Appending (writes to disk immediately) ──
+ // ── Appending ──
/// Append a message entry as child of the current leaf.
+ /// Generates an 8-char hex id (collision-checked against by_id).
+ /// If flushed: writes and fsyncs the new line immediately.
+ /// If not flushed and the message is an assistant message: writes the
+ /// header + all buffered entries + the new entry, then sets flushed.
+ /// Otherwise (not flushed, non-assistant): buffers in memory only.
/// Returns the new entry's id.
pub fn appendMessage(self, message: DiskMessage) ![]const u8
-
-
// ── Tree traversal ──
pub fn getLeafId(self) ?[]const u8
@@ -511,14 +537,19 @@ SessionManager = struct {
// ── Session listing ──
- /// List sessions for a given cwd.
- pub fn listSessions(allocator, cwd) ![]SessionInfo
+ /// List sessions for a given cwd. Loads files concurrently via Io.concurrent.
+ /// `on_progress` is called as each file finishes parsing (loaded, total).
+ pub fn listSessions(
+ allocator,
+ cwd,
+ on_progress: ?*const fn (loaded: usize, total: usize) void,
+ ) ![]SessionInfo
// ── Crash recovery ──
- /// Remove the last line of the file if it doesn't parse as complete JSON.
- /// Called during open().
- fn trimCorruptedTrailingLine(file_path: []const u8) !void
+ /// Truncate the file at the first line that fails to parse as a complete
+ /// JSON object. Called during open() before parsing entries.
+ fn truncateFromFirstCorruption(file_path: []const u8) !void
};
SessionInfo = struct {
@@ -597,10 +628,18 @@ Output:
---
+## Resolved Design Decisions
+
+The following were open questions in an earlier draft. Recording the resolutions here so they don't get re-litigated.
+
+1. **Entry ID generation**: 8-char hex IDs, generated randomly, **collision-checked** against `by_id` (the in-memory index of every entry in the session). Retry up to 100 times; in the vanishingly unlikely event of repeated collisions, fall back to a full UUID. `by_id` lookup is O(1), so the check is effectively free.
+2. **Timestamp precision**: ISO 8601 with millisecond precision. Format Zig's nanoseconds down to milliseconds.
+3. **Session ID**: UUIDv7 (time-ordered). Filenames are `<uuid>.jsonl` with no separate timestamp prefix — lexicographic filename sort equals chronological sort.
+4. **File locking**: Not needed. The crash-recovery rule (truncate from first corruption) handles interleaved writes safely: any garbled tail is discarded on next open. Two pantograph processes resuming the same session simultaneously is rare and self-corrects to whichever process wrote last cleanly.
+5. **Session directory creation**: Lazy. Created on first flush (alongside the file itself). Avoids creating empty directory trees when pantograph is invoked transiently (e.g., `panto --help`, `panto sessions` on a fresh install).
+
## Open Questions (to resolve during implementation)
-1. **Entry ID generation**: 8-char hex IDs give ~4 billion possibilities. Collision probability within a single session is negligible, but should we verify uniqueness against existing entries, or trust the randomness?
-2. **Timestamp precision**: ISO 8601 with millisecond precision is sufficient. Zig's `std.time` provides nanosecond precision — we format to milliseconds.
-3. **File locking**: If two `pantograph` processes try to append to the same session file simultaneously, lines could interleave and corrupt the file. Should we use `flock` for mutual exclusion? Or is this not worth worrying about in phase 4?
-4. **Large session files**: A long coding session can produce thousands of entries and megabytes of JSONL. Replaying the entire log on every resume could become slow. Should we cache the rebuilt Conversation state (e.g., as a checkpoint line at the end of the file) and only replay new entries on subsequent loads? Or defer this optimization?
-5. **Session directory creation**: Should `pantograph` create the session directory tree eagerly on startup, or lazily when the first session is created?
+1. **Large session files**: A long coding session can produce thousands of entries and megabytes of JSONL. Replaying the entire log on every resume could become slow. Should we cache the rebuilt `Conversation` state (e.g., as a checkpoint line at the end of the file) and only replay new entries on subsequent loads? **Defer**: measure first; add only if real sessions hit a noticeable load delay.
+2. **`fsync` cadence**: We flush after every completed entry (per the persistence strategy). Do we also `fsync` the file handle, or trust the kernel's writeback? `fsync` is safer but slower. Likely settle on `fsync` after assistant messages and tool-result user messages (the entries that matter for resume), not after every streaming-derived write — but phase 4 only writes once per completed entry, so a single `fsync` per `appendMessage` is probably fine.
+3. **Concurrent listing**: Use `Io.concurrent` with what concurrency cap? Pi uses 10. Probably mirror that until we have data.