Skip to content

feat(desktop): move a Session between installations from Settings - #5197

Merged
M4n5ter merged 1 commit into
apache:mainfrom
Joob1n:feat/session-bundle-host-operations
Sep 12, 2026
Merged

M4n5ter merged 1 commit into
apache:mainfrom
Joob1n:feat/session-bundle-host-operations

Conversation

@Joob1n

@Joob1n Joob1n commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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.

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

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.

The import half with Maka session file selected as the source

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.

The export half showing a subagent subtree with nesting rules

Both are product-settings-pages--import-tasks and --import-tasks-export in 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_spawns links and a clean foreign_key_check, and refused with session_exists on 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 called Export too. 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-host and the desktop main/preload/renderer build and typecheck clean; biome check on every changed file; check:asf-headers, check:locale-hygiene and check:renderer-architecture pass. 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.md is 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

@github-actions github-actions Bot added the effort/XL Under 2500 readable lines label Sep 11, 2026
@Joob1n
Joob1n force-pushed the feat/session-bundle-host-operations branch 8 times, most recently from 7dab2b9 to 58c8bcb Compare September 11, 2026 15:26

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

I found one blocking authority issue and four additional correctness issues that should be addressed before this ships:

  1. 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), so selectedRuntimeHostScope(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 granted session-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.

  2. Expected failures escape the typed error contract. A busy subtree causes fenceSubtree to throw from session-bundle-coordinator.ts:68; the coordinator does not map that exception, so operation-dispatcher.ts:370 returns internal_failure instead of the declared session_busy. The renderer actions at session-bundle-tasks.tsx:199 and 213 also use only try/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.

  3. 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 reports internal_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.

  4. 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. For active 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 reuse projectLinkedSessionTree from @maka/core/session instead of maintaining a second lineage projection.

  5. 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 问题也建议在功能发布前处理:

  1. 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,不能继续传路径字符串。

  2. 预期内的失败没有留在 typed error contract 中。 Subtree 忙碌时,session-bundle-coordinator.ts:68fenceSubtree 会抛错;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 已有的本地化失败结果。

  3. 超过 64 个 Session 时,系统先产生副作用,再由协议判失败。 packages/runtime-host/src/protocol/session-bundle.ts:143decodeSessionIds 在 handler 返回后才执行数量限制。于是 export 已经写出文件却返回 internal_failure;import 已经提交全部 Session 才返回失败,用户重试时又只会得到 conflict。应删除这个任意上限,或者在任何文件和数据库写入之前用同一限制拒绝,并返回协议明确声明的错误。

  4. 导出树会让可导出的 Session 消失。 apps/desktop/src/renderer/features/session-bundle/export-tree.tsx:82nestsUnderAnother 会越过已归档父节点,把 grandchild 判定为挂在更上层的 active ancestor 下;第 110 行渲染时却只遍历直接 child,并过滤掉归档节点。active root → archived child → active grandchild 中,grandchild 最终没有任何渲染入口;多节点 parent cycle 也可能没有 root。这里应先过滤出可见 Session,再复用 @maka/core/sessionprojectLinkedSessionTree,不要维护第二套 lineage projection。

  5. 用户确认的 subtree 可能不是最终导出的 subtree。 Renderer 根据当前 catalog 的数量决定是否弹确认,主进程随后打开保存对话框,Host 要等对话框结束后才重新发现并 fence 实际 subtree。另一个客户端可以在对话框打开期间新增并完成 child,最终 bundle 会包含用户从未确认过的 Session。应把预期 subtree 的身份或 revision 带入 Host,在 fence 内发现变化时拒绝本次导出。

@Joob1n
Joob1n force-pushed the feat/session-bundle-host-operations branch from 58c8bcb to a760386 Compare September 11, 2026 16:33
@Joob1n

Joob1n commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

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 profileKind === 'local' Sessions that are not Guest projections. Remote support does need a byte transfer rather than a path string, so it is out of scope here rather than half-present.

2 — typed failures. The coordinator catches SessionQuiescentMutationBusyError and returns session_busy; a running Session was being reported as internal_failure, which is the one code that means something is broken. The renderer actions now catch as well as finally, so a routing or disconnect rejection becomes the same localized banner instead of an unhandled promise and silence.

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 projectLinkedSessionTree from @maka/core/session rather than a second lineage read model maintained here. Your active root → archived child → active grandchild case has a test.

Worth reporting: adopting the shared projection made one of my own tests fail, and it was the test that was wrong. isSubagentSessionParent requires the full spawnedBy shape, and my fixture had only parentSessionId — so it had been rendering five unrelated roots while asserting things that happened to hold anyway. The shared reader is stricter than what I wrote.

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 candidate_set_stale if a child finished while the dialog was open.

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: product-shell-official-appshell--multiline-submitted-prompt-does-not-reverse failed once in my Storybook smoke and passed on a re-run of the same build, and passes on main. It asserts line-start x positions, so it looks like a font-loading race rather than anything here.

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 5197 Review

结论

APPROVE
把 CLI 已有的 Session 迁移搬到应用内 Settings:导出用 runSessionSubtreeQuiescentMutation 整棵 subagent 子树加静默栅栏 + expectedSubtreeSize 防"确认后子树变了",导入不加 Session 栅栏(目标里尚不存在);Host 出借自持的 Storage Root lease 而非重新选举(选举锁对同进程也拒绝第二次独占),路径经原生对话框 + 协议 requireUtf8String 限长;bundleFileName 清洗分隔符/前导点/超长;epoch 142→143 且有 operator-command 测试锁定新操作。IPC 错误分类、测试覆盖(coordinator/IPC/export-tree/集成)都到位。

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

The amended head fixes the renderer rejection handling, the 64-result cutoff, and the duplicate lineage projection. Four correctness gaps remain:

  1. A running subtree still becomes internal_failure. Production wires fenceSubtree to SessionManager.runSessionSubtreeQuiescentMutation at packages/runtime-host/src/server/execution-composition.ts:2270-2274. Before the error reaches the coordinator, SessionManager.runSessionQuiescentMutation converts SessionQuiescentMutationBusyError into SessionConfigurationTransitionError('session_busy') at packages/runtime/src/session-manager.ts:1775-1784. The coordinator catches only the original kernel error at packages/runtime-host/src/server/session-bundle-coordinator.ts:121-130, so the translated exception still escapes to the dispatcher. The new test throws the pre-translation type directly and does not exercise the production path. Map the exported SessionConfigurationTransitionError.code or stop translating beneath this API.

  2. expectedSubtreeSize neither describes nor fences the set that is exported. The renderer excludes archived Sessions before counting (apps/desktop/src/renderer/features/session-bundle/export-tree.tsx:44-68), while SessionManager and storage include every linked descendant (packages/runtime/src/session-manager.ts:1808-1828, packages/storage/src/session-bundle-policy.ts:785-802). A stable active parent with an archived child is therefore permanently rejected as candidate_set_stale. There is also a race inside the new fence: runSessionSubtreeQuiescentMutation discovers descendants before entering runSessionQuiescentMutation at lines 1800-1805. A child created in that gap is absent from the checked count and fence, but the later storage snapshot can still include it. Comparing only a count also cannot detect a same-cardinality member change. Use one subtree definition, discover it under the relevant admission fence, and compare a complete identity/revision token; otherwise this contract should not claim to guarantee the confirmed set.

  3. Local-only routing is still presented inside a selected-Host scope. The Import Tasks page is declared Runtime-Host-scoped (apps/desktop/src/renderer/settings/settings-nav.ts:101-120) and its adapter calls use the selected Host, but the Maka source is appended for every target at apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx:539-542; preload then always routes it to Local at apps/desktop/src/preload/preload.ts:2684-2704. With Remote selected, the page therefore presents a Remote-scoped action that actually mutates Local. Hide the bundle source/actions outside the Local target, or present this feature in an explicitly Local/client scope.

  4. Removing the 64-item decoder limit leaves an unbounded post-commit response. decodeSessionIds now accepts an arbitrary array at packages/runtime-host/src/protocol/session-bundle.ts:171-183, while one Runtime Host frame is limited to 768 KiB at packages/runtime-host/src/protocol/index.ts:395-398,702-710. A sufficiently large valid import can commit every Session and then fail while encoding the success frame; retry reports conflicts against work that already landed. Return a bounded result such as the count and root identity, or enforce a transport-safe limit before any mutation.

中文

修订版已经补上 Renderer rejection 处理、移除了 64 项结果截断,也删掉了重复的 lineage 投影。当前仍有四处 correctness 缺口:

  1. 运行中的 subtree 仍会被映射成 internal_failure 生产组合在 packages/runtime-host/src/server/execution-composition.ts:2270-2274fenceSubtree 接到 SessionManager.runSessionSubtreeQuiescentMutation。异常到达 coordinator 之前,packages/runtime/src/session-manager.ts:1775-1784 已经把 SessionQuiescentMutationBusyError 转成 SessionConfigurationTransitionError('session_busy');但 packages/runtime-host/src/server/session-bundle-coordinator.ts:121-130 只捕获原始 kernel error,因此转换后的异常仍会逃到 dispatcher。新增测试直接 mock 了生产链路不会抛出的原始类型。这里应映射导出的 SessionConfigurationTransitionError.code,或者不要在下层转换该异常。

  2. expectedSubtreeSize 既没有描述、也没有真正 fence 最终导出的集合。 Renderer 在 apps/desktop/src/renderer/features/session-bundle/export-tree.tsx:44-68 先排除 archived Session 再计数,SessionManager 和 storage 却会包含所有 linked descendant(packages/runtime/src/session-manager.ts:1808-1828packages/storage/src/session-bundle-policy.ts:785-802)。因此,只要 active parent 下存在 archived child,即使数据完全稳定,也会永久得到 candidate_set_stale。新 fence 内部还存在竞态:runSessionSubtreeQuiescentMutation 在第 1800-1805 行先发现 descendants,之后才进入 runSessionQuiescentMutation;如果 child 在两步之间创建,它不会进入受检数量和 fence,后续 storage snapshot 却仍可能把它导出。只比较数量也无法识别成员等量替换。应统一 subtree 定义,在相应 admission fence 内确定成员,并比较完整的 identity/revision token;否则这套 contract 不能声称保证用户确认过的集合。

  3. Local-only 路由仍被放在“当前选中 Host”的页面语义中。 Import Tasks 在 apps/desktop/src/renderer/settings/settings-nav.ts:101-120 被声明为 Runtime Host scope,其他 adapter 调用也会使用当前选中的 Host;但 apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx:539-542 会为所有 target 加入 Maka source,preload 又在 apps/desktop/src/preload/preload.ts:2684-2704 始终把它路由到 Local。用户选中 Remote 时,页面展示的是 Remote scope 下的操作,实际修改的却是 Local。应在非 Local target 下隐藏 bundle source 和相关操作,或者把该功能放进明确的 Local/client scope。

  4. 移除 64 项 decoder 限制后,post-commit response 变成了无界结果。 packages/runtime-host/src/protocol/session-bundle.ts:171-183decodeSessionIds 现在接受任意长度数组,而单个 Runtime Host frame 在 packages/runtime-host/src/protocol/index.ts:395-398,702-710 被限制为 768 KiB。足够大的合法导入可以先提交全部 Session,随后在编码成功响应时失败;用户重试只会遇到已经落盘数据造成的 conflict。应返回 count 和 root identity 等有界结果,或者在任何 mutation 前实施符合传输上限的限制。

@Joob1n
Joob1n force-pushed the feat/session-bundle-host-operations branch from a760386 to ee02bb3 Compare September 12, 2026 02:00
@Joob1n

Joob1n commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

All four fixed.

1. The busy error. You are right, and the part that stings is that my test was what hid it: it threw SessionQuiescentMutationBusyError directly, which is a type production never delivers — SessionManager translates it into SessionConfigurationTransitionError('session_busy') on the way out. The coordinator now catches both, and the test throws what production throws.

2. One subtree definition. Three changes, because there were three different problems under it:

  • The renderer counted the drawn set and the Host fences every linked descendant, so a parent with an archived child was permanently candidate_set_stale. It now confirms over the whole linked subtree, archived included — the set the Host will actually carry — while continuing to draw only the active ones.
  • A count cannot see a same-cardinality membership change, so the request now carries sha256 over the sorted ids instead. Bounded, and exact.
  • The discovery-before-fence gap: runSessionSubtreeQuiescentMutation now re-discovers the subtree inside the fence and refuses if it differs. A child that appeared in the gap is in the subtree and not in what was held still, and fencing it retroactively would be fencing a set the call never admitted.

3. Scope. The feature is not rendered at all unless the Settings target is the Local Host. Presenting a Host-scoped action that silently mutates Local was the real problem; hiding it is the honest version until a byte-transfer contract exists.

4. The unbounded response. Both results are counts now — sessionCount rather than the id list — so the frame cannot grow with the subtree and fail after every Session is committed. Nothing downstream used the ids: the desktop tells the shell to re-read its catalog, which it now does unscoped.

Tests: the busy mapping through the production error type, a membership change at the same cardinality, a matching digest passing through, and the unscoped catalog publication. Each was checked by reverting what it covers — catching only the kernel type, skipping the digest comparison, dropping the publication — and each turns its test red.

Storybook smoke, check:release, the epoch guard and the renderer architecture check all pass; rebased onto current main.

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@Joob1n
Joob1n requested a review from M4n5ter September 12, 2026 02:40

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

The previous busy-error mapping, archived-tree definition, pre-fence recheck, and bounded response shape are fixed. Four correctness gaps remain:

  1. Blocking — every normal UI export hashes a different identity set from the Host. Desktop projects each Session id to the JSON [hostId, sessionId] key at apps/desktop/src/shared/desktop-session-projection.ts:214-223. ExportTree passes those projected ids as the subtree, and session-bundle-tasks.tsx:137-141,272-275 hashes them. Preload deprojects only the root id at apps/desktop/src/preload/preload.ts:2691-2700; it cannot transform the already-computed digest. The Host hashes its raw fenced ids at packages/runtime-host/src/server/session-bundle-coordinator.ts:78-87, so even an unchanged one-Session export returns candidate_set_stale. Compute the digest at a boundary that can deproject every member, and cover the Renderer → preload → Host identity transition in a test.

  2. Remote Import Tasks still renders a Maka source without its required provider. SessionBundleTasks returns its children directly for a non-Local target at apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx:104-108, while ImportTasksSettingsPage always appends MAKA_BUNDLE_SOURCE_ID at apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx:539-542 and renders its panel at line 959. That panel then throws when the context provider is absent (session-bundle-tasks.tsx:181-186). A Remote target with no adapter reaches this deterministically after source loading; with adapters, selecting Maka reaches it. Exclude the Maka source for non-Local targets as well as hiding the export shell.

  3. The bounded import result still emits an unbounded post-commit catalog burst. After importSessionBundle commits, the coordinator synchronously calls onImported once per id at packages/runtime-host/src/server/session-bundle-coordinator.ts:162-174; production maps each call to publishSessionCatalog. Each subscribed owner connection enqueues one frame, while BoundedSerialOutboundWriter includes the in-flight frame in its 64-frame bound (packages/runtime-host/src/server/serial-outbound-writer.ts:27,86-118). With a blocked or ordinary asynchronous write, the 65th imported Session raises frame_limit, closes the writer, and prevents the success response even though the import already landed. I reproduced the writer failure exactly at frame 65. Publish one unscoped catalog invalidation or otherwise coalesce this fan-out, and test the real connection path above the queue limit.

  4. The checked subtree can still change through lineage writers that do not share this fence. runSessionSubtreeQuiescentMutation rechecks membership inside the RuntimeKernel mutation lane at packages/runtime/src/session-manager.ts:1796-1816, but Session retirement commits through the separate admission gate (packages/runtime-host/src/server/session-retirement-coordinator.ts:420-463,343-351), and bundle import can attach a preserved subagent_parent_session_id without taking this fence. Either can commit after the recheck and before backupOperationalState at packages/storage/src/session-bundle-policy.ts:332-350; the private snapshot then derives a smaller subtree after removal or includes a newly imported child, despite the digest having passed. Serialize every lineage commit with the same authority, or compare the private snapshot's actual ids to the fenced ids before publishing the destination. Cover both removal and import interleavings.

中文

上次 review 中 busy error 的映射、包含 archived Session 的 subtree 定义、进入 fence 后的二次检查,以及有界响应结构都已经修复。当前还剩四处 correctness 缺口:

  1. Blocking — 正常 UI 导出计算的身份集合与 Host 永远不同。 Desktop 在 apps/desktop/src/shared/desktop-session-projection.ts:214-223 把每个 Session id 投影为 JSON [hostId, sessionId] key;ExportTree 把这些投影后的 id 作为 subtree 传出,session-bundle-tasks.tsx:137-141,272-275 再对它们计算哈希。Preload 在 apps/desktop/src/preload/preload.ts:2691-2700 只反投影了根 id,无法转换已经算好的 digest;Host 则在 packages/runtime-host/src/server/session-bundle-coordinator.ts:78-87 对原始 fenced id 计算哈希。因此,即使只有一个 Session 且集合完全没变,导出也会返回 candidate_set_stale。应在能够反投影全部成员的边界生成 digest,并用测试覆盖 Renderer → preload → Host 的身份转换。

  2. Remote 的“导入任务”页仍会渲染缺少必需 provider 的 Maka source。 apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx:104-108 在非 Local target 下直接返回 children;但 apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx:539-542 仍会无条件加入 MAKA_BUNDLE_SOURCE_ID,并在第 959 行渲染其 panel。随后 panel 会因为缺少 context provider 而在 session-bundle-tasks.tsx:181-186 抛错。Remote 没有 adapter 时,source 加载完成后稳定触发;有 adapter 时,选择 Maka 后触发。除了隐藏 export 外,还需要在非 Local target 下排除 Maka source。

  3. 响应虽然有界,导入提交后的 catalog 通知仍然无界。 importSessionBundle 提交后,coordinator 会在 packages/runtime-host/src/server/session-bundle-coordinator.ts:162-174 对每个 id 同步调用一次 onImported,生产实现会逐个执行 publishSessionCatalog。每个订阅了完整 catalog 的 owner connection 都会入队一个 frame,而 packages/runtime-host/src/server/serial-outbound-writer.ts:27,86-118 的 64-frame 上限包含正在发送的那一帧。只要 write 是普通异步过程,第 65 个导入 Session 就会抛出 frame_limit、关闭 writer,成功响应也无法发送,但导入已经落盘。我复现到 writer 恰好在第 65 帧失败。这里应发布一次 unscoped catalog invalidation,或以其他方式合并 fan-out,并在真实 connection 路径上覆盖超过队列上限的导入。

  4. 仍有 lineage writer 不共享当前 fence,已校验的 subtree 可以继续变化。 packages/runtime/src/session-manager.ts:1796-1816 会在 RuntimeKernel mutation lane 内二次检查成员;但 Session retirement 通过另一套 admission gate 提交(packages/runtime-host/src/server/session-retirement-coordinator.ts:420-463,343-351),bundle import 也可以在不获取该 fence 的情况下保留 subagent_parent_session_id 并挂入 child。二者都可能在二次检查之后、packages/storage/src/session-bundle-policy.ts:332-350 的 backupOperationalState 之前提交;private snapshot 随后可能因删除得到更小的 subtree,或带上新导入且未经确认的 child,而 digest 已经通过。应让所有 lineage commit 使用同一 authority,或者在发布 destination 前把 private snapshot 的实际 ids 与 fenced ids 再比较。测试需覆盖 remove 与 import 两种交错。

@Joob1n
Joob1n force-pushed the feat/session-bundle-host-operations branch from ee02bb3 to 6cc2220 Compare September 12, 2026 03:05

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

The projected/raw identity mismatch is fixed: preload now deprojects every confirmed member and main hashes the Host-native ids.

One deterministic regression still matters before merge:

  1. Remote Import Tasks renders a Maka source without its provider and crashes. SessionBundleTasks returns its children directly for a non-Local target at apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx:104-108. ImportTasksSettingsPage still always adds MAKA_BUNDLE_SOURCE_ID at apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx:539-542 and renders SessionBundleImportPanel at line 959; that panel throws when SessionBundleActionsContext is absent at session-bundle-tasks.tsx:174-179. With a Remote target and no adapter this happens after source loading every time; with adapters it happens when Maka is selected. Exclude the Maka source outside Local, not only the export shell.

Two low-frequency cases can be non-blocking follow-ups:

  • Importing 65 or more Sessions synchronously publishes one catalog frame per id after commit and exceeds BoundedSerialOutboundWriter's 64-frame queue, closing the connection before its success response.
  • Retirement or another bundle import can change lineage after the in-fence membership check but before backupOperationalState, so a narrow concurrent export can contain a smaller or newly enlarged subtree.

The current Linux CI failure is in the unrelated WorkHub queued-prompt E2E; all other steps passed.

中文

投影 ID 与原始 ID 的 digest 不一致已经修复:preload 现在会反投影全部确认成员,main 再对 Host 原生 id 计算哈希。

目前还有一项稳定回归应在合入前修复:

  1. Remote 的“导入任务”页会渲染缺少 provider 的 Maka source 并崩溃。 apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx:104-108 在非 Local target 下直接返回 children;apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx:539-542 仍无条件加入 MAKA_BUNDLE_SOURCE_ID,并在第 959 行渲染 SessionBundleImportPanel。该 panel 在 session-bundle-tasks.tsx:174-179 发现 SessionBundleActionsContext 缺失后会抛错。Remote 没有 adapter 时会在 source 加载后每次触发;有 adapter 时选择 Maka 也会触发。非 Local 环境需要排除 Maka source,不能只隐藏 export 外壳。

另外两项低频场景可以作为不阻塞合入的 follow-up:

  • 一次导入 65 个以上 Session 时,提交后会为每个 id 同步发布一帧 catalog 通知,超过 BoundedSerialOutboundWriter 的 64-frame 队列,导致成功响应前连接关闭。
  • Retirement 或另一条 bundle import 可以在 fence 内成员检查完成后、backupOperationalState 之前改变 lineage;窄并发窗口下,导出的 subtree 可能缩小或加入未经确认的新成员。

当前 Linux CI 的失败来自无关的 WorkHub queued-prompt E2E,其余步骤均通过。

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
@Joob1n
Joob1n force-pushed the feat/session-bundle-host-operations branch from 6cc2220 to 49936bb Compare September 12, 2026 05:58
@Joob1n

Joob1n commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

The blocking one is fixed, and one of the two follow-ups with it.

The crash. You are right, and the shape of my mistake is worth naming: I put the guard in one place and left the other path that depended on it. The page now takes offersBundleSource from the same flag that mounts the feature, so the source is absent outside Local rather than present and broken. The feature also provides its context unconditionally now — whether the catalog offers the source and whether the feature is mounted are two decisions, and a panel that throws when they disagree turns a wiring slip into a blank page.

Chasing that surfaced a second thing I had broken: I removed the "no supported Agent" empty state when the bundle source became always-available, and beside a Remote target with no agent installed that left an empty page. It is back, keyed on the source list rather than the adapter list.

The 64-frame queue. Fixed rather than deferred — it is the same shape as the response limit from the previous round: a failure that arrives after everything has been committed. The Host publishes one catalog invalidation for the root instead of one per Session. A client re-reads its catalog either way, so the extra frames bought nothing.

The lineage change between the in-fence check and the backup I have left. Closing it means holding a fence across the backup, which is a wider change than this PR should make, and assertSessionQuiescent on the private copy still refuses a Session caught mid-turn. Happy to file it if you would rather it not rest on that.

Tests: the bundle source absent where the feature is not mounted and present where it is, and the empty state when neither exists. Each was checked by reverting what it covers.

Two notes on my own testing, since both are corrections to what I told you earlier:

  • The first version of the "source is offered" test passed for the wrong reason — with no adapters the switch does not render at all, so the name was absent either way. It uses an adapter now, so the assertion is about the bundle rather than about the control existing.
  • The page tests rendered ImportTasksSettingsPage standalone, a composition production never has. They now compose it the way the settings surface does, which is what let this crash reproduce in a test at all.

The Linux failure is the WorkHub queued-prompt E2E, as you said: it passes 3/3 locally and main is green.

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审通过。Remote Import Tasks 现在由同一个 local-host 条件同时控制 bundle source 与 feature composition,远端无 adapter 时也恢复了正确的无来源空态;bundle import 的 catalog fanout 收敛为一次 invalidation,而 renderer 会据此完整重读 catalog,不再受 64 帧队列上限影响。此前的 raw/projected session ID digest 边界修复仍然正确。

剩余的 subtree lineage 窄竞态按约定作为非阻塞 follow-up。当前 head 49936bb 的 Linux CI 与 Windows recovery 均通过,相关文件 Biome 检查通过。

Re-review approved. The Remote Import Tasks page now uses the same local-host condition for both the bundle source and feature composition, including the correct no-source state when a remote target has no adapters. Bundle import emits one catalog invalidation, which triggers a full renderer catalog refresh and avoids the bounded 64-frame queue. The raw/projected session-ID digest boundary fix remains correct.

The narrow subtree-lineage race is accepted as a non-blocking follow-up. Linux CI, Windows recovery, and the relevant Biome checks pass at head 49936bb.

@M4n5ter
M4n5ter merged commit 69a3c06 into apache:main Sep 12, 2026
2 checks passed
Shouly pushed a commit to Shouly/maka that referenced this pull request Sep 13, 2026
)

