Skip to content

feat(desktop): move a Session between Maka installations from the app #5182

Description

@Joob1n

Problem

Moving a Session from one Maka installation to another — a release build to a dev build, one machine to another — has no path through the app.

maka session-export and maka session-import landed in #5113 and #5139, but they only work with Maka closed. A Runtime Host takes an exclusive lock on its Storage Root at startup and holds it for its lifetime, and both commands need that authority to read or write the context-offload store. Run either while the app is open and it refuses:

Context import requires an offline Storage Root; stop the Runtime Host first

So the one surface where a user would reach for this — a menu item in the app they already have open — is the one place it cannot run.

Desired outcome

Export a Session (and the subagent subtree under it) to a .maka-session file from the sidebar, and import one from Settings, without closing the app.

What a Session is on disk

Not a row. A Session spans four things in a workspace, and all four have to travel:

workspace/
  runtime.sqlite            Session rows, events, tool calls
  context-offload.sqlite    index of offloaded payloads
  context-offload-values/   the payload bytes, at sha256/<prefix>/<hash>
  artifacts/<sessionId>/    files the Session produced

exportSessionBundleState() / importSessionBundleState() already handle all four. What is missing is a way to call them from inside the process that holds the Storage Root.

Proposed flow

The Runtime Host is the single process holding the Storage Root, so it is the one that can do this. It does not need to take the lock again — it already has it, and can lend it.

Export

  1. Walk the subagent subtree under the named Session.
  2. runSessionQuiescentMutation([root, ...subtree]) — refuses while any of them has an active execution claim, and serialises against other Session mutations, so no Turn starts mid-export.
  3. runWithStorageRootLease(owner.lease, 'interactive', 'write', ...).
  4. exportSessionBundleState() under that lease: back up the operational database, check quiescence on the copy, filter out everything the subtree does not own, snapshot context, copy artifacts.
  5. Pack to .maka-session.

Steps 2 and 4 are not redundant: the kernel fence stops a new Turn from starting, the on-copy check catches state that was already mid-flight.

Import

  1. inspect() the bundle's manifest, then hydrate() into a private staging directory.
  2. runWithStorageRootLease(owner.lease, ...).
  3. importSessionBundleState() under that lease: artifacts and context land first, Session rows last, so a failure before the final step publishes nothing and leaves the ids free for a retry.

No Session fence on the import side — the Sessions being imported do not exist locally yet, so there is nothing to fence by id.

What had to be verified first

Two questions decided whether the Host can do this at all.

Can the Host use the lock it already holds? Not through the current entry point. The Storage Root lock is an election (tryLock, fail fast), not a mutex, and it is not reentrant — a second exclusive lock on the same file from the same process is refused:

first  exclusive: true
second exclusive (same process, different fd): false

So withOfflineContextSnapshot needs a lease-bound entry alongside the electing one. withLeaseBoundArtifactWriterLock is the same pattern, already in the same package.

Does an import racing the Host's own writes corrupt anything? For the databases, no:

  • Only one process can hold the interactive write authority (verified across processes: the second is refused), and the production store can only be opened through that lease.
  • Neither side holds a SQLite write transaction across an await. put() publishes its managed file before entering a synchronous #writeTransaction, and the import's merge runs BEGIN IMMEDIATECOMMIT with no await in between.

So the usage recompute always sees a settled table.

Correction (2026-09-11, from review on #5186): that is not the whole question, and the conclusion drawn from it was wrong. The Store's operations read database state, await, and only then act on files — collection decides a payload is unreferenced, awaits, and unlinks it. An import that commits a reference inside that await leaves the reference pointing at a file about to be removed, and collection's re-check cannot see it because the check and the unlink straddle the await. A root-scoped fence is needed, shared by the import, the Store's publication, and collection.

For the payload files, yes — and this is a present-day defect, not a future one. The store publishes a managed blob atomically (staging file → fsynclink), while the import uses copyFile, which is observable at its final path in partial states. Copying a 64 MB file and stat-ing the destination every millisecond:

copyFile  partial sizes observed at the final path: 38   [2580480, 4177920, 5259264, ...]
link      partial sizes observed at the final path:  0

Payloads are content-addressed, so a bundle and its target routinely hold the same hash at the same path. Today that is harmless because the import only runs offline. It is not harmless once the Host runs it — and it already bites offline in one case: copyContextValueTree swallows EEXIST, so an import that crashes mid-copy leaves a truncated payload that the retry silently accepts as already present.

Proposed split

PR 1 — storage

  • copyContextValueTree: publish through a staging file and link, matching SqliteContextOffloadStore#publishManagedFile; on EEXIST, verify the existing bytes instead of skipping. Fixes the truncated-payload retry above.
  • withOfflineContextSnapshot: accept a caller-supplied lease. Without one it elects an owner exactly as today, so the CLI path is unchanged.

PR 2 — Runtime Host and desktop

  • Two Host operations, shaped after protocol/external-session.ts, running under the Host's lease, with the Session fence on the export side.
  • Desktop IPC and file dialogs, a sidebar entry for export, and a Settings entry for import.

Credentials stay out: a bundle names its connection by slug and model and carries no key, and the importing side resolves the slug against its own catalog.

I plan to implement this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions