feat(desktop): move a Session between installations from Settings - #5197
feat(desktop): move a Session between installations from Settings#5197Joob1n wants to merge 1 commit into
Conversation
7dab2b9 to
58c8bcb
Compare
M4n5ter
left a comment
There was a problem hiding this comment.
English
I found one blocking authority issue and four additional correctness issues that should be addressed before this ships:
-
Blocking — bundle operations do not have one coherent Host/filesystem authority. The bundle service calls
bridge.sessionBundles.import()without the Host selected in Settings (apps/desktop/src/renderer/platform/desktop/create-session-bundle-services.ts:34), soselectedRuntimeHostScope(undefined)falls back to the globally active Host. With Settings pointed at Host B while Host A is active, the import mutates A and reports success on B's page. Export also lists remote-owner and guest Sessions, although guests do not register the bundle IPC, remote owners are not grantedsession-bundle.*, and the Electron picker returns a Desktop-local path that the protocol interprets on the Host filesystem. The smallest coherent scope for this release is local-owner-only: route import explicitly to the local owner and filter export candidates accordingly. Remote support needs a byte-transfer/capability contract rather than a path string. -
Expected failures escape the typed error contract. A busy subtree causes
fenceSubtreeto throw fromsession-bundle-coordinator.ts:68; the coordinator does not map that exception, sooperation-dispatcher.ts:370returnsinternal_failureinstead of the declaredsession_busy. The renderer actions atsession-bundle-tasks.tsx:199and 213 also use onlytry/finally; routing, disconnect, and unsupported-channel rejections become unhandled promises with no banner. Map the fence error at the coordinator boundary and convert bridge rejections into the feature's localized failure outcome. -
A bundle with more than 64 Sessions mutates state before the protocol rejects it.
decodeSessionIds(packages/runtime-host/src/protocol/session-bundle.ts:143) applies the limit only after the handler returns. Export therefore writes the file and reportsinternal_failure; import commits every Session and then reports failure, so retry reports a conflict. Remove the arbitrary result limit, or enforce the same limit before any filesystem or database mutation with a declared error. -
The export tree can hide an exportable Session.
nestsUnderAnother(apps/desktop/src/renderer/features/session-bundle/export-tree.tsx:82) climbs through an archived parent and classifies the grandchild as nested under an active ancestor, but rendering at line 110 walks only direct children and filters the archived node. Foractive root → archived child → active grandchild, the grandchild is never rendered; multi-node parent cycles can also end up with no root. Filter the visible Sessions first and reuseprojectLinkedSessionTreefrom@maka/core/sessioninstead of maintaining a second lineage projection. -
The confirmed subtree can differ from the exported subtree. The renderer decides whether to confirm from its current catalog count, then the main process opens the save dialog, and only afterward does the Host discover and fence the actual subtree. Another client can add and finish a child while the dialog is open, causing export to include Sessions the user was never asked to confirm. Carry an expected subtree identity/revision into the fenced Host operation and reject if it changed.
中文
这里有一个阻塞性的 authority 问题,另外四项 correctness 问题也建议在功能发布前处理:
-
Blocking — bundle 操作没有统一的 Host/文件系统 authority。 Bundle service 在
apps/desktop/src/renderer/platform/desktop/create-session-bundle-services.ts:34调用bridge.sessionBundles.import()时丢掉了 Settings 当前选择的 Host,随后selectedRuntimeHostScope(undefined)会回退到全局 active Host。Settings 指向 Host B、全局 active Host 是 A 时,导入会实际写入 A,却在 B 的页面上显示成功。导出列表还包含 remote owner 和 guest Session,但 guest 没有注册 bundle IPC,remote owner 没有session-bundle.*权限,而 Electron 文件选择器给出的 Desktop 本地路径又会被协议当成 Host 文件系统路径。这个版本最小且自洽的边界是只支持 local owner:import 明确路由到本地 owner,export 只显示本地 owner 的 Session。远端能力需要传输 bytes/capability,不能继续传路径字符串。 -
预期内的失败没有留在 typed error contract 中。 Subtree 忙碌时,
session-bundle-coordinator.ts:68的fenceSubtree会抛错;coordinator 没有转换它,operation-dispatcher.ts:370最终只能返回internal_failure,而不是协议已经声明的session_busy。Renderer 在session-bundle-tasks.tsx:199和第 213 行也只有try/finally,路由失效、Host 断开或 IPC 不支持都会变成没有 banner 的 unhandled rejection。应在 coordinator 边界映射 fence 错误,并把 bridge rejection 收敛成 feature 已有的本地化失败结果。 -
超过 64 个 Session 时,系统先产生副作用,再由协议判失败。
packages/runtime-host/src/protocol/session-bundle.ts:143的decodeSessionIds在 handler 返回后才执行数量限制。于是 export 已经写出文件却返回internal_failure;import 已经提交全部 Session 才返回失败,用户重试时又只会得到 conflict。应删除这个任意上限,或者在任何文件和数据库写入之前用同一限制拒绝,并返回协议明确声明的错误。 -
导出树会让可导出的 Session 消失。
apps/desktop/src/renderer/features/session-bundle/export-tree.tsx:82的nestsUnderAnother会越过已归档父节点,把 grandchild 判定为挂在更上层的 active ancestor 下;第 110 行渲染时却只遍历直接 child,并过滤掉归档节点。active root → archived child → active grandchild中,grandchild 最终没有任何渲染入口;多节点 parent cycle 也可能没有 root。这里应先过滤出可见 Session,再复用@maka/core/session的projectLinkedSessionTree,不要维护第二套 lineage projection。 -
用户确认的 subtree 可能不是最终导出的 subtree。 Renderer 根据当前 catalog 的数量决定是否弹确认,主进程随后打开保存对话框,Host 要等对话框结束后才重新发现并 fence 实际 subtree。另一个客户端可以在对话框打开期间新增并完成 child,最终 bundle 会包含用户从未确认过的 Session。应把预期 subtree 的身份或 revision 带入 Host,在 fence 内发现变化时拒绝本次导出。
Export a task, and everything the model saw while it ran, to a `.maka-session` file -- and import one -- without closing the app. The work happens in the Runtime Host because the authority it needs is already held there. The Storage Root owner lock is an election taken with `tryLock`, and it refuses a second exclusive hold even inside the process that has one, so the Host cannot reach the export by calling it. It lends the lease instead. Export is fenced with `runSessionSubtreeQuiescentMutation` so no Turn starts in the subtree while the bundle is prepared; import needs no Session fence, because the Sessions it carries do not exist here yet. Settings › Import/export tasks gains a switch between the two halves. Import keeps the external-agent catalog and adds the bundle file as a source of its own -- a source needs no agent installed, which is also why a machine with no agent no longer sees an empty page. Export is a tree: a bundle can be rooted at any node, so every row exports, the nesting says which subtree a row would carry, and a row with descendants asks before writing them. Refs apache#5182 Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
58c8bcb to
a760386
Compare
|
All five fixed, rebased onto current main. 1 (blocking) — one authority. You are right that the picker returns a path on this machine while the protocol reads it on the Host's filesystem, and those are the same filesystem only for the Local Host. Both halves now route there explicitly — not the active Host, not the selected one — and the export list offers only 2 — typed failures. The coordinator catches 3 — the result limit. Removed. A limit on a result rejects after the work is done: the file is written, the Sessions are committed, and the caller cannot tell that from a real failure — the retry then reports a conflict against what did land. Enforcing it earlier would be a rule about what may be exported, which is not the decoder's to make. 4 — the hidden Session. Filtered first, then projected, and the projection is now Worth reporting: adopting the shared projection made one of my own tests fail, and it was the test that was wrong. 5 — confirmation drift. The count the user was shown now travels with the request, and the Host compares it against what it actually fenced — the one moment the subtree is settled — refusing with Tests: 6 at the coordinator (busy mapping, drift refused, matching size allowed) and 7 on the tree. Each checked by reverting what it covers — not mapping the fence error, not comparing the confirmed size, not filtering archived Sessions first — and each turns the matching test red. One thing I did not touch: |
Summary
Move a task between two Maka installations from inside the app, under #5182. The CLI could already do it — with Maka closed, which is the one state the user is not in when they reach for it.
Why the Runtime Host does the work
A Runtime Host takes the Storage Root owner at startup and holds it for its lifetime, and that lock is an election, not a mutex: it is taken with
tryLock, and a second exclusive hold is refused even inside the process that already has one.So the Host cannot reach these by calling them — it would be refused by its own lock. It lends the lease that #5186 taught the storage layer to accept.
Export walks the subagent subtree and fences it with
runSessionSubtreeQuiescentMutation: it refuses while any of those Sessions has an active execution claim, so no Turn starts while the bundle is prepared. That is not the check the export already makes on its private copy — that one catches state already in flight, this one stops new state from arriving.Import takes no Session fence. The Sessions it carries do not exist here yet, so there is nothing to fence by id; what has to be exclusive is the context store, and the import takes that turn itself.
Settings › Import/export tasks
Import. The bundle file is a source beside the agents — where is this conversation coming from — and the only one that is always available, since a file the user already has needs nothing installed.
Export. A bundle can be rooted at any node, so every row exports. The nesting says which subtree a row would carry, and the count on a parent is the whole subtree rather than its children — the root here carries three, not two. A row with descendants asks before writing them.
Both are
product-settings-pages--import-tasksand--import-tasks-exportin Storybook; the second is new here.Tests
15 new, across three layers.
session-bundle-export-tree(6): a subtree nests and counts the whole tree rather than one level; the link is read from the field the catalog actually publishes; a task whose parent is not in the list still appears; a Session naming itself as its parent still renders; archived tasks stay out; and every row offers its own export, because a bundle can be rooted at any node. The invariant these protect is that every task the user could export appears exactly once — nesting is the nicety.runtime-host-session-bundle-ipc-main(6): the destination reaches the Host and the count is the subtree; a closed save dialog asks the Host for nothing; each imported Session is published so the shell re-reads its catalog; a reason code a reader can act on survives; a failure no code describes keeps its message; and a task name cannot steer the proposed filename.session-bundle-coordinator(3): export runs inside the Session fence, import does not, and a fence that refuses a running Session refuses the export.Each was checked by reverting the implementation it covers — reading only
subagentParent, treating a self-referencing row as nested, hiding a row whose parent is absent, dropping the catalog publication, losing the failure message, calling the Host after a cancelled dialog, and removing the export fence. Every one turns the matching test red.The round trip was also run end to end against a real workspace: a task with a 4-Session subtree exported to 19,711 bytes, imported into an empty workspace as 5 Sessions with all 4
subagent_spawnslinks and a cleanforeign_key_check, and refused withsession_existson a second import.Two things this fixed in passing
The harness had no
ToastProvider. The page asks for a confirmation before exporting a subtree, and a confirmation is a toast. The app has always provided one; the test harness did not, so every case failed on the provider rather than on the case.Two controls answered to the same name. The mode switch was labelled
Import/Export, and a row's action is calledExporttoo. A person tabbing through reached the wrong one — and so did the tests. The switch now has its own label and names no row action shares.Gates
@maka/core,@maka/storage,@maka/runtime,@maka/runtime-hostand the desktop main/preload/renderer build and typecheck clean;biome checkon every changed file;check:asf-headers,check:locale-hygieneandcheck:renderer-architecturepass. No schema change.Protocol epoch 142 → 143. Two new operations are two new keys in the operation map, and the rule in
protocol-compatible-changes/README.mdis explicit that a new accepted value moves the epoch whatever the intent. An epoch-142 Host rejects both as unknown, so a Client that offers them must not pair with one.https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J