Thirty-one upstream commits. The one that reaches the new renderer is apache#5170,
which gives the Renderer the transcript window: Main keeps a tail cache and
answers page requests pass-through, `loadBefore` / `loadAfter` return a page,
`loadAround` / `loadLatest` a reset, `acknowledgeTail` is new, and a batch
carries `extends` / `coversFrom` / `navigation` instead of
`evictedDurableSequences` / `completedOverlayMessageIds`. Also in: apache#5217's
observation contract (`subscribeEvents` loses `onSeeded`; readiness follows
seed consumption as the `ready` phase, and the execution projection it offers
is not consumed here yet), the memory work across composer and stream
(apache#5153), interactions cleared per Turn on abort/complete (apache#4562), Session
bundles and external agents in main/preload (apache#5197, apache#5164), Code Mode
(apache#3615, apache#5219), and the scheduled-task snooze fix (apache#5226).

Resolution per the sync policy: conflicts under the old renderer's trees,
packages/ui's deleted components, their stories, e2e specs and the main tests
that import them stay deleted; upstream's new files in those trees are dropped
(`application/contracts/settings-presentation`, `features/external-agent-settings`,
`features/session-bundle`, `workhub/ui/return-button`, `model-wheel-picker`,
the prompt-rail and live-turn-buffer tests, `workhub-return-rail.spec.ts`).
The renderer side of apache#5217 (one live Turn per Session → a buffer keyed by
Turn, `liveTurn` → `liveTurns`, `phase` gone) stays out: `packages/ui`
`live-turn-projection.ts`, `transcript-projection.ts` and their tests keep
ours and `live-turn-buffer.ts` is dropped; `session-event-handlers.ts` keeps
ours plus upstream's display-frame scheduler. `packages/ui`
`conversation-copy.ts` keeps `transcriptGap` (our gap rows use it),
`transcript-row-projection.ts` is restored, `use-pending-selection.ts` goes.
Astryx stays out of package.json and the lockfile; `@ai-sdk/provider-utils`
moves to 5.0.40 and the `@ai-sdk/code-mode` override lands.

Re-implemented for the new contract:
- `lib/ported/desktop-transcript-range-store.ts` and
  `transcript-reading-position.ts` are re-ported from upstream head (the
  previous copies were format-only ports of the old versions);
  `TranscriptReadSupersededError` lives in the latter, and
  `display-frame-scheduler.ts` joins `lib/ported`.
- `store/active-session-store.ts`: the window is the store's — the display
  follows a store subscription rather than `accept`'s return; the paging gate
  and `loadTranscriptHistory` are gone (the controller refuses a read against
  an edge it already read), `loadHistory` keeps only the gap-row indicator;
  `prefetchHistory` and `retainWindow` serve `useChatScroll`'s geometry-driven
  filling and trimming; `setReadingAnchor` only moves the bookmark; the
  bookmark re-anchors after a replica generation change, by sequence within a
  Host epoch and by Turn through the landmark index across one; a read
  superseded by an epoch change is not an error.
- `SessionView` passes `onPrefetchHistory` / `onRetainWindow`; the gap rows
  and the return-to-latest button keep their explicit commands.
- `bridge/sessions.ts` drops `onSeeded`.
- Main tests for the range store, navigation race, overlay settlement and the
  two new probes are upstream's with paths under `lib/ported`; the
  reading-position test keeps upstream's pure-module cases (send pinning,
  overlay-only bookmark, superseded read) — the shell-shaped cases live with
  the store's tests.
- `settings-sections.ts` and the copy files name core's new `external-agents`
  section id as a deferred page.
- Ported apache#5226: an edit that leaves the schedule fields alone omits
  `schedule` from its patch, so the Host keeps a snoozed fire
  (`scheduled-task-form-payload.ts`, `ScheduleFormDialog.tsx`, a test in
  `scheduled-module.test.tsx`).

Also in this tree, found while verifying the sync and not caused by it: a live
Turn's finished steps vanished after switching to another task and back,
leaving only "Working on it…". The Host re-seeds only what is still incomplete
(the streaming text, pending interactions) and Main's transcript overlay is
bootstrapped once per replica, so the steps that finished while the Session
was on screen existed only in the renderer's live projection — which the
store wiped on every selection and reseed. The projection now survives the
switch (a reseed drops only the incomplete text and thinking it replays; a
Turn that ended meanwhile is retired by the transcript it left behind).
`test:streaming-switch` drives the real app through it with a new fake-backend
scenario that settles a text step and a tool call, then holds the Turn open.

The compatible-change declaration `base64-length-allocation.json` is re-pinned
from 143 to the epoch this branch carries (147, upstream's own): upstream left
it at the epoch of its commit and its per-commit hook never re-judged it, while
our merge stages it next to the epoch bump. Its reason (Base64 byte counting
in `artifact.ts` / `session-transcript.ts` without observable change) still
holds against the protocol as merged.

Gates: build:test + build:renderer, typecheck, biome lint and format, locale
hygiene, ASF headers, renderer architecture ledger (rewritten with `--write`;
the new range store's `window` local reads as environment capabilities to the
checker), e2e budget, third-party notices, knip (same findings as before the
merge), desktop dist tests (1599), renderer state (282), Electron smoke (44
checks, no renderer errors), core-dialogue smoke, streaming-switch smoke. `packages/storage`
`workspace-identity` (git worktree ENOTEMPTY) is a parallel-run flake that
passes in isolation, as is `packages/eval` `lifecycle-boundaries` (relay
cancellation timing); `packages/runtime` `model-adapter-onerror` fails on this
machine before and after the merge (asynchronous activity after the test
ended; the file is unchanged this round).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants