Skip to content

Persist terminal layouts incrementally and reap zmx sessions by attach state#369

Merged
sbertix merged 3 commits into
mainfrom
sbertix/better-layout-persistence
May 30, 2026
Merged

Persist terminal layouts incrementally and reap zmx sessions by attach state#369
sbertix merged 3 commits into
mainfrom
sbertix/better-layout-persistence

Conversation

@sbertix
Copy link
Copy Markdown
Collaborator

@sbertix sbertix commented May 30, 2026

Why

layouts.json was only written on quit (plus a couple of rare paths), so launching a second instance mid-session (e.g. make run-app) saw a stale snapshot: it pruned tabs the first instance had just opened and terminated their sessions. Two root causes, fixed independently.

Attach-aware zmx reaping

zmx ls --short emits only the session name, so the reaper had no way to tell whether another live instance still had a client attached to a session. We now parse the full ls format (name=/clients=) through a pure, unit-tested ZmxSessionListParser behind a listSessionsWithClients query that returns nil on probe failure.

  • reapOrphanSessions and the orphan subset of terminateAllSessions spare any session with a client attached (clients > 0) or an unknown count (nil), so a concurrently-running instance keeps its sessions.
  • A failed/timed-out probe reaps nothing (unknown is never killed), while an instance's own tracked sessions are still force-killed on quit.
  • Attach state is the authority; layouts.json is only a hint, so this fixes the second-instance-kills-first-instance bug regardless of snapshot freshness.

Incremental layout persistence

A per-worktree debounced markLayoutDirty fires off the settled tab callbacks (create, close, projection drift, removal, rename, selection), captures the freshest snapshot plus live agent records at fire time, updates the in-memory @Shared(.layouts) dict on main, and merges to disk off the main actor.

  • LayoutsIncrementalWriter is a single FIFO actor: it re-reads layouts.json, splices in only the keys a flush carries (positive snapshot or explicit delete tombstone), and writes atomically via temp+rename. LayoutsKey.save becomes a no-op so the actor is the sole disk writer.
  • Writes are skipped when the splice leaves the dict unchanged, so high-frequency projection ticks under an agent tool-call storm do not churn the file.
  • Deletes (prune/archive) bypass the debounce so a queued positive save cannot resurrect a removed worktree; the on-quit synchronous flush stays the terminal write.
  • A transient read failure aborts the flush so siblings are not clobbered, while corrupt bytes are rotated aside to layouts.json.corrupt-<timestamp> and persistence recovers, mirroring SidebarPersistenceKey.
  • A tab rename now routes through WorktreeTerminalState so a custom title persists incrementally instead of only at quit; the layout-restore path keeps seeding setCustomTitle directly so hydration does not trigger a redundant save.

Fixes along the way

The close-last-surface-via-close_surface path returned without emitting a projection, so onTabRemoved never fired and the closed surface's session was re-orphaned at next launch. It now emits a projection on that branch.

Tests

New coverage for the parser (clients/err/arrow/non-supa/malformed lines), attach-aware reaping (spare attached, skip unknown, kill tracked, nil-probe reaps nothing), and incremental persistence (debounce coalesce, delete-beats-queued-save, no-resurrect-after-prune, flush-on-quit, agent-record-survives-mutation, skip-if-unchanged, corrupt-file rotate-and-recover, rename persists). Full suite green.

sbertix added 3 commits May 30, 2026 17:17
zmx ls --short emits only the session name, so the reaper cannot tell whether another instance still holds a client. Parse the full ls format through a pure ZmxSessionListParser (name=/clients=, supa- prefix filter, clients=nil for unreachable lines) behind a listSessionsWithClients field that returns nil on probe failure, so an unprobeable session is never reaped. The reaper consumes this exclusively, so the name-only listSessions is gone.
Per-mutation persistence cannot run the whole-dict encode and atomic write synchronously on the main actor. LayoutsIncrementalWriter is a single FIFO actor that re-reads layouts.json, splices in only the keys a flush carries (positive snapshot or explicit delete tombstone), and writes atomically through the temp+rename storage. It skips the write when the splice leaves the dict unchanged so high-frequency projection ticks do not churn the file. A transient read failure aborts the flush so siblings are not clobbered, while corrupt bytes are rotated aside to layouts.json.corrupt-<timestamp> and persistence recovers, mirroring SidebarPersistenceKey. LayoutsKey.save becomes a no-op so the actor is the sole disk writer.
…h state

Layouts were only written on quit, so a second instance launched mid-session pruned tabs the first instance had just opened and terminated their sessions. Drive a per-worktree debounced markLayoutDirty off the settled tab callbacks (create, close, projection drift, removal, rename, selection), capturing the freshest snapshot plus live agent records at fire time and merging off main; prune deletes immediately so a queued save cannot resurrect a removed worktree, and the on-quit synchronous flush stays the terminal write. reapOrphanSessions and the orphan subset of terminateAllSessions now spare any session that still has a client attached (or an unknown count), so a live instance keeps its sessions. A tab rename now routes through WorktreeTerminalState so a custom title persists incrementally instead of only at quit, while the layout-restore path keeps seeding setCustomTitle directly. Also fixes the close-last-surface-via-close_surface path that returned without emitting a projection, leaving the closed surface's session orphaned.
@sbertix sbertix enabled auto-merge (squash) May 30, 2026 15:36
@tuist
Copy link
Copy Markdown

tuist Bot commented May 30, 2026

🛠️ Tuist Run Report 🛠️

Builds 🔨

Scheme Status Duration Commit
supacode 4m 13s 666c9e25b

@sbertix sbertix merged commit a536175 into main May 30, 2026
2 checks passed
@sbertix sbertix deleted the sbertix/better-layout-persistence branch May 30, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant