| 1 | # Session ownership, rewind, and worktree fallback |
| 2 | |
| 3 | How Reasonix decides who may write a session, how conflicts are saved, and |
| 4 | how rewind and workspace isolation interact. |
| 5 | |
| 6 | ## Session writers |
| 7 | |
| 8 | A conversation lives in an append-only event log, `<id>.events.jsonl` |
| 9 | (session format 2). Every message entry carries its own id and the id of its |
| 10 | parent, so the log is a DAG: each path from a leaf back to the root is one |
| 11 | version of the conversation, called a *head*. A log starts with one head, |
| 12 | `main`; forks, rewinds, and concurrent writers add heads of kind `fork`, |
| 13 | `rewind`, and `concurrent`. The transcript file `<id>.jsonl` is a derived cache |
| 14 | of the selected head and is never the source of truth. |
| 15 | |
| 16 | The selected head is the last `select` marker that still points at a live |
| 17 | head, otherwise the head with the newest activity. Opening a session by path |
| 18 | opens that head. `.event-index.json` mirrors the heads so the session catalog |
| 19 | can list them without replaying the log. |
| 20 | |
| 21 | Writers append; they never rewrite or truncate the log. A save takes the |
| 22 | bounded `.jsonl.lock` flock, reads whatever another writer appended since its |
| 23 | own last save, and continues on its own head. The session lease |
| 24 | (`.lease.lock`, with a generation-bound `SessionWriter`) no longer gates |
| 25 | appending: it decides who writes the derived `.jsonl`, the indexes, and the |
| 26 | turn ledger, so a second window can join the same conversation without |
| 27 | waiting. A turn opens with a `turn_begin` marker and closes with `turn_end`; |
| 28 | a crash between them is noticed on the next open and the incomplete tail is |
| 29 | set aside with a `rewind` marker, never truncated. When the shutdown save |
| 30 | cannot take the save lock within its bounded wait, it appends the unsaved |
| 31 | tail without the lock on a fresh `concurrent` head and leaves the derived |
| 32 | files to the next locked save; a shutdown never writes a copy of the session. |
| 33 | |
| 34 | Path changes (`new`, `clear`) still use the prepare-before-publish handoff: |
| 35 | the frontend acquires the target lease and binds the unpublished Session |
| 36 | before the controller swaps paths. `fork`, `branch`, `switch`, and |
| 37 | conversation rewind stay on the same path and move between heads instead. |
| 38 | |
| 39 | Sessions saved before Reasonix 1.39.0 use format 1: a whole-file transcript |
| 40 | plus a position-based event log. A 1.39.0 or newer binary upgrades such a |
| 41 | session in place on its first save, once it can prove it is the only writer; |
| 42 | until then the session keeps the format-1 rules below. A binary older than |
| 43 | 1.39.0 refuses to open a format-2 log and leaves the file untouched; |
| 44 | `reasonix doctor session <id> --export-v1 PATH.jsonl` writes the current head |
| 45 | back out as a format-1 session when a rollback needs it. |
| 46 | |
| 47 | ## Conflicts |
| 48 | |
| 49 | Two processes appending to one format-2 log cannot conflict; they interleave. |
| 50 | When a save finds that another writer extended the chain this session was on |
| 51 | and this session added nothing, it follows the disk. When both added content, |
| 52 | the save forks a `concurrent` head from the last shared message and continues |
| 53 | there; both sides receive a notice (`session_concurrent_writer`). Reloading |
| 54 | the log afterwards opens the newest head and lists the other one under *View |
| 55 | versions* (`session_head_switched`). Nothing is copied into a `-recovery-` |
| 56 | file, and a save never removes a head. |
| 57 | |
| 58 | Format-1 sessions keep the previous rules until they are upgraded: |
| 59 | |
| 60 | 1. Event-log tail still matches this writer → normal save (no-op / append / replace). |
| 61 | 2. Disk already covers the local prefix → adopt disk, no branch. |
| 62 | 3. True divergence, replaced log, or deleted original → one stable recovery |
| 63 | file keyed by root branch ID + the live Session's first writer generation. |
| 64 | Lease rebinds keep that lane; later conflicts update the same path. There is |
| 65 | no recovery-on-recovery chain. |
| 66 | |
| 67 | ## Heads as versions |
| 68 | |
| 69 | Fork-from-here, `/branch`, and a conversation rewind append a `fork` marker |
| 70 | and a `select` marker: the new head starts at the chosen message and becomes |
| 71 | current, and the previous chain stays as another version of the same |
| 72 | conversation. The desktop switches the current tab to the new head in place; |
| 73 | the terminal replays the transcript. *View versions* lists the live heads with |
| 74 | their kind, makes another head current (`select`), renames a head, and |
| 75 | removes *covered* heads: heads whose whole chain is already part of the |
| 76 | current one. Removing appends a `retire` marker; a retired head leaves the |
| 77 | version list, and its bytes are reclaimed only when a single writer rotates |
| 78 | the log. Heads with unique content are never removed automatically, and a head |
| 79 | that was active within the last minute is reported busy instead of retired. |
| 80 | |
| 81 | ## Rewind |
| 82 | |
| 83 | - **Code**: restore file before-images. Already-restored files (current == |
| 84 | before) are skipped. External changes refuse overwrite. |
| 85 | - **Conversation**: fork a `rewind` head at the turn boundary and make it |
| 86 | current. The previous chain is never truncated. A format-1 session forks a |
| 87 | new session file instead. |
| 88 | - **Both**: fork first, then restore files. A file conflict keeps the new head |
| 89 | and reports `partial=true`. |
| 90 | - **Undo**: restores the file after-images. If nothing was added on the rewind |
| 91 | head since, the controller returns to the parent head and retires the empty |
| 92 | rewind head; a continued rewind head stays as a version. |
| 93 | |
| 94 | New checkpoints write `turns/<turn>/meta.json` plus raw `files/NNNN.before` |
| 95 | payloads (schema v3). The newest 100 turn directories are retained by default; |
| 96 | new checkpoint payloads are not duplicated into blobs. v1/v2 `turn-N.json` |
| 97 | files and their legacy blobs remain readable. |
| 98 | |
| 99 | The v2 compatibility marker is also the v3 turn's liveness record. A previous |
| 100 | binary that truncates `turn-N.json` therefore tombstones the matching v3 |
| 101 | directory; upgrading again cannot resurrect the removed future turns. |
| 102 | |
| 103 | Structured writers (`write_file`, `edit_file`, `multi_edit`, notebook edit) |
| 104 | re-check existence, SHA-256, and mode before publish. A mismatch returns |
| 105 | `ErrFileChanged`. |
| 106 | |
| 107 | ## Worktree fallback |
| 108 | |
| 109 | Forking from a message offers two workspace policies. **Conversation only |
| 110 | (shared)** keeps the source workspace, including its current uncommitted files. |
| 111 | **Isolated worktree** creates a durable `reasonix/delivery-*` branch from the |
| 112 | repository's committed `HEAD`, opens the fork as a registered project, and |
| 113 | keeps the source checkout unchanged. Because Git worktrees do not copy local |
| 114 | changes, Reasonix requires a clean source checkout for this combined fork. A |
| 115 | dirty checkout is refused with guidance to commit/stash or use the shared fork. |
| 116 | |
| 117 | If the folder is not a Git project or worktree prerequisites are unavailable, |
| 118 | Reasonix creates the conversation fork in the shared workspace and reports the |
| 119 | fallback. If conversation creation or tab attachment fails after a worktree was |
| 120 | created, automatic cleanup removes it only while its branch, `HEAD`, and status |
| 121 | still match the untouched creation result. Any detected change preserves the |
| 122 | worktree for recovery. A successfully attached worktree remains registered |
| 123 | across tab close/restart. New allocations also store a mode-0600 v1 |
| 124 | `metadata.json` beside the checkout. It binds the original source checkout, |
| 125 | target branch, creation `HEAD`, managed worktree root, and temporary branch. |
| 126 | Older allocations without this metadata cannot use Merge-Back because Reasonix |
| 127 | will not guess a destination; the UI leaves them intact and shows manual merge |
| 128 | guidance. Unknown metadata versions also fail closed. |
| 129 | |
| 130 | Merge-Back is a two-phase, failure-atomic operation. Preflight verifies the |
| 131 | managed path and repository identity, exact branches and `HEAD`s, clean source, |
| 132 | absence of an in-progress Git operation, all visible or detached Desktop work, |
| 133 | integrated terminals, workspace write leases, divergence, diff, and conflicts. |
| 134 | After the dual leases are held, Desktop briefly quiesces turn starts and |
| 135 | controller publication, then reserves both canonical source and worktree roots |
| 136 | through the Git mutation. Project-runtime owners, new turns, and terminal |
| 137 | create/write calls all use that admission gate; contained paths and symlink |
| 138 | aliases are covered without blocking prefix siblings or unrelated workspaces. |
| 139 | Uncommitted worktree changes block the merge unless the user explicitly opts |
| 140 | into an automatic commit; that option is off by default. Confirmation binds a |
| 141 | transient, NUL-safe token to the real index entries and status as well as every |
| 142 | dirty path's type, mode, bytes, or symlink target. Auto-commit seeds a private |
| 143 | `0600` temporary index from the confirmed `HEAD` and runs `git add -A` only |
| 144 | there. If the real index contains staged or index-only content that the full |
| 145 | working tree does not represent, Reasonix stops with the real index and both |
| 146 | versions untouched. Otherwise it creates a hook-free, single-parent |
| 147 | `commit-tree`, compare-and-swaps only the confirmed worktree branch, and then |
| 148 | installs the prepared index through Git's exclusive `index.lock` protocol only |
| 149 | if the real index bytes still match. Any failure after the branch CAS is marked |
| 150 | recovery-required; conflict preflight runs again on the exact new commit. A |
| 151 | target branch, `HEAD`, index, or content-token change refreshes the confirmation |
| 152 | instead of continuing. The source merge uses |
| 153 | `git merge --no-ff --no-commit --no-verify` with a Reasonix-scoped committer |
| 154 | identity, so it neither depends on user Git identity nor invokes commit hooks. |
| 155 | It binds the real index tree to a freshly computed merge tree. The worktree root, common repository, |
| 156 | symbolic branch, branch ref, `HEAD`, Git operation, and content token are |
| 157 | revalidated before preparation and before ref installation. Only while those |
| 158 | identities, the target branch, original `HEAD`, exact `MERGE_HEAD`, and prepared |
| 159 | tree still match does Reasonix create a hook-free `commit-tree` object with |
| 160 | fixed parents and tree. A short source mutation fence holds the real index, |
| 161 | `HEAD`, and `MERGE_HEAD` lockfiles and compares their exact snapshots. While |
| 162 | those checkout-local locks remain held, Git uses a detached administrative view |
| 163 | of the same common ref store to acquire only the branch ref locks. One |
| 164 | `update-ref --stdin` transaction verifies the |
| 165 | worktree branch ref and compare-and-swaps the target ref against its original |
| 166 | `HEAD`, so neither ref check can partially succeed. Post-commit verification |
| 167 | rechecks both checkouts plus the commit tree, real index tree, parents, refs, |
| 168 | clean state, and Git operations. After installation, `git merge --quit` removes |
| 169 | only Git's auxiliary merge state; Reasonix does not update the `MERGE_HEAD` |
| 170 | pseudoref directly or reset the prepared index. Owned preparation failures |
| 171 | before the CAS are aborted only when the prepared state can still be proved; |
| 172 | target-ref drift, post-CAS drift, or any state whose recovery cannot be proven |
| 173 | is marked recovery-required while every worktree resource and external state |
| 174 | is preserved. |
| 175 | |
| 176 | A successful merge first navigates to the recorded source checkout. Every UI |
| 177 | navigation registers an opaque intent token with Desktop; the close request |
| 178 | must still own that exact token both before its snapshot and at the backend |
| 179 | removal linearization point. A newer intent therefore stops close and cleanup |
| 180 | while preserving resources. Otherwise Desktop closes only the exact idle |
| 181 | worktree tab while the exact source tab is still active. Cleanup is then a |
| 182 | separate, retryable step. It reserves the complete allocation containing both |
| 183 | the canonical worktree and its fixed recovery subtree while checking visible |
| 184 | and detached runtimes; every project-runtime creation, restoration, |
| 185 | delete/archive fallback, and redirect uses the same gate. Symlink and contained |
| 186 | paths are covered. Prefix siblings outside the allocation and other allocations |
| 187 | remain independent. |
| 188 | |
| 189 | Finalization runs only when the temporary commit is contained by the target, |
| 190 | identities still match, and the full status including ignored files is empty. |
| 191 | Before moving anything, Reasonix writes a mode-0600 v2 `cleanup-state.json` |
| 192 | journal with the original root, an unguessable recovery root under the reserved |
| 193 | allocation, branch, `HEAD`, and a `planned` stage. It then uses ordinary |
| 194 | `git worktree move` and rechecks the common Git directory, symbolic branch, |
| 195 | branch ref, `HEAD`, operation state, full status, and registered path before |
| 196 | advancing the journal to `retained`. A crash in either stage is retried from the |
| 197 | Git worktree registry and the exact journal identity; multiple or unknown |
| 198 | candidates fail closed. |
| 199 | |
| 200 | The recovery checkout deliberately stays registered and keeps its |
| 201 | `reasonix/delivery-*` branch checked out. Reasonix does not unregister the |
| 202 | worktree, delete its branch, unlink manifest entries, or recursively delete any |
| 203 | path. An already-open file descriptor therefore follows the moved checkout and |
| 204 | late writes remain recoverable; content recreated at the former public path is |
| 205 | also left untouched and reported. Desktop removes only the stale managed-project |
| 206 | registration after the recovery receipt is durable, keeps the source project |
| 207 | active, and does not add the hidden recovery path to the sidebar. Registration |
| 208 | failure is retryable while the recovery root and journal remain available. |
| 209 | |
| 210 | New readers accept v1 cleanup journals only for preservation. A still-registered |
| 211 | legacy checkout can be converted to v2 after its exact identity and manifest are |
| 212 | proved; a detached or ambiguous legacy root is reported for manual recovery and |
| 213 | is never deleted or automatically re-registered. Unknown journal versions fail |
| 214 | closed. Metadata remains v1; older cleanup readers reject the unknown v2 |
| 215 | journal and therefore preserve the recovery checkout. |
| 216 | |
| 217 | Delivery worktrees stay optional. Non-isolated directories use the workspace |
| 218 | lease (`filelock`). Path-bound writes take shared ancestor compatibility locks, |
| 219 | shared hierarchy stripes through the concrete path, and an exclusive file |
| 220 | stripe for the duration of that tool. Whole-workspace writers take their exact |
| 221 | root and hierarchy stripe exclusively. Parent workspaces and directly opened |
| 222 | nested repositories therefore intersect, while two sessions can still write |
| 223 | different files (including in the same repo) at once. `bash`/MCP mutations take |
| 224 | the whole-workspace locks only for that command. Any tool call does the same |
| 225 | when a configured tool hook may write undeclared paths. File and hierarchy |
| 226 | identities map into bounded stripe sets; collisions may serialize unrelated |
| 227 | work but cannot weaken protection. Read-only bash does not take a write lease. |
| 228 | On macOS, folded domains coordinate case aliases while exact-case root locks |
| 229 | remain compatible with older binaries. An older process still recognizes only |
| 230 | the path spelling it opened; cross-spelling coexistence requires both processes |
| 231 | to use the folded protocol. |
| 232 | Conflict cards name the file or workspace being written. Git is never required. |
| 233 | A finished conversation does not keep the write lease; use a worktree when you |
| 234 | need a long-lived isolated tree. |
| 235 |