From 1dac5381c7c1625111ef0526e86bcc6ad950619c Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 14 Sep 2026 22:39:42 +0800 Subject: [PATCH 1/3] refactor(components): extract session submission boundary Keep creation, continuation, dispatch, and guide behavior behind a UI-independent Promise service. Record the staged attachment draft design and validation. Model: gpt-6 --- .agents/docs/cli-lib-session-files.md | 7 +- .agents/docs/sessions-run-config.md | 10 + .../2026-09-14-deferred-attachment-send.md | 97 ++++ .../2026-09-14-deferred-attachment-send.zh.md | 94 +++ packages/components/src/hooks/README.md | 9 + .../src/hooks/use-session-actions.ts | 539 ++---------------- .../components/src/lib/session-submission.ts | 474 +++++++++++++++ .../tests/use-session-actions.test.ts | 54 +- specs/local-attachment-references.md | 64 +++ specs/local-attachment-references.zh.md | 64 +++ specs/models/session-files.effect-probe.mjs | 249 ++++++++ specs/models/session-files.model.ts | 163 ++++++ specs/session-files.md | 364 ++++++++++++ specs/session-files.zh.md | 364 ++++++++++++ 14 files changed, 2022 insertions(+), 530 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md create mode 100644 .agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md create mode 100644 packages/components/src/lib/session-submission.ts create mode 100644 specs/local-attachment-references.md create mode 100644 specs/local-attachment-references.zh.md create mode 100644 specs/models/session-files.effect-probe.mjs create mode 100644 specs/models/session-files.model.ts create mode 100644 specs/session-files.md create mode 100644 specs/session-files.zh.md diff --git a/.agents/docs/cli-lib-session-files.md b/.agents/docs/cli-lib-session-files.md index d8d3326e0..58bae1fc9 100644 --- a/.agents/docs/cli-lib-session-files.md +++ b/.agents/docs/cli-lib-session-files.md @@ -1,7 +1,12 @@ # Session file attachments in the CLI How a file or image travels from a client into an agent prompt and, eventually, into -cloud storage. Normative intent: `specs/session-files.md`. +cloud storage. The [attachment draft Spec](../../specs/session-files.md) proposes +delaying existing transfers until Send; it retains the lifecycle below. +[Permanent local references](../../specs/local-attachment-references.md) belong to +a separate follow-up PR. Both are drafts, not implementation claims. The client lifecycle proposal +separates upload preparation from durable message delivery; CLI materialization, +Agent execution, and backfill retain their existing owners. [`apps/cli/src/lib/AGENTS.md`](../../apps/cli/src/lib/AGENTS.md) requires this page to be read before session file upload, dispatch materialization, or backfill is changed, because the statements below bind those paths. diff --git a/.agents/docs/sessions-run-config.md b/.agents/docs/sessions-run-config.md index 260f72e97..4326694ae 100644 --- a/.agents/docs/sessions-run-config.md +++ b/.agents/docs/sessions-run-config.md @@ -242,3 +242,13 @@ this page is the full text of the rules summarised there. `` on every platform (Windows included — the renderer no longer crashes once locale `.pak`s ship; see `apps/electron/AGENTS.md`) and routes each selection by MIME into the image or file state machine. + Proposed replacement: [send-time attachment preparation](../../specs/session-files.md). + That draft unifies new-conversation and continuation drafts, moves task ownership + out of the composer, and delays existing transfers until Send. Permanent local + references belong to a separate PR; immediate transfer remains the current implementation. + +The draft Spec also separates composer takeover/focus from actual submission. +Its proposed Effect integration covers child-draft promotion, warmup cleanup, +frozen user choices versus current runtime facts, submission side effects, and +post-submit delivery ownership. These are proposed changes; the source behavior +described above has not been replaced. diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md new file mode 100644 index 000000000..21da6f779 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md @@ -0,0 +1,97 @@ +# Attachment draft lifetimes and PR boundaries + +Status: proposed +Translation: current + +[中文](2026-09-14-deferred-attachment-send.zh.md) + +## Abstract + +Attachments currently transfer on addition, and composer unmounting or failure handling can affect complete submission. The draft feature unifies creation and continuation: addition only validates/previews, an independent service takes over after Send, and every attachment must be ready before submission. Adopt pinned Effect 3.18.4 through four proposed PRs: extract submission boundaries, own resources, complete submission/delivery, then deliver all draft entry points together; the first three retain transfer on addition, at the cost of completing persistence, recovery, and exit boundaries before feature delivery. Original local paths, permanent zero upload, and related protocol/Daemon work remain separate follow-up work. Six probes establish partial cancellation/resource boundaries, including two using the actual store cache; this revision changes design only, without product implementation or acceptance. + +## PR boundary + +The [attachment draft Spec](../../../../specs/session-files.md) owns this PR. New conversations and continuations share takeover, preparation, retry, and cancellation contracts, while retaining distinct final creation/continuation adapters. Both require complete UI acceptance; testing a shared helper or landing alone is insufficient. + +The [direct local-reference Spec](../../../../specs/local-attachment-references.md) separately preserves the follow-up design: original paths, generated local files, registration authority, new attachment protocols, Daemon/adapter/preview compatibility, and disabling backfill. None are implementation or acceptance gates for this PR. The current work delays existing upload/local handoff and preserves attachment types, fallback, backfill, materialization, and platform capabilities without claiming permanently upload-free local files. + +This replaces the initial combined draft/local-reference implementation scope. The later PR plugs into the same preparation boundary instead of duplicating draft state machines. The split reduces this PR's implementation scope, without removing draft failure, cancellation, exit, or recovery requirements. + +## Inspected source and tradeoffs + +Baseline: `8c429a890037c5b21855ce7ef9f59e3677c25a38`. + +- `session-chat-input-area.tsx` and landing hooks transfer on addition. Failed ordinary files are filtered while unsuccessful images block sending. This PR unifies both entry points around complete attachment readiness. +- `use-session-actions.ts` generates turn IDs per call; `HistoryWriter.append` has no ID deduplication, and initial meta/history writes run concurrently. Stable IDs need reconciliation and persistent handoff together. +- An in-memory manager supports in-page navigation but is insufficient for mobile-shell reclamation. Retain local recovery records, preserve drafts on save failure, and do not claim OS background transfer. +- Electron confirmation must still precede relay/CLI cleanup. This belongs to draft lifecycle and is not deferred with local-communication optimization. +- Reusing existing local handoff lets drafts ship independently. Its copying/backfill changes belong to the later PR; current UI must not imply they have disappeared. + +Native mobile shells and private upload services outside this repository were not inspected. API reuse is an implementation direction, not completed integration acceptance. + +## Effect proposal and tradeoffs + +[Spec section 11](../../../../specs/session-files.md#11-effect-ts-integration-and-implementation-order) scopes adoption to workspace preparation, submission, and delivery. React/Jotai retain drafts and short takeover/focus tokens; CLI retains Agent/backfill ownership. Scopes follow release boundaries. Ref.modify is optional state-operation syntax; encapsulated ordinary variables also work. Ref is neither a cross-I/O transaction nor a cross-window lock. + +Current call-chain findings refine the proposal: + +- Mount-scoped useComposerSubmission also owns keyboard/focus; session-chat-input-area's success callback clears drafts and marks visual comments submitted. Split saved takeover from actual acceptance to avoid premature annotation changes, prolonged input locking, or focus theft. Preserve click-time mobile blur; data clearing waits for saving. +- session-detail.handleSendDraft couples child creation, tab promotion, navigation, and failure deletion while reading the current parent. Freeze parent/identity, recover promotion aliases, and never delete a written child on navigation failure. Landing is not the only creation entry. +- useSessionPreparation.handoffToSession only drops references/timers. First version stops warmup on attachment takeover and gives the service exact cancellation cleanup; actual send may cold-start. Preserve CLI TTL/compatible claim and attachment-free warmup reuse. +- workspace-writer-impl runs initial meta/history concurrently and ignores dispatch arguments. use-session-actions.requestSessionDispatch separately launches sync, full-input RPC, and a meta pointer write. Extract UI-independent submission and delivery ownership; old “durable accept unit” comments are not evidence. +- CLI SessionDispatchWatcher.offerRpcTurn ACK means stash/deduplicated receipt, not execution or persistence. RPC carries full input and can start execution early. TurnHistoryGate waits for user history before output writes, so ACK does not end history sync; preserve CLI execution/deduplication ownership. +- createSessionStore separates references from room sync leases; store-ref-tracker owns dispose/unload. Effect finalizers release only their borrow. Preparation/offline parking does not retain full history; late acquisition still needs release. acquireRelease masks acquisition by default, so unbounded acquisition is not controlled shutdown. +- waitUntilSynced(signal) can resolve on abort/detached, while transport-ready waiting does not yet forward cancellation. Distinguish success, skipped, interrupted, and uncertain outcomes; delivery borrows existing sync instead of creating per-message transports/reconnect loops. +- session-chat-interface captures MCP, Role, tool switches, resume, billing guards, presence, and direct locks in components. Freeze user choices and recheck runtime facts after extraction. Ordinary messages share FIFO; preserve unfinished-history queue barriers. Guide false mixes outcomes: authoritative no-active-turn differs from uncertainty. +- resolveWorkspaceRuntimeCacheIdentity isolates repo/cursors per window; another window's empty replica cannot prove rejection. Token refresh need not destroy the service, while account/topology changes require old-generation exit protection. +- Image cancellation, unowned multipart cleanup, noncancelable IPC, runtime/Electron shutdown still need changes. Main temporary files and CLI blobs/backfill keep existing ownership/semantics; upload-free local references remain a separate PR. + +Use one ManagedRuntime with storage, transport, and submission dependency boundaries. Persistent handoff can compact into smaller delivery obligations instead of stopping delivery with upload Scope. The cost is defining actual persistence/sync receipts, original-replica recovery, and side-effect timing; replacing individual Promises is insufficient. Adapt coupled boundaries without rewriting unrelated reconnect/cache/Daemon systems. + +## Staged adoption and rollback + +[Spec section 11.6](../../../../specs/session-files.md#116-staged-adoption-and-acceptance) proposes three prerequisite PRs followed by one complete draft feature PR, each merged after its responsibility is complete: + +1. Extract ordinary submission interfaces while preserving behavior. Baseline cases exercise actual input/configuration/routing; retain existing defects as counterexamples with an owning later fix. +2. Use Effect inside the service to fully own migrated uploads, cancellation, retries, borrows, and release. Components keep ordinary interfaces and transfer still starts on addition; exit cleanup ships with its resources. +3. Own submission/delivery of already-prepared messages, with identity, persistence receipts, uncertain-result reconciliation, cross-window recovery, and necessary exit flows. This explicitly improves reliability while retaining transfer timing. +4. Connect complete drafts for creation, children, and continuations together, including saving, failure, cancellation, ordering, warmup, and platform exit/recovery before enabling the feature. + +Remove the previous owner when migrating a responsibility. Never run real uploads/writes twice or fall back to legacy sending after an uncertain result. Do not simultaneously upgrade Effect, rewrite underlying sync, or implement upload-free local references. The tradeoff is later visible feature delivery in exchange for independently checking behavior, cancellation, and resource ownership at each stage. After introducing recovery records, rollback requires a compatible version that can process them; stop takeover and finish or reliably retain in-flight work first. Deleting records or resending is not rollback. Do not ship the persistent stage before original-replica recovery and record compatibility are defined. + +## Related decisions + +- Preserve [workspace draft isolation](../../implemented/bug-fix/2026-09-11-workspace-window-composer-drafts.md) across new/existing conversation drafts and account/workspace boundaries. +- Preserve the [single history writer](../../implemented/architecture/2026-09-07-single-history-writer.md), without another history mutation path. +- Preserve folded text and pre-send expansion from the [context-copy decision](../../implemented/feature/2026-09-09-conversation-context-fallback.md). Automatic text-file conversion and image editing are excluded. + +## Stack implementation status + +Layer 1 extracts `lib/session-submission.ts` from `use-session-actions.ts` and +keeps React bindings for billing admission, analytics, and observable atoms. +Creation, initial history, continuation, dispatch, and guide still use the same +writer and routing. It preserves transfer timing and acceptance behavior; no +persistent send service or draft product behavior is enabled by this layer. +The base includes main's composer paste-size ceiling (`6fde8b07`). Validation +uses the existing action/composer suites, with writer-call-only assertions +replaced by observable initial/continuation history checks. + +Layer 1 validation: repository typechecking/lint pass; components 3,656 tests, +shared 1,194 tests, and Electron 112 tests pass. The full `pnpm check` run stops +at an unrelated CLI worktree-GC assertion comparing macOS `/var` and +`/private/var` aliases (other CLI cases: 2,791 passed). Its complete 11-test suite +passes with `TMPDIR=/private/tmp`; remaining i18n/import/platform/public-boundary +checks and docs check pass separately. `pnpm format` ran; unrelated formatting +was discarded. No packaged-device draft acceptance is claimed. + +## Verification and limits + +The [finite model](../../../../specs/models/session-files.model.ts) covers only the current draft scope: add/remove/replace/navigate/send gates for both entry points, paired attachment readiness, and two accepted same-session messages with at most one retry each. Acceptance and persistent handoff are separate; cancellation, obsolete callbacks, FIFO, and the old failed-file filtering counterexample remain. Upload-free local routing has been removed from this model. + +Run `node --experimental-strip-types specs/models/session-files.model.ts` and `tsc --noEmit --strict --target ES2022 --module ESNext --lib ES2022,DOM --skipLibCheck specs/models/session-files.model.ts`. This model does not connect to actual UI, writers, disk, IPC, or Agents. Complete new-conversation/continuation acceptance follows A01–A18 and cannot be replaced by the model. + +The [Effect probes](../../../../specs/models/session-files.effect-probe.mjs) use pinned 3.18.4 and pass six cases: noncooperative writes after interruption, signal-controlled transfer, awaited Scope cleanup, phase/generation checks, plus late-acquisition release and preservation of shared UI references through current store-ref-tracker.ts. The cache cases use synthetic stores, not real Loro/disk/network. Explicit gates and releaseIfIdle drive release without elapsed-time or scheduler guesses. + +Reproduce with a temporary effect@3.18.4 installation (`npm install --prefix --ignore-scripts --no-audit --no-fund effect@3.18.4`). Copy specs/models/session-files.effect-probe.mjs and packages/components/src/providers/store-ref-tracker.ts retaining their repository-relative paths; run `node --experimental-strip-types --test /specs/models/session-files.effect-probe.mjs`. This establishes only those boundaries, not real XHR/IPC, writer durability, cross-window coordination, or E01–E12 acceptance. Official v3 sources and current code references are in the Spec. + +The original design checkout had 20 broken links to uninitialized ACP submodules. The independent implementation checkout initializes the pinned submodules: document checks now have zero errors and no registered SHA-protected topics. The three action/composer suites pass 69 tests for layer 1; full repository verification and PR references are recorded with the stack status. Product draft behavior and device acceptance remain incomplete. Specs remain draft and this Note remains proposed. diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md new file mode 100644 index 000000000..7708c17b9 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md @@ -0,0 +1,94 @@ +# 附件 draft 的生命周期与 PR 边界 + +Status: proposed +Translation: current + +[English](2026-09-14-deferred-attachment-send.md) + +## 摘要 + +当前附件添加即传,输入组件卸载和失败后的提交容易影响完整内容。draft 功能统一新建与继续对话:添加只校验/预览,发送后由独立服务接管,全部附件就绪才提交。建议用锁定的 Effect 3.18.4 分四个 PR 接入:先抽提交边界,再管理资源,再落实提交/投递,最后同时交付完整 draft;前三步保留添加即传,代价是功能交付前需要完成持久化、恢复与退出边界。本机原路径引用、永久零上传及相关协议/Daemon 工作仍独立到后续 PR。六个实验验证了部分取消与资源边界,其中两项使用实际 store 缓存;本次仅更新设计,尚未实现或完成产品验收。 + +## PR 边界 + +[附件 draft Spec](../../../../specs/session-files.zh.md)是当前 PR 的意图入口。新对话和继续对话必须使用同一接管、准备、重试、取消契约,但创建会话与继续发送保留各自最终提交适配。两个入口都要经过完整 UI 验收,不能只验证共用 helper 或只完成 landing。 + +[本机直接引用 Spec](../../../../specs/local-attachment-references.zh.md)独立保存后续 PR 的设计:原路径引用、生成内容本机保存、授权登记、新附件协议、Daemon/适配器/预览与兼容,以及禁止后台补传。这些不属于当前 PR 的实现或验收门槛。当前 PR 延后调用既有云端上传或本机 handoff,保留既有附件类型、fallback、backfill、物化和平台能力边界,不承诺同机附件永久不上传。 + +这替代本提案初稿中将 draft 与新本地引用协议一起实施的范围划分;后续 PR 接入同一准备边界,不复制草稿状态机。此拆分降低本 PR 的改动范围,但不减少当前 draft 的失败、取消、退出和恢复要求。 + +## 当前源码与取舍 + +基线:`8c429a890037c5b21855ce7ef9f59e3677c25a38`。 + +- `session-chat-input-area.tsx` 和 landing hooks 均在添加后传输;普通文件失败会被过滤,图片失败则阻止发送。当前 PR 将两类入口统一到全部附件就绪的提交条件。 +- `use-session-actions.ts` 每次生成 turn ID,`HistoryWriter.append` 不按 ID 去重;首次 meta/history 并行写。固定 ID 与结果核对、持久交接需一起落实。 +- 组件外仅存内存任务能支持页面内切换,却不足以覆盖移动壳回收;因此保留本机恢复记录,保存失败保留草稿,不等同于系统后台上传。 +- Electron 退出确认仍要先于 relay/CLI 清理。这是 draft 生命周期责任,不随本机通信优化一起延期。 +- 沿用现有本机 handoff 可以独立交付 draft;该路径的复制和补传是后续 PR 才改变的传输行为,不能在本 PR 文案中暗示已消失。 + +没有检查本仓库之外的移动壳或私有上传服务;复用接口是实现方向,不是已完成集成验收。 + +## Effect 方案与取舍 + +[Spec 第 11 节](../../../../specs/session-files.zh.md#11-effect-ts-落地方案与实施顺序)将接入范围定义为 workspace 的准备、提交、投递流程。React/Jotai 继续拥有草稿与短接管/焦点 token;CLI 拥有 Agent 与 backfill。Scope 依释放边界分组,Ref.modify 只是统一状态操作的可选写法,封装普通变量同样可行;不把 Ref 当作跨 I/O 事务或跨窗口锁。 + +本轮沿实际调用链补充了这些结论: + +- `useComposerSubmission` 的挂载生命周期还管键盘/焦点;`session-chat-input-area` 的成功回调同时清草稿和触发视觉批注已提交。因此“保存并接管”和“真实消息接受”必须拆开,否则会过早改批注、长时间锁输入框或夺回焦点。保持现有移动端点击即收键盘,数据保存成功才清理。 +- `session-detail.handleSendDraft` 将子会话创建、tab 升级、导航和失败删除绑在一起,且读取当前父会话。需要冻结父关系与 ID、恢复升级别名,导航失败不能删除已写子会话;landing 不是唯一新会话入口。 +- `useSessionPreparation.handoffToSession` 只清引用/计时器,不能跨上传管理资源。首版附件接管时停止预热并由服务收尾精确取消,真实发送可冷启动;保留 CLI 硬 TTL、兼容 claim 和无附件的已有复用。 +- `workspace-writer-impl` 的首次 meta/history 并行,dispatch 参数被忽略;`use-session-actions.requestSessionDispatch` 另起同步 Promise、完整输入 RPC 和 meta 指针写入。需抽无 UI 提交适配器和独立投递所有者,不能把旧注释的“durable accept unit”当成当前证明。 +- CLI `SessionDispatchWatcher.offerRpcTurn` ACK 表示暂存/去重接收,不是执行或落盘收据;RPC 实际带完整输入,可以先执行。`TurnHistoryGate` 等用户历史后再写输出,故 ACK 后仍需同步历史;保留 CLI 的执行和去重所有权。 +- `createSessionStore` 的 store 引用与 room sync lease 独立;`store-ref-tracker` 才负责 dispose/unload。Effect finalizer 只退本次借用;准备/离线等待不保活完整历史,获取晚到也不能漏释放。`acquireRelease` 的获取阶段默认不可中断,不能套在无界获取上就宣称退出可控。 +- `waitUntilSynced(signal)` 在 abort/detached 下可直接返回,transport-ready 等待尚未贯通信号;必须区分同步成功、跳过、中断和未知。为投递尝试借用现有同步资源,不另造每消息 transport/reconnect。 +- `session-chat-interface` 的 MCP、Role、工具开关、resume、billing guard、presence 与 direct 锁来自组件。提取后冻结用户选择,复查运行事实;所有普通新消息都遵守 FIFO,保留未完成历史的 queue 屏障。guide 的 false 混合多种结果,权威 no-active-turn 与未知结果必须分开。 +- `resolveWorkspaceRuntimeCacheIdentity` 的 repo/cursor 按窗口隔离;另一窗口空副本不能证明未发送。现有 token 更新不必销毁服务,账户/拓扑切换则需旧代次退出保护。 +- 图片取消、multipart 无人等待的清理、不可取消 IPC、runtime/Electron 关闭顺序仍须调整。main 临时文件、CLI blob/backfill 的所有权和既有传输语义保留,本机跳过上传仍是独立 PR。 + +拟采用一个 ManagedRuntime,按存储、传输、提交三个依赖边界组装;持久交接后继续保留较小的投递义务记录,不让上传 Scope 结束时把投递一起中断。主要代价是要定义真实持久化/同步收据、跨窗口恢复来源和副作用触发时机;不是把现有 Promise 逐个换成 Effect 就结束。只处理耦合边界,不重写无关重连/缓存/Daemon。 + +## 渐进接入与回退 + +[Spec 第 11.6 节](../../../../specs/session-files.zh.md#116-分阶段接入与验收)建议三个前置 PR 加一个完整 draft 功能 PR,按职责完成后再合并: + +1. 抽取普通提交接口,保持当前行为;基线用例覆盖真实输入/配置/路由,已有缺陷保留为反例及后续修复项。 +2. 在服务内部用 Effect 完整管理迁入的上传、取消、重试、借用与释放;组件仍用普通接口,添加即传时机不变,退出收尾随资源一起交付。 +3. 接管已准备消息的提交与投递,落实身份、持久化收据、未知结果核对、跨窗口恢复及必要的退出流程;明确这是可靠性行为改进,仍不切换上传时机。 +4. 同时接通新建、子会话与继续对话的完整 draft,包括保存、失败、取消、顺序、预热和各端退出/恢复后再启用。 + +每迁移一项职责就删除旧所有者,不双跑真实上传/写入,也不在未知结果后退到旧发送路径;不同时升级 Effect、重写底层同步或实现本机零上传。其取舍是晚一些改变产品体验,换取每一步可独立核对行为、取消和资源边界。开始保存恢复记录之后,回退只能使用能处理这些记录的兼容版本;须先停止接管并结束或可靠保留在途工作,不能靠删除记录或重发清场。原副本恢复及记录兼容未确定时,不发布该持久化阶段。 + +## 相关决定 + +- 延续[工作区草稿隔离](../../implemented/bug-fix/2026-09-11-workspace-window-composer-drafts.zh.md):新建和已有会话草稿均不跨账户/工作区泄露。 +- 延续[唯一历史 writer](../../implemented/architecture/2026-09-07-single-history-writer.zh.md),不引入另一套历史写入。 +- 保留[上下文复制](../../implemented/feature/2026-09-09-conversation-context-fallback.zh.md)所确定的折叠文本与发送前展开,自动文本转文件及图片编辑器不随本 PR 引入。 + +## PR 栈实施状态 + +第一层从 `use-session-actions.ts` 提取 `lib/session-submission.ts`,React +保留额度准入、统计和 atom 观察绑定。创建、首条历史、继续发送、dispatch 与 +guide 仍使用同一 writer 和路由,上传时机与接受行为不变;这一层不启用持久 +发送服务或 draft 产品行为。基线已纳入 main 的粘贴大小上限(`6fde8b07`)。 +验证使用现有 actions/composer 套件,将仅检查 writer 调用的用例改为核对真实 +返回并可读的首条/继续对话历史结果。 + +第一层验证:全仓类型检查和 lint 通过;components 3,656 项、shared 1,194 项、 +Electron 112 项通过。完整 `pnpm check` 在无关 CLI worktree-GC 用例比较 macOS +`/var` 与 `/private/var` 路径别名时停止,其余 CLI 2,791 项通过;使用 +`TMPDIR=/private/tmp` 重跑该完整套件,11 项通过。剩余 i18n/import/platform/ +public-boundary 检查及文档检查分别通过。已运行 `pnpm format` 并撤销无关格式 +改动;不宣称完成打包设备上的 draft 验收。 + +## 验证与限制 + +[有限模型](../../../../specs/models/session-files.model.ts)只覆盖当前 draft 范围:两入口的添加/移除/替换/导航/发送门槛、双附件就绪组合,以及同会话两条已接管消息、各至多一次重试的有限状态图。接受回执与持久交接分开;检查取消、过期回调、FIFO,并保留旧失败文件过滤规则的反例。本机零上传路由已从当前模型删除。 + +运行 `node --experimental-strip-types specs/models/session-files.model.ts`,以及 `tsc --noEmit --strict --target ES2022 --module ESNext --lib ES2022,DOM --skipLibCheck specs/models/session-files.model.ts`。模型不连接真实 UI、writer、磁盘、IPC 或 Agent;新对话与继续对话的完整验收按 Spec A01–A18 执行,不能用有限模型代替。 + +[Effect 实验](../../../../specs/models/session-files.effect-probe.mjs)使用锁定的 3.18.4,六项通过:非配合 Promise 中断后仍写入、signal 停止受控传输、Scope 等待异步清理、状态/代次检查,以及使用当前 `store-ref-tracker.ts` 验证晚到获取的释放和共享 UI 引用不被误销毁。后两项使用合成 store;没有真实 Loro、磁盘或网络。释放用显式闸门与 releaseIfIdle 触发,不依靠真实 sleep 或定时器碰运气。 + +复现:临时目录安装 `effect@3.18.4`(`npm install --prefix --ignore-scripts --no-audit --no-fund effect@3.18.4`);按仓库相对路径复制 `specs/models/session-files.effect-probe.mjs` 和 `packages/components/src/providers/store-ref-tracker.ts`;运行 `node --experimental-strip-types --test /specs/models/session-files.effect-probe.mjs`。只证明这些边界,不证明真实图片 XHR、IPC、writer 持久化、多窗口协调或 E01–E12 已验收。官方 v3 资料及当前源码入口列在 Spec 末尾。 + +最初设计工作树有 20 个未初始化 ACP 子模块导致的断链错误。独立实施 checkout 已初始化锁定的子模块,文档检查现在为零错误,没有注册的 SHA 保护主题。第一层的三个 actions/composer 套件共 69 项通过;全仓验证与 PR 链接随栈实施状态记录。完整 draft 产品行为和设备验收仍未完成,Spec 保持 draft,本 Note 保持 proposed。 diff --git a/packages/components/src/hooks/README.md b/packages/components/src/hooks/README.md index 8311cb595..d4e1f8630 100644 --- a/packages/components/src/hooks/README.md +++ b/packages/components/src/hooks/README.md @@ -4,6 +4,15 @@ Binding rules for this directory live in [AGENTS.md](AGENTS.md); this file keeps the reasoning behind them so the rules can stay short. It explains only the hooks that carry an invariant — the directory itself is the list of hooks. +## Session submission + +`use-session-actions.ts` binds admission, analytics, and Jotai observations to +`lib/session-submission.ts`. The latter owns the ordinary Promise entry points +for creation, initial history, continuation, dispatch, and guide. It has no React +lifetime or second writer. This extraction preserves existing upload/acceptance +behavior; persistent delivery and deferred attachment transfer are later layers +of the [attachment draft plan](../../../../specs/session-files.md). + ## Horizontal wheel scrolling `use-horizontal-wheel-scroll.ts` is the one owner for converting a plain vertical diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index 0dcd770de..8736f54a2 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useCloudMutation } from '@lody/platform/react'; import { cloudOperations } from '@/lib/cloud-api-operations'; import { useCloudQuery } from '@lody/platform/react'; @@ -32,7 +32,6 @@ import { formatSessionQuotaRejection, isConvexUnauthenticatedError, isLoroRepoDocDeleted, - normalizeSessionTurnInputConfig, readMachineFlockRowsFromFlock, sanitizeMessageTextSpans, } from '@lody/shared'; @@ -41,7 +40,6 @@ import { usePostHog } from '@posthog/react'; // Default import: `debug` is CJS. Named `{ debug }` breaks Vite 8 / TanStack // module-runner interop used by site-docs SSR (UNEXPECTED named-export error). import debug from 'debug'; -import { v4 as uuidv4 } from 'uuid'; import { activeWorkspaceRuntimeAtom, type WorkspaceRuntime } from '@/atoms/runtime'; import { docMetaCacheReadyAtom, @@ -54,21 +52,18 @@ import { getRpcDeliveredTurnKey, rpcDeliveredTurnsAtom, } from '@/atoms/session-dispatch-delivery'; -import { resolveSessionCreateRepoFullName } from '@/lib/session-repo'; import { capturePostHogEvent } from '@/lib/posthog-analytics'; import { sendIpc } from '@/lib/electron-ipc-client'; import { useAuthenticatedConvex } from './use-authenticated-convex'; +import { + createSessionSubmission, + type CreateSessionResult, + type StartSessionResult, +} from '@/lib/session-submission'; const log = debug('lody:session-actions'); type RepoDocMetaPatch = Parameters[1]; -type CreateSessionResult = { - sessionId: SessionId; - sessionMeta: SessionMeta; -}; -type StartSessionResult = CreateSessionResult & { - historyEntry: SessionHistory; -}; export type SessionChatType = 'regular' | 'side_chat'; @@ -131,63 +126,6 @@ export function countSessionMentions(items: SessionHistoryInput['items']): Sessi }; } -function buildSessionCreateResult(payload: SessionToCreate): CreateSessionResult { - const sessionId = payload.sessionId ?? (uuidv4() as SessionId); - const sessionMeta: SessionMeta = { - id: sessionId, - machineId: payload.machineId, - userId: payload.userId, - status: SessionStatusFactory.idle(), - isArchived: false, - createdAt: new Date().toISOString(), - cliType: payload.cliType, - agentType: payload.agentType, - agentConfigId: payload.agentConfigId, - acpSessionId: undefined, - diffStats: undefined, - }; - if (payload.title?.trim()) { - sessionMeta.title = payload.title.trim(); - sessionMeta.titleSource = payload.titleSource ?? 'user'; - } - if (payload.fromFeedbackPostId?.trim()) { - sessionMeta.fromFeedbackPostId = payload.fromFeedbackPostId.trim(); - } - const repoFullName = resolveSessionCreateRepoFullName(payload); - if (repoFullName) { - sessionMeta.repoFullName = repoFullName; - } - if (payload.project) { - sessionMeta.project = payload.project; - } - if ( - payload.isWorktree === true || - payload.project?.kind === 'github' || - payload.project?.useWorktree === true - ) { - sessionMeta.isWorktree = true; - } - const baseBranch = - payload.project?.kind === 'local' - ? undefined - : payload.project?.branch?.trim() || payload.branchName?.trim(); - if (baseBranch) { - sessionMeta.baseBranch = baseBranch; - } - if (payload.parentSessionId) { - sessionMeta.parentSessionId = payload.parentSessionId; - } - // Where this session came from, not how it runs: the launch config above is - // already frozen, so nothing re-reads the mutable Role catalog from these. - if (payload.agentRoleId) { - sessionMeta.agentRoleId = payload.agentRoleId; - if (typeof payload.agentRoleRevision === 'number') { - sessionMeta.agentRoleRevision = payload.agentRoleRevision; - } - } - return { sessionId, sessionMeta }; -} - /** * Local workspace state rejected creating this session for billing reasons * (free session limit, or the workspace is waiting on checkout). Callers surface @@ -407,74 +345,6 @@ export async function touchSessionActivityMeta( } } -/** - * Fire the `session/dispatch-turn` Machine RPC fast path for a user turn that - * is (or is about to be) durable. Returns a promise resolving to whether the - * machine accepted the offer, or null when the offer cannot be built. The RPC - * only accelerates dispatch — the durable `latestUserMsgId` pointer write - * remains recovery truth. - */ -function fireSessionDispatchTurnRpc( - runtime: WorkspaceRuntime, - store: ReturnType, - args: { - sessionId: SessionId; - userTurnId: string; - machineId: MachineId | null | undefined; - timestamp: string | undefined; - inputConfig: SessionTurnInputConfig | undefined; - dispatchUserId: string | undefined; - } -): Promise | null { - const { sessionId, userTurnId, machineId, timestamp, inputConfig, dispatchUserId } = args; - // The Machine RPC fast path rides the facade's per-target routing: local - // machines go over the local socket RPC, remote machines over the cloud - // JSON stream. - if (!machineId || !timestamp || !inputConfig || !dispatchUserId) { - return null; - } - const rpcArgs = { - sessionId, - userTurnId, - userId: dispatchUserId, - timestamp, - inputConfig, - }; - // Attachments ride as R2/local references, so payloads are normally - // small; skip the fast path for pathological sizes rather than risk an - // oversized stream append. - try { - if (JSON.stringify(rpcArgs).length > 256 * 1024) { - return null; - } - } catch { - return null; - } - return runtime - .requestSessionDispatchTurn(machineId, rpcArgs) - .then((response) => { - if (response?.accepted) { - store.set(rpcDeliveredTurnsAtom, (previous) => - addRpcDeliveredTurn(previous, getRpcDeliveredTurnKey(sessionId, userTurnId)) - ); - return true; - } - log( - 'session dispatch-turn rpc not accepted for %s/%s: %s', - sessionId, - userTurnId, - response - ? `${response.disposition}${response.error ? `: ${response.error}` : ''}` - : 'timeout' - ); - return false; - }) - .catch((error) => { - log('session dispatch-turn rpc threw for %s/%s: %o', sessionId, userTurnId, error); - return false; - }); -} - export function useSessionActions(): SessionActions { const runtime = useAtomValue(activeWorkspaceRuntimeAtom); const setDocMetaByRoomId = useSetAtom(setDocMetaByRoomIdAtom); @@ -530,174 +400,48 @@ export function useSessionActions(): SessionActions { [billingEntitlement, runtime, store] ); - const createSession = useCallback( - async (payload: SessionToCreate): Promise => { - if (!runtime) { - throw new Error('Runtime not ready'); - } - const { sessionId, sessionMeta } = buildSessionCreateResult(payload); - const sessionRoomId = getSessionRoomId(sessionId); - // The local Flock index is the session-count source of truth. Incomplete - // local state fails open so session creation never depends on Convex - // availability or a server-side reservation. - assertSessionCreateAllowed(sessionId); - if (payload.parentSessionId) { - // Creating a child session (filter/sieve) is an explicit active user action. - recordWorkspaceActivity(runtime.workspaceId); - } - - const metaWrite = runtime.writer.upsertDocMeta(sessionRoomId, sessionMeta); - // Stream pre-creation is a warm-up, not part of accepting the user's turn. - // Rejected: awaiting it here lets a stuck createStream() prevent history - // and dispatch writes. Room join/retry handles stream_not_found recovery. - void runtime.ensureDocStream(sessionRoomId).catch((error: unknown) => { - console.warn('Failed to pre-create session doc stream', { sessionId, error }); - }); - await metaWrite; - setDocMetaByRoomId(sessionRoomId, sessionMeta); - - return { sessionId, sessionMeta }; - }, - [assertSessionCreateAllowed, recordWorkspaceActivity, runtime, setDocMetaByRoomId] - ); - - const startSession = useCallback( - async ( - payload: SessionToCreate, - history: Omit - ): Promise => { - if (!runtime) { - throw new Error('Runtime not ready'); - } - const { sessionId, sessionMeta } = buildSessionCreateResult(payload); - // The accept unit includes the first user message, so the meta it - // publishes already carries that activity. Written here, not by a - // follow-up touch: a close between acceptance and the first turn must - // never make the session look empty (empty tabs are deleted, not - // archived). - sessionMeta.lastMessageAt = getServerNow(); - const sessionRoomId = getSessionRoomId(sessionId); - const historyEntry = { ...history, id: uuidv4() } as SessionHistory; - const inputConfig = normalizeSessionTurnInputConfig(historyEntry.inputConfig); - const userId = historyEntry.userId?.trim(); - const timestamp = historyEntry.timestamp?.trim(); - if (historyEntry.role !== 'user' || !userId || !timestamp || !inputConfig) { - throw new Error(`Cannot start session with invalid user history (sessionId=${sessionId})`); - } - - assertSessionCreateAllowed(sessionId); - recordWorkspaceActivity(runtime.workspaceId); - void runtime.ensureDocStream(sessionRoomId).catch((error: unknown) => { - console.warn('Failed to pre-create session doc stream', { sessionId, error }); - }); - await runtime.writer.startSession( - sessionId, - sessionMeta as unknown as Record, - historyEntry, - { - userTurnId: historyEntry.id, - userId, - timestamp, - inputConfig: inputConfig as unknown as Record, - } - ); - setDocMetaByRoomId(sessionRoomId, sessionMeta); - capturePostHogEvent(postHog, 'session/chat', { - user_id: sessionMeta.userId, - workspace_id: runtime.workspaceId, - session_id: sessionId, - machine_id: sessionMeta.machineId, - agent_config_id: sessionMeta.agentConfigId, - cli_type: sessionMeta.cliType, - agent_type: sessionMeta.agentType, - project_kind: sessionMeta.project?.kind ?? null, - is_first_message: true, - session_type: resolveSessionChatType(sessionMeta), - ...countSessionMentions(history.items), - }); - return { sessionId, sessionMeta, historyEntry }; - }, - [postHog, recordWorkspaceActivity, assertSessionCreateAllowed, runtime, setDocMetaByRoomId] - ); - - const addSessionHistory = useCallback( - async ( - sessionId: SessionId, - history: Omit, - options?: { dispatch?: boolean } - ) => { - if (!runtime) { - throw new Error('Runtime not ready'); - } - - // Sending any user message (new chat, reply, child-session/filter reply) - // counts as an explicit active user action. - if (history.role === 'user') { - recordWorkspaceActivity(runtime.workspaceId); - } - - const entry = { ...history, id: uuidv4() } as SessionHistory; - - // The pending user turn is authored through the writer seam. In direct - // (web/cloud) mode the writer authors it into the renderer's own repo, - // exactly as `sessionStore.setState(history.push(entry))` did before. In - // intent (Electron local-first) mode it forwards the append to the CLI — - // the sole author — which relays the authored op back into the local - // mirror and up to Loro Streams. This resolves when the write is ACCEPTED - // (the send hot-path accept boundary), not when remote sync completes; the - // caller may clear the composer / navigate once it returns. It REJECTS - // when the write did not happen (intent failed after bounded retries), - // which propagates to the send paths' failure branches — the composer - // stays intact and the error is surfaced instead of the message silently - // vanishing. - // - let dispatch: - | { - userTurnId: string; - userId: string; - timestamp: string; - inputConfig: Record; - } - | undefined; - if (options?.dispatch) { - const inputConfig = normalizeSessionTurnInputConfig(entry.inputConfig); - const userId = entry.userId?.trim(); - const timestamp = entry.timestamp?.trim(); - if (!userId || !timestamp || !inputConfig) { - throw new Error(`Cannot dispatch invalid user history entry (sessionId=${sessionId})`); - } - dispatch = { - userTurnId: entry.id, - userId, - timestamp, - inputConfig: inputConfig as unknown as Record, - }; - } - await runtime.writer.appendSessionTurn(sessionId, entry, dispatch); - // session/chat fires once for every user message dispatched through Lody — - // the session-creating turn AND every follow-up — so it tracks active-use - // frequency, unlike session/start_success which only covers creation. This - // is the single convergence point for both the chat-landing (new session) - // and session-chat-interface (reply/queue/child) send paths. - if (history.role === 'user') { - const sessionMeta = store.get(sessionMetaCacheAtom)[getSessionRoomId(sessionId)]; - capturePostHogEvent(postHog, 'session/chat', { - user_id: sessionMeta?.userId, - workspace_id: runtime.workspaceId, - session_id: sessionId, - machine_id: sessionMeta?.machineId, - agent_config_id: sessionMeta?.agentConfigId, - cli_type: sessionMeta?.cliType, - agent_type: sessionMeta?.agentType, - project_kind: sessionMeta?.project?.kind ?? null, - is_first_message: false, - session_type: resolveSessionChatType(sessionMeta), - ...countSessionMentions(history.items), - }); - } - return entry; - }, - [runtime, recordWorkspaceActivity, postHog, store] + const { + createSession, + startSession, + addSessionHistory, + requestSessionDispatch, + requestSessionSteer, + } = useMemo( + () => + createSessionSubmission({ + runtime, + assertSessionCreateAllowed, + recordWorkspaceActivity, + publishSessionMeta: setDocMetaByRoomId, + readSessionMeta: (sessionId) => + store.get(sessionMetaCacheAtom)[getSessionRoomId(sessionId)], + onRpcDelivered: (sessionId, turnId) => + store.set(rpcDeliveredTurnsAtom, (previous) => + addRpcDeliveredTurn(previous, getRpcDeliveredTurnKey(sessionId, turnId)) + ), + recordChat: (meta, sessionId, first, items) => + capturePostHogEvent(postHog, 'session/chat', { + user_id: meta?.userId, + workspace_id: runtime?.workspaceId, + session_id: sessionId, + machine_id: meta?.machineId, + agent_config_id: meta?.agentConfigId, + cli_type: meta?.cliType, + agent_type: meta?.agentType, + project_kind: meta?.project?.kind ?? null, + is_first_message: first, + session_type: resolveSessionChatType(meta), + ...countSessionMentions(items), + }), + }), + [ + runtime, + assertSessionCreateAllowed, + recordWorkspaceActivity, + setDocMetaByRoomId, + store, + postHog, + ] ); const updateSessionStatus = useCallback( @@ -728,80 +472,6 @@ export function useSessionActions(): SessionActions { [runtime] ); - const requestSessionDispatch = useCallback( - async ( - sessionId: SessionId, - userTurnId: string, - options?: { inputConfig?: SessionTurnInputConfig; machineId?: MachineId | null } - ) => { - if (!runtime) { - throw new Error('Runtime not ready'); - } - const entry = await runtime.withSessionStore(sessionId, async (sessionStore) => { - const read = await sessionStore.sessionData.history.readTurn(userTurnId); - return read.state === 'ready' && read.turn.role === 'user' ? read.turn : undefined; - }); - const inputConfig = - options?.inputConfig ?? normalizeSessionTurnInputConfig(entry?.inputConfig); - const dispatchUserId = entry?.userId?.trim(); - let rpcAcceptedPromise: Promise | null = null; - const startDispatchTurnRpc = (machineId: MachineId | null | undefined): void => { - // The durable pointer write below remains recovery truth. - rpcAcceptedPromise = fireSessionDispatchTurnRpc(runtime, store, { - sessionId, - userTurnId, - machineId, - timestamp: entry?.timestamp, - inputConfig, - dispatchUserId, - }); - }; - - // Local history writes are the accept boundary. Remote document sync is a - // sibling of dispatch signaling, never a blocker for clearing the composer. - // Hold a store ref for the flush so eviction cannot unload the doc mid-flush. - void runtime - .withSessionStore(sessionId, (sessionStore) => sessionStore.waitUntilSynced()) - .catch((error: unknown) => { - console.warn('Failed to sync session doc after dispatch request', { - sessionId, - userTurnId, - error, - }); - }); - startDispatchTurnRpc(options?.machineId ?? null); - const roomId = getSessionRoomId(sessionId); - const existing = await runtime.repo.getDocMeta(roomId); - if (isLoroRepoDocDeleted(existing)) { - return; - } - if (!options?.machineId) { - const meta = existing?.meta as SessionMeta | undefined; - startDispatchTurnRpc(meta?.machineId ?? null); - } - try { - await runtime.writer.upsertDocMeta(roomId, { - latestUserMsgId: userTurnId, - } as Partial); - } catch (error) { - // The RPC fast path may already have delivered this turn to the CLI; a - // rejection here would make callers toast "failed to send" for a turn - // that is actually running, inviting a duplicate resend. Only surface - // the failure when the fast path did not deliver. - if (await rpcAcceptedPromise) { - console.warn('Dispatch metadata write failed after RPC fast-path delivery', { - sessionId, - userTurnId, - error, - }); - return; - } - throw error; - } - }, - [runtime, store] - ); - const requestSessionCancel = useCallback( async (sessionId: SessionId, turnId: string) => { if (!runtime) { @@ -862,119 +532,6 @@ export function useSessionActions(): SessionActions { [runtime] ); - const requestSessionSteer = useCallback( - async ( - sessionId: SessionId, - expectedTurnId: string, - userTurnId: string, - options?: { machineId?: MachineId | null } - ): Promise => { - if (!runtime) { - throw new Error('Runtime not ready'); - } - const entry = await runtime.withSessionStore(sessionId, async (sessionStore) => { - const read = await sessionStore.sessionData.history.readTurn(userTurnId); - return read.state === 'ready' && read.turn.role === 'user' ? read.turn : undefined; - }); - const inputConfig = normalizeSessionTurnInputConfig(entry?.inputConfig); - const userId = entry?.userId?.trim(); - const roomId = getSessionRoomId(sessionId); - let machineId = options?.machineId ?? null; - if (!machineId) { - const existing = await runtime.repo.getDocMeta(roomId); - const meta = isLoroRepoDocDeleted(existing) - ? undefined - : (existing?.meta as SessionMeta | undefined); - machineId = meta?.machineId ?? null; - } - if (!entry || !inputConfig || !userId || !machineId) { - return false; - } - const steerRequest = { - sessionId, - expectedTurnId, - userTurnId, - userId, - timestamp: entry.timestamp, - inputConfig, - }; - let response = await runtime.requestSessionSteer(machineId, steerRequest); - if (response?.recoveryOwned && response.disposition === 'promotion-failed') { - // This verdict proves non-delivery. Repair through the same owner once; - // a renderer pointer write could erase a newer producer activation. - response = await runtime.requestSessionSteer(machineId, steerRequest); - if ( - !response || - response.disposition === 'promotion-failed' || - response.disposition === 'error' - ) { - throw new Error(response?.error ?? 'Could not recover the undelivered guidance'); - } - } - if (response?.applied) { - store.set(rpcDeliveredTurnsAtom, (previous) => - addRpcDeliveredTurn(previous, getRpcDeliveredTurnKey(sessionId, userTurnId)) - ); - return true; - } - if ( - !response?.recoveryOwned && - (response?.disposition === 'no-active-turn' || response?.disposition === 'promotion-failed') - ) { - // The CLI proved the steer was not applied, either before submission - // or from the adapter's final verdict. Reuse the same user turn as an - // ordinary follow-up. Ambiguous legacy results must not be promoted: - // replay could deliver the input twice. - // Re-acquire the store for the write: the steer RPC above can run long, - // and we must not hold a store ref across it. - const promoted = await runtime.withSessionStore(sessionId, async (sessionStore) => { - const changed = - ( - await sessionStore.sessionData.commands.applyHistoryAction({ - kind: 'user-status', - turnId: userTurnId, - status: 'pending', - onlyPendingApply: true, - }) - ).matched ?? false; - if (changed) return true; - // CLI promotion can write history before its activation pointer - // fails. Auto-seen may also have observed that pending entry. - const read = await sessionStore.sessionData.history.readTurn(userTurnId); - return ( - read.state === 'ready' && - read.turn.role === 'user' && - (read.turn.status === 'pending' || read.turn.status === 'seen') - ); - }); - // Pending promotion is repairable; a started, terminal, or removed turn is not. - if (!promoted) { - return false; - } - await requestSessionDispatch(sessionId, userTurnId, { - inputConfig, - machineId, - }); - log( - 'session steer promoted to ordinary dispatch for %s/%s after target turn ended', - sessionId, - userTurnId - ); - return false; - } - log( - 'session steer not applied for %s/%s: %s', - sessionId, - userTurnId, - response - ? `${response.disposition}${response.error ? `: ${response.error}` : ''}` - : 'timeout' - ); - return false; - }, - [requestSessionDispatch, runtime, store] - ); - const touchSessionActivity = useCallback( async (sessionId: SessionId) => { if (!runtime) { diff --git a/packages/components/src/lib/session-submission.ts b/packages/components/src/lib/session-submission.ts new file mode 100644 index 000000000..1b6e5a184 --- /dev/null +++ b/packages/components/src/lib/session-submission.ts @@ -0,0 +1,474 @@ +import type { + SessionHistory, + SessionHistoryInput, + SessionId, + SessionMeta, + SessionToCreate, + MachineId, + SessionTurnInputConfig, +} from '@lody/shared'; +import { + getSessionRoomId, + getServerNow, + isLoroRepoDocDeleted, + normalizeSessionTurnInputConfig, + SessionStatusFactory, +} from '@lody/shared'; +import { v4 as uuidv4 } from 'uuid'; +import debug from 'debug'; +import type { WorkspaceRuntime } from '@/atoms/runtime'; +import { resolveSessionCreateRepoFullName } from './session-repo'; + +const log = debug('lody:session-submission'); + +export type CreateSessionResult = { sessionId: SessionId; sessionMeta: SessionMeta }; +export type StartSessionResult = CreateSessionResult & { historyEntry: SessionHistory }; + +/** UI bindings supply observation/admission, not another history writer. */ +export type SessionSubmissionPorts = { + runtime: WorkspaceRuntime | null; + assertSessionCreateAllowed: (sessionId: SessionId) => void; + recordWorkspaceActivity: (workspaceId: string | undefined) => void; + publishSessionMeta: (roomId: string, meta: SessionMeta) => void; + readSessionMeta: (sessionId: SessionId) => SessionMeta | undefined; + recordChat: ( + meta: SessionMeta | undefined, + sessionId: SessionId, + first: boolean, + items: SessionHistoryInput['items'] + ) => void; + onRpcDelivered: (sessionId: SessionId, turnId: string) => void; +}; + +function buildSessionCreateResult(payload: SessionToCreate): CreateSessionResult { + const sessionId = payload.sessionId ?? (uuidv4() as SessionId); + const sessionMeta: SessionMeta = { + id: sessionId, + machineId: payload.machineId, + userId: payload.userId, + status: SessionStatusFactory.idle(), + isArchived: false, + createdAt: new Date().toISOString(), + cliType: payload.cliType, + agentType: payload.agentType, + agentConfigId: payload.agentConfigId, + acpSessionId: undefined, + diffStats: undefined, + }; + if (payload.title?.trim()) { + sessionMeta.title = payload.title.trim(); + sessionMeta.titleSource = payload.titleSource ?? 'user'; + } + if (payload.fromFeedbackPostId?.trim()) { + sessionMeta.fromFeedbackPostId = payload.fromFeedbackPostId.trim(); + } + const repoFullName = resolveSessionCreateRepoFullName(payload); + if (repoFullName) { + sessionMeta.repoFullName = repoFullName; + } + if (payload.project) { + sessionMeta.project = payload.project; + } + if ( + payload.isWorktree === true || + payload.project?.kind === 'github' || + payload.project?.useWorktree === true + ) { + sessionMeta.isWorktree = true; + } + const baseBranch = + payload.project?.kind === 'local' + ? undefined + : payload.project?.branch?.trim() || payload.branchName?.trim(); + if (baseBranch) { + sessionMeta.baseBranch = baseBranch; + } + if (payload.parentSessionId) { + sessionMeta.parentSessionId = payload.parentSessionId; + } + // Where this session came from, not how it runs: the launch config above is + // already frozen, so nothing re-reads the mutable Role catalog from these. + if (payload.agentRoleId) { + sessionMeta.agentRoleId = payload.agentRoleId; + if (typeof payload.agentRoleRevision === 'number') { + sessionMeta.agentRoleRevision = payload.agentRoleRevision; + } + } + return { sessionId, sessionMeta }; +} + +/** + * Fire the `session/dispatch-turn` Machine RPC fast path for a user turn that + * is (or is about to be) durable. Returns a promise resolving to whether the + * machine accepted the offer, or null when the offer cannot be built. The RPC + * only accelerates dispatch — the durable `latestUserMsgId` pointer write + * remains recovery truth. + */ +function fireSessionDispatchTurnRpc( + runtime: WorkspaceRuntime, + onRpcDelivered: SessionSubmissionPorts['onRpcDelivered'], + args: { + sessionId: SessionId; + userTurnId: string; + machineId: MachineId | null | undefined; + timestamp: string | undefined; + inputConfig: SessionTurnInputConfig | undefined; + dispatchUserId: string | undefined; + } +): Promise | null { + const { sessionId, userTurnId, machineId, timestamp, inputConfig, dispatchUserId } = args; + // The Machine RPC fast path rides the facade's per-target routing: local + // machines go over the local socket RPC, remote machines over the cloud + // JSON stream. + if (!machineId || !timestamp || !inputConfig || !dispatchUserId) { + return null; + } + const rpcArgs = { + sessionId, + userTurnId, + userId: dispatchUserId, + timestamp, + inputConfig, + }; + // Attachments ride as R2/local references, so payloads are normally + // small; skip the fast path for pathological sizes rather than risk an + // oversized stream append. + try { + if (JSON.stringify(rpcArgs).length > 256 * 1024) { + return null; + } + } catch { + return null; + } + return runtime + .requestSessionDispatchTurn(machineId, rpcArgs) + .then((response) => { + if (response?.accepted) { + onRpcDelivered(sessionId, userTurnId); + return true; + } + log( + 'session dispatch-turn rpc not accepted for %s/%s: %s', + sessionId, + userTurnId, + response + ? `${response.disposition}${response.error ? `: ${response.error}` : ''}` + : 'timeout' + ); + return false; + }) + .catch((error) => { + log('session dispatch-turn rpc threw for %s/%s: %o', sessionId, userTurnId, error); + return false; + }); +} + +/** Ordinary Promise boundary shared by all existing submission entry points. */ +export function createSessionSubmission(ports: SessionSubmissionPorts) { + const { + runtime, + assertSessionCreateAllowed, + recordWorkspaceActivity, + publishSessionMeta, + readSessionMeta, + recordChat, + onRpcDelivered, + } = ports; + + const createSession = async (payload: SessionToCreate): Promise => { + if (!runtime) { + throw new Error('Runtime not ready'); + } + const { sessionId, sessionMeta } = buildSessionCreateResult(payload); + const sessionRoomId = getSessionRoomId(sessionId); + // The local Flock index is the session-count source of truth. Incomplete + // local state fails open so session creation never depends on Convex + // availability or a server-side reservation. + assertSessionCreateAllowed(sessionId); + if (payload.parentSessionId) { + // Creating a child session (filter/sieve) is an explicit active user action. + recordWorkspaceActivity(runtime.workspaceId); + } + + const metaWrite = runtime.writer.upsertDocMeta(sessionRoomId, sessionMeta); + // Stream pre-creation is a warm-up, not part of accepting the user's turn. + // Rejected: awaiting it here lets a stuck createStream() prevent history + // and dispatch writes. Room join/retry handles stream_not_found recovery. + void runtime.ensureDocStream(sessionRoomId).catch((error: unknown) => { + console.warn('Failed to pre-create session doc stream', { sessionId, error }); + }); + await metaWrite; + publishSessionMeta(sessionRoomId, sessionMeta); + + return { sessionId, sessionMeta }; + }; + + const startSession = async ( + payload: SessionToCreate, + history: Omit + ): Promise => { + if (!runtime) { + throw new Error('Runtime not ready'); + } + const { sessionId, sessionMeta } = buildSessionCreateResult(payload); + // The accept unit includes the first user message, so the meta it + // publishes already carries that activity. Written here, not by a + // follow-up touch: a close between acceptance and the first turn must + // never make the session look empty (empty tabs are deleted, not + // archived). + sessionMeta.lastMessageAt = getServerNow(); + const sessionRoomId = getSessionRoomId(sessionId); + const historyEntry = { ...history, id: uuidv4() } as SessionHistory; + const inputConfig = normalizeSessionTurnInputConfig(historyEntry.inputConfig); + const userId = historyEntry.userId?.trim(); + const timestamp = historyEntry.timestamp?.trim(); + if (historyEntry.role !== 'user' || !userId || !timestamp || !inputConfig) { + throw new Error(`Cannot start session with invalid user history (sessionId=${sessionId})`); + } + + assertSessionCreateAllowed(sessionId); + recordWorkspaceActivity(runtime.workspaceId); + void runtime.ensureDocStream(sessionRoomId).catch((error: unknown) => { + console.warn('Failed to pre-create session doc stream', { sessionId, error }); + }); + await runtime.writer.startSession( + sessionId, + sessionMeta as unknown as Record, + historyEntry, + { + userTurnId: historyEntry.id, + userId, + timestamp, + inputConfig: inputConfig as unknown as Record, + } + ); + publishSessionMeta(sessionRoomId, sessionMeta); + recordChat(sessionMeta, sessionId, true, history.items); + return { sessionId, sessionMeta, historyEntry }; + }; + + const addSessionHistory = async ( + sessionId: SessionId, + history: Omit, + options?: { dispatch?: boolean } + ) => { + if (!runtime) { + throw new Error('Runtime not ready'); + } + + // Sending any user message (new chat, reply, child-session/filter reply) + // counts as an explicit active user action. + if (history.role === 'user') { + recordWorkspaceActivity(runtime.workspaceId); + } + + const entry = { ...history, id: uuidv4() } as SessionHistory; + + // Acceptance is the existing renderer writer boundary. Persistence and + // independent delivery are introduced in the next layer of the stack. + let dispatch: + | { + userTurnId: string; + userId: string; + timestamp: string; + inputConfig: Record; + } + | undefined; + if (options?.dispatch) { + const inputConfig = normalizeSessionTurnInputConfig(entry.inputConfig); + const userId = entry.userId?.trim(); + const timestamp = entry.timestamp?.trim(); + if (!userId || !timestamp || !inputConfig) { + throw new Error(`Cannot dispatch invalid user history entry (sessionId=${sessionId})`); + } + dispatch = { + userTurnId: entry.id, + userId, + timestamp, + inputConfig: inputConfig as unknown as Record, + }; + } + await runtime.writer.appendSessionTurn(sessionId, entry, dispatch); + // session/chat fires once for every user message dispatched through Lody — + // the session-creating turn AND every follow-up — so it tracks active-use + // frequency, unlike session/start_success which only covers creation. This + // is the single convergence point for both the chat-landing (new session) + // and session-chat-interface (reply/queue/child) send paths. + if (history.role === 'user') { + const sessionMeta = readSessionMeta(sessionId); + recordChat(sessionMeta, sessionId, false, history.items); + } + return entry; + }; + + const requestSessionDispatch = async ( + sessionId: SessionId, + userTurnId: string, + options?: { inputConfig?: SessionTurnInputConfig; machineId?: MachineId | null } + ) => { + if (!runtime) { + throw new Error('Runtime not ready'); + } + const entry = await runtime.withSessionStore(sessionId, async (sessionStore) => { + const read = await sessionStore.sessionData.history.readTurn(userTurnId); + return read.state === 'ready' && read.turn.role === 'user' ? read.turn : undefined; + }); + const inputConfig = options?.inputConfig ?? normalizeSessionTurnInputConfig(entry?.inputConfig); + const dispatchUserId = entry?.userId?.trim(); + let rpcAcceptedPromise: Promise | null = null; + const startDispatchTurnRpc = (machineId: MachineId | null | undefined): void => { + // The durable pointer write below remains recovery truth. + rpcAcceptedPromise = fireSessionDispatchTurnRpc(runtime, onRpcDelivered, { + sessionId, + userTurnId, + machineId, + timestamp: entry?.timestamp, + inputConfig, + dispatchUserId, + }); + }; + + // Local history writes are the accept boundary. Remote document sync is a + // sibling of dispatch signaling, never a blocker for clearing the composer. + // Hold a store ref for the flush so eviction cannot unload the doc mid-flush. + void runtime + .withSessionStore(sessionId, (sessionStore) => sessionStore.waitUntilSynced()) + .catch((error: unknown) => { + console.warn('Failed to sync session doc after dispatch request', { + sessionId, + userTurnId, + error, + }); + }); + startDispatchTurnRpc(options?.machineId ?? null); + const roomId = getSessionRoomId(sessionId); + const existing = await runtime.repo.getDocMeta(roomId); + if (isLoroRepoDocDeleted(existing)) { + return; + } + if (!options?.machineId) { + const meta = existing?.meta as SessionMeta | undefined; + startDispatchTurnRpc(meta?.machineId ?? null); + } + try { + await runtime.writer.upsertDocMeta(roomId, { + latestUserMsgId: userTurnId, + } as Partial); + } catch (error) { + // The RPC fast path may already have delivered this turn to the CLI; a + // rejection here would make callers toast "failed to send" for a turn + // that is actually running, inviting a duplicate resend. Only surface + // the failure when the fast path did not deliver. + if (await rpcAcceptedPromise) { + console.warn('Dispatch metadata write failed after RPC fast-path delivery', { + sessionId, + userTurnId, + error, + }); + return; + } + throw error; + } + }; + + const requestSessionSteer = async ( + sessionId: SessionId, + expectedTurnId: string, + userTurnId: string, + options?: { machineId?: MachineId | null } + ): Promise => { + if (!runtime) { + throw new Error('Runtime not ready'); + } + const entry = await runtime.withSessionStore(sessionId, async (sessionStore) => { + const read = await sessionStore.sessionData.history.readTurn(userTurnId); + return read.state === 'ready' && read.turn.role === 'user' ? read.turn : undefined; + }); + const inputConfig = normalizeSessionTurnInputConfig(entry?.inputConfig); + const userId = entry?.userId?.trim(); + const roomId = getSessionRoomId(sessionId); + let machineId = options?.machineId ?? null; + if (!machineId) { + const existing = await runtime.repo.getDocMeta(roomId); + const meta = isLoroRepoDocDeleted(existing) + ? undefined + : (existing?.meta as SessionMeta | undefined); + machineId = meta?.machineId ?? null; + } + if (!entry || !inputConfig || !userId || !machineId) { + return false; + } + const response = await runtime.requestSessionSteer(machineId, { + sessionId, + expectedTurnId, + userTurnId, + userId, + timestamp: entry.timestamp, + inputConfig, + }); + if (response?.applied) { + onRpcDelivered(sessionId, userTurnId); + return true; + } + if ( + response?.disposition === 'no-active-turn' || + response?.disposition === 'promotion-failed' + ) { + // The CLI proved the steer was not applied, either before submission + // or from the adapter's final verdict. Reuse the same user turn as an + // ordinary follow-up. `delivery-unknown` and every other result stay + // pending_apply because replay could deliver the input twice. + // Re-acquire the store for the write: the steer RPC above can run long, + // and we must not hold a store ref across it. + const promoted = await runtime.withSessionStore(sessionId, async (sessionStore) => { + const changed = + ( + await sessionStore.sessionData.commands.applyHistoryAction({ + kind: 'user-status', + turnId: userTurnId, + status: 'pending', + onlyPendingApply: true, + }) + ).matched ?? false; + if (changed) return true; + // CLI promotion can write history before its activation pointer + // fails. Auto-seen may also have observed that pending entry. + const read = await sessionStore.sessionData.history.readTurn(userTurnId); + return ( + read.state === 'ready' && + read.turn.role === 'user' && + (read.turn.status === 'pending' || read.turn.status === 'seen') + ); + }); + // Pending promotion is repairable; a started, terminal, or removed turn is not. + if (!promoted) { + return false; + } + await requestSessionDispatch(sessionId, userTurnId, { + inputConfig, + machineId, + }); + log( + 'session steer promoted to ordinary dispatch for %s/%s after target turn ended', + sessionId, + userTurnId + ); + return false; + } + log( + 'session steer not applied for %s/%s: %s', + sessionId, + userTurnId, + response ? `${response.disposition}${response.error ? `: ${response.error}` : ''}` : 'timeout' + ); + return false; + }; + + return { + createSession, + startSession, + addSessionHistory, + requestSessionDispatch, + requestSessionSteer, + }; +} diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index b1d5b96de..b450507e3 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -762,16 +762,7 @@ describe('useSessionActions', () => { it('authors the pending user turn through the writer seam on send', async () => { const sessionId = 'session-append-turn-writer' as SessionId; - const appendSessionTurn = vi.fn(async () => 'direct' as const); - const runtime = createRuntime({ - writer: { - modeForMachine: () => 'direct' as const, - modeForSession: async () => 'direct' as const, - upsertDocMeta: vi.fn(async () => undefined), - appendSessionTurn, - appendSessionHistory: vi.fn(async () => undefined), - } as unknown as WorkspaceRuntime['writer'], - }); + const runtime = createRuntime({}); const actions = await renderActions(runtime); const entry = await actions.addSessionHistory(sessionId, { @@ -784,12 +775,11 @@ describe('useSessionActions', () => { finished: true, } as unknown as Parameters[1]); - expect(appendSessionTurn).toHaveBeenCalledTimes(1); - expect(appendSessionTurn).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ id: entry.id, role: 'user' }), - undefined + const stored = await runtime.withSessionStore(sessionId, (sessionStore) => + sessionStore.sessionData.history.readTurn(entry.id) ); + expect(stored).toMatchObject({ state: 'ready', turn: entry }); + expect(entry.items).toEqual([{ type: 'text', text: 'hi' }]); }); it('mints a fresh turn id when identical content is sent again (undelivered-turn resend)', async () => { @@ -842,16 +832,9 @@ describe('useSessionActions', () => { expect(resentEntry.inputConfig?.inputBlocks).toEqual(inputBlocks); }); - it('starts a session through one aggregate writer call', async () => { + it('preserves the initial history and activity through the extracted submission service', async () => { const sessionId = 'session-aggregate-start' as SessionId; - const startSession = vi.fn(async () => 'direct' as const); - const runtime = createRuntime({ - writer: { - modeForMachine: () => 'direct' as const, - modeForSession: async () => 'direct' as const, - startSession, - } as unknown as WorkspaceRuntime['writer'], - }); + const runtime = createRuntime({}); const actions = await renderActions(runtime); const result = await actions.startSession(createSessionPayload(sessionId), { @@ -868,21 +851,16 @@ describe('useSessionActions', () => { }, } as unknown as Parameters[1]); - expect(startSession).toHaveBeenCalledOnce(); - expect(startSession).toHaveBeenCalledWith( - sessionId, - // lastMessageAt rides the accept unit itself: the meta always carries - // the first message's activity, so a close racing the first turn can - // never mistake the session for an empty, deletable one. - expect.objectContaining({ - id: sessionId, - machineId: 'machine-1', - lastMessageAt: expect.any(Number), - }), - expect.objectContaining({ id: result.historyEntry.id, role: 'user' }), - expect.objectContaining({ userTurnId: result.historyEntry.id }) + const stored = await runtime.withSessionStore(sessionId, (sessionStore) => + sessionStore.sessionData.history.readTurn(result.historyEntry.id) ); - expect(runtime.withSessionStore).not.toHaveBeenCalled(); + expect(stored).toMatchObject({ state: 'ready', turn: result.historyEntry }); + expect(result.sessionMeta).toMatchObject({ + id: sessionId, + machineId: 'machine-1', + lastMessageAt: expect.any(Number), + }); + expect(result.historyEntry.inputConfig?.inputBlocks).toEqual([{ type: 'text', text: 'hi' }]); }); it('keeps a local branch selector out of baseBranch until the target machine resolves it', async () => { diff --git a/specs/local-attachment-references.md b/specs/local-attachment-references.md new file mode 100644 index 000000000..c455f3634 --- /dev/null +++ b/specs/local-attachment-references.md @@ -0,0 +1,64 @@ +# Direct local attachment references (separate follow-up PR) + +Status: draft +Translation: current + +[中文](local-attachment-references.zh.md) + +## Abstract + +A separate follow-up PR will let Electron send attachments to its local Daemon without uploading: original files retain their paths, while pathless content becomes managed local files, with neither automatic upload nor backfill. This needs a distinct reference protocol, trusted registration, Daemon resolution, preview, and version compatibility; originals are not immutable snapshots and do not widen Agent permissions. The current [attachment draft PR](session-files.md) changes preparation timing and pending submission while retaining existing transports, and does not depend on this proposal. This document preserves future design, not current implementation or acceptance requirements. + +## 1. Interface with the attachment draft PR + +Reuse the input snapshot, pending manager, and readiness boundary from the draft PR. Later replace eligible preparation strategies without building separate new-session and continuation send flows. + +| Scenario | Follow-up preparation | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Electron original file to verified local Daemon | Validate/register original path without full reads/hashing/copying; return local reference | +| Electron pathless File/Blob to verified local Daemon | Atomically save to a persistent local attachment directory after Send; return local reference | +| Web/mobile, or Electron to remote Daemon | Retain existing upload and verification | + +Same-machine routing requires trusted local runtime identity matching the target machine ID, not Electron detection, hostname, or project kind alone. Unknown identity waits. A known local target with missing capabilities, an offline Daemon, or failed preparation reports failure without upload fallback. Original references do not mechanically inherit upload size caps; generated content still has storage limits. Edited bytes are Blob sources, not original-path references. + +## 2. Local-reference contract + +### 2.1 Provenance, scope, and permissions + +Electron extracts the system path while the original File remains identifiable; pathless Files use the Blob branch. A trusted product window and local control entry register the file against the workspace, session, target machine, and authorization for this submission. Synchronized documents contain a verifiable reference and necessary display metadata; absolute paths stay in the local registry. + +Arbitrary synchronized `path`, `machineId`, or reference IDs cannot authorize a new file. Resolution requires a valid local registration and checks that the current input belongs to its usage scope. Copying IDs between sessions, remotely fabricating IDs, and identical paths on another machine grant no access. References must not become ambient authority for arbitrary later messages; the exact submission binding and fork authorization shape are implementation-stage decisions. + +ACP receives a correctly encoded `file://` `resource_link`, never only a textual path. A user-selected file outside the working directory can be registered, but this does not widen the Agent sandbox. If unreadable to the Agent, use existing permission handling or report unavailability; copying into an allowed directory or uploading must not bypass permission. + +### 2.2 Original paths are not byte snapshots + +Original content may change after Send; the Agent reads content at read time. Preparation and actual dispatch check for a readable regular file. Moved/deleted files, directories, devices, and pipes fail rather than silently using cached bytes. Lody never modifies or deletes the original. + +Path validation handles symlinks and parent-directory redirection: resolve the target during registration, detect redirection to an unauthorized target before use, and require reselection when it changes. Never follow a substituted link unconditionally. Ordinary content edits do not create snapshot semantics. Because ACP ultimately passes a path, bytes are not guaranteed stable between validation and the Agent's later open; the Agent sandbox remains its read boundary. This version promises neither immutable snapshots nor protection against every local filesystem race. + +### 2.3 Managed files, images, and history + +Generated content stays a draft until Send. Save it completely to a stable directory before returning a reference, preserving the original draft on failure. Accepted messages retain generated files for execution, retry, preview, and history. Object URLs and persistent files have separate lifetimes. Pending records, history, and authorized forks retain managed files. Cleanup removes only definitively unreferenced application-owned files; uncertainty retains them. + +Local images retain visual input semantics: use an adapter-supported local image input or form ACP image content from local bytes, retaining required resource links. A filename alone is not an image input. Unsupported adapters fail explicitly instead of silently degrading or uploading. Zero upload concerns Lody's attachment upload/backfill service, not existing message synchronization or the Agent's configured model input. + +Other devices show “Available only on that computer” and never try their own same-named path. Preview retains Electron's narrow file-resource capabilities; a durable reference ID is not a general file-reading URL. Local resend/fork must explicitly inherit authorized use. Cross-machine forks/migrations disclose unavailable content and resolve it before execution without implicit upload. Sharing/export cannot automatically collect this type as a cloud attachment; missing content must block the operation or be disclosed rather than claiming a complete attachment package. + +### 2.4 Existing protocol coexistence + +Add a distinct local-reference input representation across parsing, history normalization, rendering, queueing, dispatch, resend, and fork. Existing `transport: 'local'` retains its old pending-backfill meaning: neither globally stop its recovery nor silently migrate it. New original references and managed files must never enter the old backfill scan, including during restart recovery. + +Advertise a version through [Machine protocol negotiation](../packages/shared/AGENTS.md#machine-protocol-negotiation). Missing support disables new sends. Preserving opaque history in an old reader does not establish execution safety: an old Daemon must reject the entire execution request containing a new reference rather than run the remaining text. Combinations without that compatibility guarantee must not enable local-reference sending. + +## 3. Separate acceptance and open decisions + +The follow-up PR separately proves correct original-path URI encoding; no copies or traffic through actual attachment HTTP/backfill outlets; no fallback during failure/retry/restart scans; visual semantics and history retention for generated images; no authority expansion through forged/cross-session/cross-machine references; visible missing/redirected/unreadable/sandbox-denied files; whole-request rejection for unsupported execution; and consistent preview, resend, fork, sharing, and cleanup boundaries. + +That PR settles wire/IPC fields, submission binding, inherited fork authority, version capabilities and old-Daemon rejection, local image-adapter support, and file reclamation. These decisions do not block the draft PR, which must not claim permanently upload-free local attachments prematurely. + +## 4. Evidence and status + +Design based on inspection of `8c429a890037c5b21855ce7ef9f59e3677c25a38`; no implementation, separate model, or device acceptance. The split is recorded in the [decision note](../.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md). The existing blob/backfill path is described in [CLI attachment lifecycle](../.agents/docs/cli-lib-session-files.md). + +Source entry points: `packages/components/src/lib/electron-session-file-sender.ts`, `apps/electron/src/main/ipc/services/local-projects-ipc.ts`, `apps/electron/src/preload/index.ts`, `apps/cli/src/lib/{message-handler,session-file-backfill,session-file-blob-store}.ts`, and `packages/shared/src/{message-schemas,session-input}.ts`. Original File path API: [Electron webUtils](https://www.electronjs.org/docs/latest/api/web-utils). diff --git a/specs/local-attachment-references.zh.md b/specs/local-attachment-references.zh.md new file mode 100644 index 000000000..416411661 --- /dev/null +++ b/specs/local-attachment-references.zh.md @@ -0,0 +1,64 @@ +# 本机附件直接引用(独立后续 PR) + +Status: draft +Translation: current + +[English](local-attachment-references.md) + +## 摘要 + +本提案在独立后续 PR 中实现 Electron 向同机 Daemon 发送附件时跳过上传:原文件直接引用原路径,无路径内容保存为本机文件,两者不自动上传或后台补传。需要独立本地引用协议、可信登记、Daemon 解析、预览和版本兼容;原文件不是不可变快照,也不扩大 Agent 文件权限。当前[附件 draft PR](session-files.zh.md)只改变准备时机和待发送流程,沿用现有传输,不依赖本提案;这里保留后续设计,不作为当前 PR 的实现或验收要求。 + +## 1. 与附件 draft PR 的接口 + +沿用 draft PR 的输入快照、待发送管理器及 ready 边界。后续仅替换符合条件的附件准备策略,不另建新对话/继续对话的发送流程。 + +| 场景 | 后续 PR 的准备方式 | +| ------------------------------------------- | ---------------------------------------------------- | +| Electron 原文件 → 可信同机 Daemon | 校验、登记原路径,不全量读取/hash/复制;返回本地引用 | +| Electron 无路径 File/Blob → 可信同机 Daemon | 点击发送后原子保存到本机持久附件目录,返回本地引用 | +| Web/移动端,或 Electron → 远程 Daemon | 继续现有上传与校验 | + +同机须由可信本地 runtime 身份匹配目标 machine ID,不能仅凭 Electron 环境、主机名或 project kind。身份未确定时等待;已知同机但能力缺失、Daemon 离线或准备失败时明确报错,不回退上传。原路径引用不机械继承网络上传大小上限,生成内容仍受本机存储限额约束。发送前替换了字节的编辑结果是 Blob,不能再冒充原路径。 + +## 2. 本机引用契约 + +### 2.1 来源、范围与权限 + +Electron 在原始 File 仍可识别时取得系统路径;无路径 File 进入 Blob 分支。通过可信的产品窗口及本机控制入口登记文件,绑定工作区、会话、目标机器和该次发送授权;同步文档只携带可验证的引用及必要展示信息,真实绝对路径保存在本机登记中。 + +Daemon 不能把同步消息中的任意 `path`、`machineId` 或引用 ID 当成新文件授权。解析必须查到有效本机登记,且验证当前输入与登记的使用范围。跨会话复制 ID、远程构造相同 ID、另一台机器的同名路径均不得获得读取权。引用也不能变成任意后续消息都能使用的环境权限;具体提交绑定及 fork 授权形态在协议实施阶段确定。 + +ACP 最终仍收到正确编码的 `file://` `resource_link`,不能退化为纯文本路径。用户选中的工作目录外文件可以登记,但不自动扩大 Agent 沙箱。Agent 无法读取时使用现有权限流程或明确报告不可用,不复制进允许目录或上传来绕过权限。 + +### 2.2 原路径不是字节快照 + +原文件内容可以在发送后改变,Agent 读取的是读取时的内容。准备和实际 dispatch 前检查可读普通文件;文件移动、删除或变成目录/设备/管道时失败,不静默使用旧缓存。Lody 永不修改或删除用户原文件。 + +路径校验须处理符号链接及父目录重定向:登记时解析目标,使用前检测是否转向未授权目标,变化后要求重新选择;不得无条件跟随替换链接。普通内容编辑不等于附件快照。最终 ACP 只传路径,因此不能保证校验与 Agent 稍后打开之间的字节不变;Agent 的沙箱仍是其读取边界。本版不承诺不可变快照或抵抗所有本地文件竞争。 + +### 2.3 生成文件、图片与历史 + +生成内容发送前只作为草稿;发送后保存到稳定目录,写完才返回引用,失败时保留原草稿。消息接受后仍需保留生成文件,供 Agent 执行、重试、预览和历史使用。临时 object URL 与持久附件文件分别管理;有待发送记录、历史或合法 fork 引用的生成文件不得回收。清理只处理明确无引用的应用自有文件,无法确认时保留。 + +本机图片保持视觉输入语义:根据目标适配器能力提供本地图片输入或从本机读取形成 ACP image,并保留需要的 resource link;不能仅发送图片文件名。适配器不支持时明确拒绝,不静默退化或上传。这里的“零上传”指 Lody 的附件上传/补传服务,不包括用户消息已有的同步,或 Agent 按其既有模型配置发送输入。 + +其他设备显示“仅在该电脑可用”,不尝试打开其本机同名路径。预览沿用 Electron 的窄文件资源能力,不把持久引用 ID 直接变成通用文件读取 URL。同机重发/fork 必须显式继承合法使用关系;跨机器 fork 或迁移需披露不可用并在实际执行前解决,不能隐式上传。分享/导出不得自动将此类型收集为云端附件;无法包含时须阻止或明确标注缺失,不能声称分享包含完整附件。 + +### 2.4 与旧协议并存 + +新增独立的本地引用输入表示,贯通解析、历史归一化、渲染、队列、dispatch、重发与 fork。旧 `transport: 'local'` 仍表示旧的待补传 blob,不能全局停止其恢复,也不能自动迁移成新语义。新原路径引用与新生成文件都不得进入旧补传扫描,恢复时也不例外。 + +按[Machine 协议协商](../packages/shared/AGENTS.md#machine-protocol-negotiation)声明版本能力;能力缺失就禁用新发送。旧 reader 保留未知内容的能力不等于旧 Daemon 可安全执行:必须保证旧 Daemon 拒绝包含新引用的整条执行请求,不能仅执行剩余文字。无法建立这个兼容条件的组合不开放本地引用发送。 + +## 3. 独立验收与待定项 + +后续 PR 单独证明:原路径 URI 的编码正确;无副本且实际附件 HTTP/补传出口为零;失败、重试和重启扫描不回退上传;生成图片保留视觉语义与历史文件;伪造引用/跨会话或跨机器复用不扩权;路径删除、重定向、沙箱拒绝可见;旧客户端/Daemon 安全拒绝不支持的整条执行;预览、重发、fork、分享与清理遵守本机引用边界。 + +需在该 PR 锁定引用 wire/IPC 字段、提交绑定、fork 授权继承、版本能力与旧 Daemon 拒绝策略、本机图片适配器支持和文件回收规则。这些决定不阻塞 draft PR;当前 draft PR 也不能提前宣称同机附件永久不上传。 + +## 4. 证据与状态 + +设计基于 `8c429a890037c5b21855ce7ef9f59e3677c25a38` 的源码检查;未实施、未做独立模型或真机验收。拆分决定见[决策记录](../.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md)。当前 blob/补传链路见[CLI 附件说明](../.agents/docs/cli-lib-session-files.md)。 + +源码入口:`packages/components/src/lib/electron-session-file-sender.ts`、`apps/electron/src/main/ipc/services/local-projects-ipc.ts`、`apps/electron/src/preload/index.ts`、`apps/cli/src/lib/{message-handler,session-file-backfill,session-file-blob-store}.ts`、`packages/shared/src/{message-schemas,session-input}.ts`。原文件路径 API 参见 [Electron webUtils](https://www.electronjs.org/docs/latest/api/web-utils)。 diff --git a/specs/models/session-files.effect-probe.mjs b/specs/models/session-files.effect-probe.mjs new file mode 100644 index 000000000..9eecb09b7 --- /dev/null +++ b/specs/models/session-files.effect-probe.mjs @@ -0,0 +1,249 @@ +// Design probes for Effect 3.18.4, not product integration tests. +// Run in a temporary effect@3.18.4 installation, retaining this script's and +// store-ref-tracker.ts's repository-relative paths. See the owning Agent Note. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Cause, Effect, Exit, Fiber, Ref, Scope } from 'effect'; +import { createManagedStoreCache } from '../../packages/components/src/providers/store-ref-tracker.ts'; + +const gate = () => Promise.withResolvers(); +const interrupted = (exit) => Exit.isFailure(exit) && Cause.isInterrupted(exit.cause); + +void test('interrupting a Promise wrapper leaves non-cooperative writes running', async () => { + const started = gate(); + const release = gate(); + const settled = gate(); + const state = { journal: 'preparing', history: [], continued: false }; + const rawWrite = async () => { + started.resolve(); + await release.promise; + state.history.push('fixed-turn-id'); + settled.resolve(); + }; + const fiber = Effect.runFork( + Effect.gen(function* () { + state.journal = 'submitting'; + yield* Effect.tryPromise(rawWrite); + state.continued = true; + }).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + state.journal = 'uncertain'; + }) + ) + ) + ); + await started.promise; + assert.equal(interrupted(await Effect.runPromise(Fiber.interrupt(fiber))), true); + assert.deepEqual(state, { journal: 'uncertain', history: [], continued: false }); + release.resolve(); + await settled.promise; + assert.deepEqual(state, { + journal: 'uncertain', + history: ['fixed-turn-id'], + continued: false, + }); +}); + +void test('an abort-aware boundary stops the controlled transfer itself', async () => { + const started = gate(); + const active = new Set(); + let completeTransfer; + let acceptedBytes = false; + const transfer = (signal) => + new Promise((resolve, reject) => { + const ticket = {}; + active.add(ticket); + const abort = () => { + active.delete(ticket); + signal.removeEventListener('abort', abort); + reject(new Error('aborted')); + }; + completeTransfer = () => { + if (!active.delete(ticket)) return; + signal.removeEventListener('abort', abort); + acceptedBytes = true; + resolve('ready'); + }; + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + started.resolve(); + }); + const fiber = Effect.runFork( + Effect.tryPromise({ + try: (signal) => transfer(signal), + catch: (error) => error, + }) + ); + await started.promise; + assert.equal(active.size, 1); + assert.equal(interrupted(await Effect.runPromise(Fiber.interrupt(fiber))), true); + completeTransfer(); + assert.equal(active.size, 0); + assert.equal(acceptedBytes, false); +}); + +void test('workspace-owned work outlives submit and closes before its dependencies', async () => { + const started = gate(); + const releaseWork = gate(); + const prepared = gate(); + const cleanupStarted = gate(); + const releaseCleanup = gate(); + const resources = new Set(); + const events = []; + const owner = await Effect.runPromise(Scope.make()); + const work = Effect.scoped( + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => { + resources.add('attachment'); + started.resolve(); + }), + () => + Effect.promise(async () => { + events.push('cleanup-started'); + cleanupStarted.resolve(); + await releaseCleanup.promise; + resources.delete('attachment'); + events.push('cleanup-finished'); + }) + ); + yield* Effect.promise(() => releaseWork.promise); + events.push('prepared-after-submit-returned'); + prepared.resolve(); + yield* Effect.never; + }) + ); + // The runPromise entry point has returned, while work belongs to owner. + const fiber = await Effect.runPromise(Effect.forkIn(work, owner)); + await started.promise; + releaseWork.resolve(); + await prepared.promise; + // Ask the owner to close. Its async finalizer explicitly blocks this boundary. + const closing = Effect.runPromise(Scope.close(owner, Exit.void)).then(() => { + assert.equal(resources.size, 0); + events.push('repo-destroyed'); + }); + await cleanupStarted.promise; + assert.equal(resources.has('attachment'), true); + assert.equal(events.includes('repo-destroyed'), false); + releaseCleanup.resolve(); + await closing; + assert.equal(interrupted(await Effect.runPromise(Fiber.await(fiber))), true); + assert.deepEqual(events.slice(-3), ['cleanup-started', 'cleanup-finished', 'repo-destroyed']); +}); + +void test('atomic phase and generation checks reject late ready and cancel events', async () => { + // Exhaust both event orders; no scheduler timing determines the winner. + for (const order of [ + ['cancel', 'submit'], + ['submit', 'cancel'], + ]) { + const result = await Effect.runPromise( + Effect.gen(function* () { + const state = yield* Ref.make({ phase: 'ready', generation: 0 }); + const winners = []; + for (const event of order) { + const won = yield* Ref.modify(state, (old) => + old.phase === 'ready' + ? [true, { ...old, phase: event === 'cancel' ? 'canceled' : 'submitting' }] + : [false, old] + ); + if (won) winners.push(event); + } + const settled = yield* Ref.get(state); + // A stale completion cannot cross the phase boundary. + yield* Ref.update(state, (old) => + old.phase === 'preparing' && old.generation === 0 ? { ...old, phase: 'ready' } : old + ); + assert.deepEqual(yield* Ref.get(state), settled); + // Nor can attempt 0 complete a retried attempt 1. + yield* Ref.set(state, { phase: 'preparing', generation: 1 }); + yield* Ref.update(state, (old) => + old.phase === 'preparing' && old.generation === 0 ? { ...old, phase: 'ready' } : old + ); + assert.deepEqual(yield* Ref.get(state), { phase: 'preparing', generation: 1 }); + return winners; + }) + ); + assert.deepEqual(result, [order[0]]); + } +}); + +void test('interrupting a late store acquisition still releases its borrowed reference', async () => { + const acquiring = gate(); + const created = gate(); + const events = []; + const cache = createManagedStoreCache({ + create: () => { + acquiring.resolve(); + return created.promise; + }, + // No elapsed time is used: releaseIfIdle triggers disposal explicitly. + releaseDelayMs: 60_000, + unload: async () => { + events.push('unloaded'); + }, + }); + try { + const work = Effect.scoped( + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.tryPromise(() => cache.acquire('session')), + () => Effect.sync(() => cache.releaseRef('session')) + ); + yield* Effect.never; + }) + ); + const fiber = Effect.runFork(work); + await acquiring.promise; + // acquireRelease masks acquisition until release is registered. This test + // deliberately supplies a finite, explicitly controlled acquisition. + await Effect.runPromise(Fiber.interruptFork(fiber)); + created.resolve({ dispose: () => events.push('disposed') }); + assert.equal(interrupted(await Effect.runPromise(Fiber.await(fiber))), true); + await cache.releaseIfIdle('session'); + assert.deepEqual(events, ['disposed', 'unloaded']); + } finally { + await cache.disposeAll(); + } +}); + +void test("a send finalizer releases its own lease without disposing the UI's store", async () => { + const started = gate(); + const events = []; + const sharedStore = { value: 'visible history', dispose: () => events.push('disposed') }; + const cache = createManagedStoreCache({ + create: async () => sharedStore, + releaseDelayMs: 60_000, + unload: async () => { + events.push('unloaded'); + }, + }); + try { + const uiStore = await cache.acquire('session'); + const fiber = Effect.runFork( + Effect.scoped( + Effect.gen(function* () { + const sendStore = yield* Effect.acquireRelease( + Effect.tryPromise(() => cache.acquire('session')), + () => Effect.sync(() => cache.releaseRef('session')) + ); + assert.equal(sendStore, uiStore); + started.resolve(); + yield* Effect.never; + }) + ) + ); + await started.promise; + await Effect.runPromise(Fiber.interrupt(fiber)); + await cache.releaseIfIdle('session'); + assert.deepEqual(events, []); + assert.equal(uiStore.value, 'visible history'); + cache.releaseRef('session'); + await cache.releaseIfIdle('session'); + assert.deepEqual(events, ['disposed', 'unloaded']); + } finally { + await cache.disposeAll(); + } +}); diff --git a/specs/models/session-files.model.ts b/specs/models/session-files.model.ts new file mode 100644 index 000000000..65a5ae7d1 --- /dev/null +++ b/specs/models/session-files.model.ts @@ -0,0 +1,163 @@ +// Design model, not a product implementation test. Run with Node 22.14+: +// node --experimental-strip-types specs/models/session-files.model.ts +// Domain: two same-session submissions, at most one retry each, serialized events. +// Excludes persistence implementation, distributed writers, auth, filesystem and UI. +// Both entry points use this same draft-to-pending contract. + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +type DraftAction = 'add' | 'remove' | 'replace' | 'navigate' | 'send'; +type DraftDecision = 'keep-draft' | 'invalid' | 'saving' | 'owned'; +function takeover(action: DraftAction, valid: boolean, saved: boolean): DraftDecision { + if (action !== 'send') return 'keep-draft'; + if (!valid) return 'invalid'; + return saved ? 'owned' : 'saving'; +} + +let draftCases = 0; +for (const entry of ['new', 'continuation'] as const) { + for (const action of ['add', 'remove', 'replace', 'navigate', 'send'] as const) { + for (const valid of [false, true]) { + for (const saved of [false, true]) { + const decision = takeover(action, valid, saved); + const canStartTransfer = decision === 'owned'; + const canClearDraft = decision === 'owned'; + assert(!canStartTransfer || action === 'send', `${entry} A01: no transfer before Send`); + assert(!canClearDraft || (valid && saved), `${entry} A17: preserve unowned draft`); + if (action !== 'send') assert(decision === 'keep-draft', `${entry} A10: draft-only action`); + if (action === 'send' && valid && saved) + assert(canStartTransfer, `${entry} A09: send works`); + draftCases++; + } + } + } +} + +const phases = ['pending', 'uploading', 'verifying', 'ready', 'failed'] as const; +let readinessCases = 0; +for (const first of phases) { + for (const second of phases) { + const ready = [first, second].every((phase) => phase === 'ready'); + assert(!ready || (first === 'ready' && second === 'ready'), 'A03: all attachments required'); + // Counterexample to retaining the old failed-file filtering rule. + if (first === 'ready' && second === 'failed') { + const oldPhases: string[] = [first, second]; + const oldAllowsSend = !oldPhases.some((phase) => phase === 'uploading'); + assert(oldAllowsSend && !ready, 'Old filtering must differ on partial failure'); + } + readinessCases++; + } +} + +type Phase = + | 'preparing' + | 'ready' + | 'failed' + | 'submitting' + | 'uncertain' + | 'handed-off' + | 'canceled'; +type Slot = { phase: Phase; generation: 0 | 1; accepted: boolean }; +type State = [Slot, Slot]; +type ModelEvent = + | 'prepared' + | 'failed' + | 'retry' + | 'cancel' + | 'submit' + | 'ack' + | 'timeout' + | 'persist'; +const events: ModelEvent[] = [ + 'prepared', + 'failed', + 'retry', + 'cancel', + 'submit', + 'ack', + 'timeout', + 'persist', +]; +const finished = (slot: Slot) => slot.phase === 'handed-off' || slot.phase === 'canceled'; + +function transition(state: State, index: 0 | 1, event: ModelEvent, generation: 0 | 1): State { + const next: State = [{ ...state[0] }, { ...state[1] }]; + const slot = next[index]; + if (finished(slot)) return next; + if (event === 'prepared' || event === 'failed') { + if (slot.phase === 'preparing' && slot.generation === generation) { + slot.phase = event === 'prepared' ? 'ready' : 'failed'; + } + } else if (event === 'retry' && slot.phase === 'failed' && slot.generation === 0) { + slot.generation = 1; + slot.phase = 'preparing'; + } else if (event === 'cancel' && ['preparing', 'ready', 'failed'].includes(slot.phase)) { + slot.phase = 'canceled'; + } else if (event === 'submit' && slot.phase === 'ready' && (index === 0 || finished(next[0]))) { + slot.phase = 'submitting'; + } else if (event === 'ack' && ['submitting', 'uncertain'].includes(slot.phase)) { + slot.accepted = true; + } else if (event === 'timeout' && slot.phase === 'submitting') { + slot.phase = 'uncertain'; + } else if (event === 'persist' && slot.accepted) { + // Abstract evidence that input AND dispatch/queue handoff are durable. + slot.phase = 'handed-off'; + } + return next; +} + +const initial: State = [ + { phase: 'preparing', generation: 0, accepted: false }, + { phase: 'preparing', generation: 0, accepted: false }, +]; +const frontier: State[] = [initial]; +const seen = new Set([JSON.stringify(initial)]); +let checkedTransitions = 0; +for (let cursor = 0; cursor < frontier.length; cursor++) { + const before = frontier[cursor]!; + for (const index of [0, 1] as const) { + for (const event of events) { + for (const generation of [0, 1] as const) { + const after = transition(before, index, event, generation); + const old = before[index]; + const current = after[index]; + if (finished(old)) assert(JSON.stringify(current) === JSON.stringify(old), 'A04: terminal'); + if (current.phase === 'submitting' && old.phase !== 'submitting') { + assert(old.phase === 'ready', 'A03: preparation before submit'); + assert(index === 0 || finished(before[0]), 'A05: no overtaking'); + } + if (event === 'ack' && old.phase !== 'handed-off') { + assert(current.phase !== 'handed-off', 'A14: ACK is not durability'); + } + if (old.phase === 'submitting' || old.phase === 'uncertain') { + assert(current.phase !== 'canceled', 'Cancellation cannot recall submitted work'); + } + if ((event === 'prepared' || event === 'failed') && generation !== old.generation) { + assert(JSON.stringify(old) === JSON.stringify(current), 'A04: obsolete callback'); + } + if (current.phase === 'handed-off' && old.phase !== 'handed-off') { + assert(event === 'persist' && old.accepted, 'A14: require safe handoff evidence'); + } + const key = JSON.stringify(after); + if (!seen.has(key)) { + seen.add(key); + frontier.push(after); + } + checkedTransitions++; + } + } + } +} +assert( + frontier.some((state) => state.every((slot) => slot.phase === 'handed-off')), + 'Both can complete' +); +assert( + frontier.some((state) => state[0].phase === 'canceled' && state[1].phase === 'handed-off'), + 'Cancel unblocks' +); +console.log( + JSON.stringify({ draftCases, readinessCases, reachableStates: seen.size, checkedTransitions }) +); diff --git a/specs/session-files.md b/specs/session-files.md new file mode 100644 index 000000000..4805158d6 --- /dev/null +++ b/specs/session-files.md @@ -0,0 +1,364 @@ +# Attachment drafts and pending messages + +Status: draft +Translation: current + +[中文](session-files.zh.md) + +## Abstract + +- **Use attachment drafts in both new conversations and continuations.** Picking, dropping, or pasting images/files performs preliminary validation and local preview only; attachments remain removable/replaceable. Existing transfer starts on Send. +- **An independent manager takes over the complete input.** After successful saving, release the composer and show pending messages/progress while allowing conversation navigation. Completion updates only the original target without navigating back or clearing newer drafts. +- **Submit only when every attachment is ready.** One failure preserves the whole message for retry/cancellation. Same-session submissions retain takeover order, including later text-only messages; different sessions progress independently. +- **Share the flow and accept both entry points separately.** New conversations retain the reserved session ID and a reachable pending view. Continuations freeze this turn's configuration and use existing direct/queue/guide handling without changing the preceding active turn. +- **Retry, exit, and recovery preserve content without duplicate submission.** Keep turn identity fixed; distinguish local acceptance, persistence, and Daemon receipt. Reconcile uncertain results. Close/reload protection covers unfinished tasks; mobile recovery uses confirmed retries, without promising transfer after application exit. +- **Adopt Effect in stages for tasks and resources.** Extract submission boundaries, take over resources, complete submission/delivery ownership, then deliver drafts for both entry points together. The first three stages keep transfer on addition; components retain ordinary interfaces and Effect stays inside the workspace service. Persistence, cross-window exclusion, and submission reconciliation still need explicit implementation. +- **This PR owns the draft lifecycle only.** Reuse existing upload, local handoff, fallback, and backfill semantics. Original-path references, permanent zero upload, and their protocol/Daemon work belong to a [separate follow-up PR](local-attachment-references.md), not a dependency. Text-to-file conversion and an image editor are also excluded. This remains an unimplemented draft for review. + +## 1. Scenario and PR scope + +On either new-conversation landing or an existing conversation, the user adds attachments and reviews/edits the draft before sending. Send creates a local pending message and lets the user work elsewhere. Actual submission follows confirmed completion of all attachments; returning exposes progress, failures, and retry. + +Leaving a conversation means navigation inside its hosting page. Leaving the page includes close, reload, external navigation, or runtime destruction. The former continues work; the latter warns. Attachment services may receive files first, but cannot cause early Agent execution of this message. Existing tasks and non-executing warmup remain separate. + +This PR covers new conversations, existing-session continuations including children, images, ordinary files, and attachment-only messages. Desktop/mobile layouts share the lifecycle; native mobile-shell recovery needs acceptance in its owning repository. Public desktop retains the [platform boundary](../packages/platform/AGENTS.md); unifying draft handling cannot add cloud capabilities. + +| Current PR | Separate follow-up PR | +| -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Draft validation/preview, takeover, delayed existing transfers, progress, cancel/retry, submission/recovery, acceptance of both entry points | Original local paths, permanently local generated attachments, new reference protocol/authorization/capabilities, Daemon resolution, removal of automatic upload/backfill | + +The PRs can be reviewed and delivered independently. This PR changes no attachment wire types, existing path-copy/materialization behavior, upload service APIs, fallback, or backfill policy; client transfer helpers may add cancellation options. Electron lifecycle changes are still needed for draft exit protection. The later PR plugs into the same preparation boundary without rebuilding drafts or orchestration. OS background transfer, cross-device unsent draft sync, automatic text-file conversion, a new image editor, and new Agent-output policies are excluded. Existing folded text still expands on Send. + +To reduce adoption risk, section 11.6 proposes three prerequisite refactoring PRs merged in sequence, followed by the complete draft feature in a fourth PR. “This PR” elsewhere in this document means that draft feature PR; its acceptance is not split between creation and continuation. Upload-free local communication remains separate follow-up work. + +## 2. Inspected implementation and gaps + +These observations concern this checkout, not deployed acceptance. Evidence paths are at the end. + +| Current behavior | Change in this PR | +| --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| New/existing sessions transfer images/files on addition, orchestrated by components/hooks | Validate/preview on addition; a shared manager calls transfer after Send | +| Unsuccessful images block sending, but failed ordinary files are filtered out | Require every attachment; never silently reduce the set | +| Landing and existing conversations maintain separate attachment/submission state | Share draft/takeover contracts; only final creation versus continuation differs | +| Same-machine files use temporary files/CLI blob storage; legacy local data may backfill and some paths may fall back | Delay existing calls until Send without redefining transport or claiming permanent zero upload | +| Send helpers generate IDs per invocation; append does not deduplicate IDs; initial meta/history writes are concurrent | Freeze IDs, reconcile through the existing writer, and recover partial success | +| Writer acceptance is a local CRDT write; workspace navigation disposes runtime | Define persistent handoff, recovery records, and exit protection | +| Electron `before-quit` destroys relays and stops CLI first | Check/confirm pending work before cleanup | + +The renderer currently authors messages through WorkspaceWriter / SessionData / HistoryWriter. Older comments must not restore CLI proxy-authoring. Retain the [single history writer](session-history-writes.md). + +## 3. Responsibilities and ownership + +```mermaid +flowchart LR + N[New conversation attachment draft] --> P[Shared pending manager] + C[Continuation attachment draft] --> P + P <--> J[Local recovery records] + P -->|After Send| U[Existing upload or local handoff] + U -->|All attachments ready| W[Existing writer and reliable send path] + W --> D[Target Daemon / Agent] + P --> V[Conversation, list, exit protection] +``` + +| Responsibility | Owner | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| Text, mention spans, references, attachment order, pre-send editing | Composer draft; components do not own background tasks | +| Takeover, transfer, cancellation, retry, order, reconciliation, recovery | Manager in shared components, independent of conversation mounting | +| Creation parameters/reserved session ID or existing-session target | Snapshot builders at both entry points, passed to the common manager | +| Upload or existing local handoff | Existing platform transport capabilities returning existing SessionInputBlock values | +| History/queue mutation and dispatch | Existing WorkspaceWriter / SessionData / HistoryWriter and send mechanisms | +| Local pending storage and window/application exit | Platform storage and Electron main/renderer lifecycle | + +The manager sits above landing, conversation pages, and mobile panels and lives until its host page exits. Workspace switching does not introduce multiple long-lived runtimes; resolve pending work before disposing the old runtime. + +Recovery records are isolated by platform/account/workspace/session and hold fixed identity, creation parameters if applicable, input snapshot, sources/results, intent, and stage. They do not belong to the shared Session Doc. Progress, File/Blob objects, object URLs, tokens, and recovery locks stay out of synchronized messages. Real messages retain existing attachment representations. + +Within one browser storage domain or Electron data directory, one executor owns each record. Local exclusion coordinates claims, ordering, and eligibility; another window cannot duplicate execution, and a former owner cannot submit. Different devices/storage domains retain existing collaboration behavior without global click-time ordering. + +## 4. Attachment draft lifecycle + +### 4.1 Addition, editing, and leaving the composer + +Pick/drop/paste only checks type, count, empty files, and existing size limits, retaining File/Blob sources and bounded local previews. Show “Pending upload” or “Pending preparation” for existing local paths. Do not upload, hash the full file, invoke local handoff, or use executable pending history/queue rows as draft storage. + +Users may remove attachments or replace draft sources before Send. Invalid attachments must not remain as silently omitted inputs. Revoke unused object URLs on removal/replacement while preserving data owned by another draft or accepted pending task. Navigating away from a conversation or landing and returning preserves attachments, text, references, and ordering within the same scope; restoring names without usable File/Blob data is insufficient. + +Changing target/configuration updates the draft and necessary validation without triggering upload. Removing all attachments before Send creates no transfer. Existing folded-text expansion stays intact; no new text-file conversion or image-editing UI is added. + +### 4.2 Reuse existing transfer after Send + +After Send and successful takeover, retain existing platform/target routing to upload or local handoff. Use current results and SessionInputBlock types, without new local-path references. Legacy `transport: local` readiness means the target can use the attachment through its existing path; it does not promise permanent zero upload and does not require waiting for background backfill in this PR. + +Cloud readiness requires a valid final response; 100% byte transfer while server verification remains pending is not ready. Existing local handoff uses its current success response. Existing fallback remains capability-gated, with no expanded authority from the draft refactor. UI must not represent local preparation using fictional network-upload percentages. + +Only explicit successful attachment results become ready. Failure/cancellation cannot become success by filtering attachments. Reuse confirmed results within the same task and retry only failed/expired ones; submission failure does not retransmit everything. Image transfers also support cancellation, and obsolete completions cannot trigger submission. + +## 5. Complete new-conversation and continuation flows + +| Stage | New conversation | Existing-conversation continuation | +| ------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Add attachments | Belongs to the landing draft with text, references, and reserved session ID | Belongs to the current workspace/session; navigation cannot mix drafts | +| Send | Freeze creation/input/configuration and reuse reserved ID; clear only after takeover | Freeze this turn's input/configuration/target; clear only after takeover | +| Transfer | Openable pending view/list entry independent of real meta; user may begin another new conversation | Pending row alongside existing Agent activity; user may navigate or draft the next message | +| All ready | Create session and first message using the same ID, handing off once | Use the same turn ID through existing direct/queue/guide, handing off once | +| Failure/retry | Retain creation parameters, text, and all attachments; no replacement session ID | Retain original target/input; do not reread another currently viewed conversation | +| Success | Merge placeholder with real session/first message without taking navigation | Merge placeholder with history/queue without clearing a newer draft | +| Cancel before submission | Remove this task without creating an empty session; preserve any newer landing draft | Cancel only this unsubmitted task, not the Agent or existing queue entries | + +Both entry points call the same takeover/preparation service, not separate upload/retry/cancel state machines. Final creation and continuation adapters differ, while snapshots, readiness, and failure semantics are identical. A busy Agent cannot cause an executable queue row before attachments are ready. Transfer or cancel new-session warmup leases instead of relying on an unmounted hook. + +## 6. Takeover, states, and ordering + +### 6.1 Send boundary + +After synchronously excluding double submission, freeze text, mention spans, code/visual references, attachment order, creation parameters, target, Role/revision, model/mode/permissions, MCP selection (including an explicit empty array), and submission intent. The attachment snapshot retains the File/Blob sources, order, and existing prepared results at Send. Later Role/configuration edits cannot replace this snapshot. Revocation, machine removal, and session archiving are rechecked before submission. + +Takeover succeeds only when the manager holds a recoverable record and required source data. Then clear this draft and release the composer. Preserve existing click-time mobile keyboard dismissal without automatically refocusing after save failure. While saving, show “Saving pending content”; preserve input on failure. File/Blob sources and pending content on every platform follow section 9, without depending on future local-reference registration. Old asynchronous callbacks cannot clear a newer draft. + +### 6.2 Minimal message states + +| State | Meaning and actions | +| ------------------------------ | ------------------------------------------------------------------------------------------------------- | +| Pending/preparing | Hash, existing upload/local handoff, or server verification; cancelable | +| Waiting for previous message | Preparation may proceed, but an earlier message is neither handed off nor canceled; cancelable | +| Preparation failed/interrupted | Not submitted; keep the whole message for failed-attachment retry or cancellation | +| Submitting | Entering writer/queue handoff; no promise of cancellation | +| Reconciling send result | Write, persistence, or handoff is uncertain; reconcile without blind resubmission | +| Handed off | Existing local send system has taken persistent responsibility; it owns later delivery/execution status | +| Canceled | Reachable only before submission; late preparation completion cannot revive the message | + +Message phase and resource lifetime are separate; handed-off messages may still have persistent delivery work. Each attachment has readiness or a specific preparation phase. Byte progress and server verification are separate; 100% transfer without confirmation is not ready. Local handoff does not invent upload percentages. Cancellation covers every preparation phase: cooperative I/O actually stops; noncancelable operations lose submission eligibility and clean up as section 11.4 specifies. Attempt generations ignore obsolete results. + +Within one client storage domain, each session enters the send system in takeover order, including later text-only messages. A failed/uncertain A holds B until A is canceled or confirmed handed off. Different sessions can progress concurrently with bounded global transfers. Tune concurrency through load validation rather than making it a protocol constant. + +### 6.3 Direct, queue, and guide + +The local pending list is separate from the Daemon message queue. Before attachments are ready, neither executable history nor queue rows may serve as placeholders. Uploads do not hold the direct-dispatch lock. + +Freeze user intent and configuration, while checking Agent activity again at actual submission. Ordinary sends safely enter existing direct/queue handling; an Agent becoming busy during upload must not cause interruption of its new task. Explicit queue intent remains queued. + +Guide fixes its target assistant turn. If that turn ends before preparation finishes and no steer was attempted, visibly convert to a normal follow-up queue entry, never guide a different turn. After a steer attempt, authoritative no-active-turn may reuse the same ID for follow-up where the existing protocol proves non-submission; other failed/unknown acknowledgments require reconciliation and do not establish non-application. Applied steer follows the [existing history contract](session-history-writes.md) and must not execute again as an ordinary message. + +## 7. Submission identity, persistence, and first sessions + +Takeover assigns stable submission/turn identity, preserving the existing `userTurnId` relationship between queue and history. Queue-row identity also remains stable. Do not generate new message IDs per retry. Check existing identity and matching input inside the existing writer's local serialized boundary; mismatching content is a conflict, not permission to overwrite. + +A fixed ID alone cannot prevent duplicate LoroList append. Implementation must validate cross-window exclusion, writer reconciliation, queue promotion, and reconnect. Sending twice with the same ID is not itself deduplication. The guarantee is that one local submission does not manufacture duplicate messages, not a new distributed exactly-once Agent execution mechanism. + +Distinguish three evidence levels: + +1. **Writer acceptance:** local CRDT mutation, without proof of disk persistence or Daemon receipt. +2. **Safe handoff:** input, initial metadata if needed, and subsequent dispatch/queue information are held by the existing persistent send path and can advance after recovery. Only then release unnecessary draft content and this submission's exit guard; outstanding delivery obligations remain in recoverable records owned by workspace tasks. +3. **Target receipt/execution:** show only with existing Daemon/Agent evidence; a connected synchronization channel does not establish receipt. + +Errors distinguish definitely unwritten from uncertain. Definitely unwritten submissions may retry with the same ID and prepared attachments. Unknown results first reconcile history, queue, and handoff state. Absence in an offline replica does not prove no write occurred. An acceptance acknowledgment without proven safe handoff retains recovery state and attachment references. + +New sessions retain the landing's reserved session ID and creation parameters, including project/branch and Agent. During preparation, use an openable local placeholder and list entry without requiring real session metadata. Create the actual session after all attachments are ready. Recover meta-only and history-only partial success against the same session, never a replacement ID. Explicitly transfer or cancel the landing's ACP preparation lease rather than relying on an unmounted hook; canceling warmup and starting normally is acceptable. + +When actual history or queue content becomes visible, replace the placeholder by fixed turn identity without disappearance or duplication. Repairing placeholder metadata must not overwrite subsequent real-session edits from another window. + +## 8. Presentation and exit protection + +| Surface/stage | Presentation and behavior | +| -------------------------------- | ------------------------------------------------------------------------------------------------ | +| Composer | Pending preparation / Pending upload; remove or replace before Send | +| Local preparation | “Preparing files · Not sent,” with existing handoff completion semantics | +| Remote transfer | “Uploading files; message not sent,” with per-file progress and reliable byte totals | +| Server confirmation | “Verifying files,” even at 100% byte transfer | +| Failure | Name affected attachment and reason; preserve whole message with Retry / Cancel send | +| Unknown result | “Confirming send result”; do not claim the Daemon has not received it | +| Sidebar/mobile home | Preparation/upload/failure indicator; new sessions are openable too | +| Agent processing an earlier turn | Preserve Agent activity and add a separate pending indicator; uploading does not mean Agent busy | + +Use i18n and accessible status names, not only spinners. Subscribe by affected message and throttle progress rather than repainting the entire list per part event. Completion updates only the original target, never navigation. + +### 8.1 Browser and application navigation + +Install `beforeunload` while the page owns records not safely handed off or explicitly canceled, including failed and uncertain records. Remove it when none remain. Call `preventDefault()` and set compatible `returnValue`. Browsers control the dialog text; the page cannot mandate a custom unsaved-edits sentence or guarantee that mobile fires the event. + +Conversation switches and panel closes that retain runtime do not prompt. Workspace switching, logout, cache clearing, and application-driven reload first show a clear in-app warning, defaulting to Stay. Before submission, offer “Cancel pending sends and leave.” During submission/uncertainty, say it may already have been sent and retain reconciliation state rather than promising recall. Logout/cache clearing cannot silently erase uncertain records: cancel exit if unresolved; forced clearing explicitly discloses possible delivery and loss of recovery data. + +### 8.2 Electron + +Main aggregates unfinished state from all product windows. Close/reload checks affected windows; application exit/update/cache clearing checks all windows. Confirm before setting quitting state, destroying relays, or stopping the CLI. Canceling exit keeps those resources usable. Hiding a live window continues work. + +Updates pass the same guard before invoking installation shutdown, not only through `before-quit`, because update shutdown closes windows in a different order. Exclude newly accepted work during exit confirmation and avoid repeated dialogs for repeated quit events. A nonresponsive renderer timeout is not “no pending messages”; use main's last known state and an explicit forced-exit warning. Persisted interrupted records remain recoverable after restart. + +## 9. Local recovery and cleanup + +The first version preserves recoverable content without promising transfers after page exit. All platforms store accepted snapshots, source File/Blob data, and stages in local IndexedDB or an equivalent platform store. Electron uses this draft-recovery mechanism too, without depending on future original-path registration. Recovery data is a local copy of unsubmitted content, not a new SessionInputBlock or permanent local-attachment protocol. Recreate object URLs after restart and obtain credentials from the original account/workspace capability for each attempt, never from stored tokens. + +Persistent saving is a takeover prerequisite. Quota failure, disabled storage, or source read errors preserve the draft rather than claiming background sending. Large local saves need visible progress/failure. Define quotas and expiry explicitly; do not reclaim pending, failed, or uncertain messages to make space silently. + +Switching apps or locking the device may suspend processing; foreground return refreshes real state. After restart, preparation-stage records show “Send interrupted; retry available.” Recheck account, workspace, machine, and authorization before user-directed recovery; do not automatically execute old messages. Submission-stage records reconcile acceptance/handoff by the same ID first, rather than retrying as incomplete uploads. Already-accepted delivery obligations may resume synchronization; they do not authorize a new turn or repeated Agent execution. Recovery claims obey the same single-executor rule. + +Cancellation or safe handoff releases pending source Blobs/object URLs once no underlying operation or preview still uses them. Committed attachment storage and cleanup retain existing transport lifecycles and are not deleted by draft cleanup. Upload cancellation promises not to submit the message, not immediate physical deletion of temporary remote bytes. Reuse existing abort/cleanup capabilities rather than inventing a backend deletion protocol. + +Storage eviction, disk damage, user clearing, and forced process termination are outside an unconditional recovery guarantee. Recovery must not disclose signed-out account content to another account. Privacy clearing and unresolved submissions require explicit user choices. + +## 10. Acceptance criteria + +These are implementation acceptance requirements, not product tests completed by this change. Use synthetic files, controllable Promises, fault injection, and actual transport observation rather than user attachments or sleep races. + +| ID | Trigger | Observable result | +| --- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| A01 | Pick, paste, or drop images/files before Send | No attachment upload, full hash, or local handoff; validation/local preview work | +| A02 | Take over A, navigate to B, then finish A's preparation | Only A receives input; B's draft/config/navigation remain; new-session placeholder is reachable | +| A03 | One of two attachments succeeds, or byte transfer is complete but verification pending | No history/queue/dispatch submission; failed file is not omitted | +| A04 | Double Send, repeated Retry, obsolete completion after cancellation | No duplicate takeover/submission; canceled work never revives | +| A05 | Attachment message A fails, followed by text B; another session C is healthy | B waits while C progresses; B can hand off after A is canceled | +| A06 | Change Role/model/permissions/MCP/text after takeover | Frozen configuration and correct spans remain; revocation blocks submission rather than substituting configuration | +| A07 | Add new-conversation attachments, leave/return to landing, send, then start another draft | Restore text/attachments/configuration; retain original session ID after takeover; completion preserves newer draft | +| A08 | Draft attachments in existing A, switch to B and return, send A and edit the next message | Isolated A/B drafts; A completion preserves next input and does not require its composer to remain mounted | +| A09 | New/continuing conversation sends image-only, file-only, mixed attachments, or text plus attachments | Takeover works; preserve type/count/order/text/references; submit only when all ready | +| A10 | Remove/replace attachments or change target before Send; empty/oversized input in both entry points | No automatic transfer; use final draft only; visible errors, no silent omissions, unused previews released | +| A11 | Retry/cancel failed first attachments in a new conversation and failed transfer in an existing one | Preserve whole input, retry failed attachments only; no duplicate new session or interruption of existing Agent | +| A12 | New-session warmup exists, then landing unmounts after takeover or pending send is canceled | Explicit lease transfer/cancellation; no lost task, duplicate startup, or cancellation of another draft warmup | +| A13 | Lost write acknowledgment, queue promotion, partial initial meta/history | Reconcile original IDs; one logical message/session; uncertainty stops resubmission | +| A14 | Failure after writer acceptance but before persistence/dispatch handoff | Recovery record remains; local acceptance is not shown as Daemon receipt | +| A15 | Guide target ends during preparation, or applied steer acknowledgment is lost | Never-attempted guide queues as follow-up; attempted steer reconciles without duplicate ordinary execution | +| A16 | Browser close/reload, Electron auxiliary-window close/quit/update, another window's pending work | Correct guard scope; canceled exit preserves CLI/relay; hide can continue | +| A17 | Quota failure, restart, native mobile reclaim, simultaneous window recovery | Preserve draft on save failure; recover interruption; one executor per record; reconcile unknown outcomes first | +| A18 | Both entry points use existing upload/local handoff, including fallback and legacy local data | Only transfer timing changes; existing attachment types, fallback/backfill, materialization, and image semantics remain; no new cloud capability | + +Applicable A01–A06, A09–A11, and A13–A18 scenarios must be accepted on both new-conversation and continuation surfaces and desktop/mobile layouts, not only a shared helper. A07/A12 specifically cover new conversations, and A08 covers continuations. + +The [finite model](models/session-files.model.ts) checks only declared draft-submission gates, readiness, ordering, and state decisions. It does not establish disk durability, browser lifecycle, or real Agent behavior. Final acceptance includes packaged Electron, browsers, native mobile, and representative adapters. Public component tests cannot establish private-shell/service integration. + +## 11. Effect TS integration and implementation order + +### 11.1 Scope and module boundaries + +Use the repository-pinned **Effect 3.18.4** to manage pending-send work. React/Jotai retain draft editing and view subscriptions; asynchronous work after takeover belongs to a `PendingSendService` independent of component mounting. This is the proposed implementation, not product integration already completed. + +| Coupled module | Current lifetime / dependency | Integration responsibility | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Composer / `useComposerSubmission` | Mount-scoped submission token also owns double-submit protection, keyboard, and focus | Keep the UI token for snapshot saving/takeover; workspace owns background work; clear only the original draft and never steal focus on background completion | +| Landing / child-session drafts | Reserved IDs, tab aliases, parent parameters, cleanup, and navigation share creation callbacks | Separate pending views from real meta; freeze parent/target, merge placeholders by ID; navigation failure cannot delete a written session | +| `useSessionPreparation` / CLI preparation service | Hook debounce/idle timers and cancel RPC; CLI hard TTL, compatibility check, and resource claim | Frontend owns lease control, not ACP itself; first version releases warmup on attachment takeover with owned cancellation cleanup, allowing normal cold start on actual send | +| File/image transfer / Electron handoff | Hash Worker, XHR, retries, main temporary files, and CLI blobs have different owners | Preparation Scope owns cooperative client resources; retain raw ownership of noncancelable IPC; main/CLI still clean up their own resources | +| Configuration / MCP / Role / access / billing capabilities | Read from several hooks/component closures, with values changing during waits | Freeze user input/configuration; recheck eligibility at commit; dynamically obtain credentials/routing and revalidate automatic resume pointers instead of freezing old ACP identity | +| History/queue writer | Store borrowing has finally; initial meta/history run concurrently; writer currently does not execute dispatch arguments | Retain the single writer; extract UI-independent submission covering fixed IDs, partial-success reconciliation, local flush, and dispatch/queue activation data | +| Session store / room sync / caches / prefetch | Separate store references and sync leases; cache disposes/unloads after final release | Borrow stores briefly for writes/reconciliation and sync leases for synchronization; release only owned borrows, never dispose a shared store; preparation/offline parking retain no full history | +| `requestSessionDispatch` / CLI watcher / history gate | RPC carries full input and ACKs a stash; history continues syncing; CLI serializes execution | Workspace owns delivery follow-through; distinguish ACK, persistence, sync, and execution; preserve CLI deduplication/history gate without making Agent execution a renderer child | +| Direct/queue/guide / other message entry points | Presence, unfinished history, direct-submit lock, queue editing/promotion interact | All ordinary new-message producers share session submission eligibility; briefly read fresh routing state; existing queue edits, Stop, and edit/resend retain their own contracts | +| Visual comments / preferences / analytics / list state | Accepted callbacks mark comments submitted, record recent runs, clear references, and scroll | Draft cleanup follows takeover; submission side effects follow real commit evidence, never resend accepted messages on their failure; upload progress remains separate from Agent presence | +| Workspace / account / windows / exit | Token updates can retain runtime; each window has a repo/cursor namespace; exit releases transport/CLI | Token refresh does not rebuild service; identity/topology changes use guards; cross-window recovery checks the original persisted replica; service drains before dependencies | + +Existing reconnect code uses Effect clocks/fibers; the file-index cache uses Scope. Their injection and asynchronous release patterns are useful references, but remaining Promises/manual state do not establish complete structured task ownership. This PR does not additionally rewrite reconnect, file caching, or the entire workspace runtime. + +### 11.2 Explicit lifetime tree + +Proposed resource ownership follows. Persistent recovery records are not temporary Scope resources. + +```text +one workspace runtime generation +└─ Send service ManagedRuntime / service Scope + ├─ preparation task → attempt Scope (hash, upload, progress, waits) + ├─ submission task → short writer/store borrow + ├─ post-submit delivery attempt → store/sync leases, RPC, reconciliation + └─ necessary cleanup of late preparation/IPC results + +React composer: short takeover and focus token only +CLI Agent / backfill: separate owners, not renderer child tasks +Persistent recovery records: survive task parking and process exit +``` + +Compose the service and injectable storage/transport/submission dependencies using `Layer.scoped`, with one `ManagedRuntime` owned by the workspace. After takeover returns, work explicitly uses `forkIn` with a service-owned Scope; do not attach it to the short-lived button invocation fiber or use an unowned `forkDaemon`. Scopes group resources that stop/release together, not every function. Preparation completion cannot terminate registered delivery work. Failed/offline records park in storage without indefinitely retaining fibers/open stores where avoidable. Storage, transport, and submission boundaries suffice; avoid a Layer per helper or a ManagedRuntime per message. + +`acquireRelease` / finalizers release owned Workers, listeners, and session resource leases. Actual preview owners release their URLs; recovery Blobs, successful attachments, and written messages follow section 9 instead of unconditional deletion on Scope closure. Ordinary failure ends its own task without terminating the service or another session. + +### 11.3 Concrete cross-module handoffs + +**UI takeover and actual submission differ.** The composer receives “saved and owned,” not “history written.” Retain `useComposerSubmission` locking, immediate mobile keyboard dismissal, and desktop one-shot focus policy for the short takeover phase. Save failure preserves drafts without automatically refocusing mobile. Move visual-comment `mark-submitted`, recent run settings, and submission analytics to actual acceptance/handoff events instead of reusing the old meaning of `onSendMessage === true`. Failed bookkeeping retains diagnostics/needed follow-up work without resending messages or overwriting newer drafts/comments. + +**Creation includes child drafts.** Integrate `draft-session-chat-interface` / `session-detail.handleSendDraft`. Freeze parentSessionId, project, machine, child tab ID, and future session ID; leaving the parent during upload cannot retarget work. Rebuild promotion aliases from pending records on return. Distinguish definite rejection from partial creation; a broad catch must not delete an accepted child because navigation/analytics failed. Explicit deletion/closure of a draft or Side Chat containing pending work handles that record; ordinary panel hiding does not cancel it. + +**Warmup is a disposable optimization.** Current `handoffToSession()` only drops hook references/timers and returns a boolean; it is not a resource handle suitable for a long upload. First version stops draft warmup scheduling on attachment takeover and gives the service ownership of “await start settlement, then cancel the exact preparationId.” Do not claim transfer to an actual session or extend CLI TTL. Cancel failure cannot lose the message; CLI expiry/compatibility checks remain, and cold start is valid. Attachment-free sends retain existing valid warmup reuse. Keeping warmup through uploads later requires explicit lease handoff/expiry rather than captured hook closures. + +**Freeze choices; refresh runtime facts.** Text, attachments, Role revision, model/permissions, MCP including `[]`, tool switches, project, and guide target are fixed. Runtime-injected capabilities supply credentials, routing, access/billing eligibility, archive/deletion, and busy state. Preserve existing offline/indeterminate-access rules rather than blanket denial or fabricated permission; token refresh does not retransmit successful attachments. Automatic acpSessionId/resume is a runtime pointer revalidated at submission; explicit fork/edit-resend targets retain their own contracts and cannot be arbitrarily rewritten as ordinary delayed input. + +**Ordinary new messages share submission.** Continuation text-only sends, plan execution, and toolbar-generated ordinary prompts cannot bypass same-session FIFO. Extract the existing route resolver and preserve queueing behind unfinished history when presence is absent; a component-local direct lock cannot coordinate multiple surfaces. Already-handed-off queue editing/reordering/promotion and Stop are not owned by preparation Scope; ordinary sends cannot bypass edit/resend's rewrite barrier. Classify guide as applied, authoritatively not submitted, or unknown. Existing `no-active-turn` may promote the same ID where its protocol proves no submission; other false/timeout results do not prove nonapplication. + +**Delivery has a separate owner.** Current `requestSessionDispatch` starts sync waiting, RPC, and a metadata pointer write; writer dispatch arguments do not perform those actions. Extract them into submission adapters and workspace-owned delivery tasks. Retain RPC acceleration without waiting for remote sync to clear the composer. RPC carries full input and CLI may execute early, but ACK proves stash/deduplicated receipt rather than persisted history/meta. Continue history synchronization for CLI TurnHistoryGate ordering; local submission need not wait for an entire Agent turn to end. + +The proposed local handoff boundary requires fixed-ID history/queue plus required metadata/activation data confirmed through repo local persistence, with delivery obligations durably registered. Preparation can then finish and unused source Blobs release; the same recovery record may compact into a smaller delivery record until sufficient target sync/receipt evidence retires that obligation. Closing upload Scope must not also interrupt delivery; never delete recovery then launch an unowned Promise. RPC timeout calls for reconciliation, not another append. No new message protocol is introduced. + +**Store borrowing differs from sync borrowing.** Use existing cache/writer borrows for writing/reconciliation and independent leases for sync. Finalizers release their own borrow, never store.dispose()/repo.unloadDoc() behind the UI. A late asynchronous acquire must still release its handle after interruption; `tryPromise` must not discard it. acquireRelease masks acquisition by default, so assess acquisition wait bounds instead of blocking exit indefinitely. Active sync attempts retain necessary stores; offline backoff releases stores/connections and reacquires later. Delivery works without mounted UI without creating a transport or Mirror per upload. + +Current waitUntilSynced(signal) can return on abort and selected detached bindings, and its transport-ready wait does not yet forward cancellation. Promise resolution is therefore not a sync receipt. The submission adapter must distinguish local durability, confirmed target-binding sync, interrupted/disconnected, and uncertain outcomes, including late-join cleanup. Reuse runtime routing/recovery rather than creating a reconnect loop per message. + +**Cross-window recovery is not another Ref.** Repo and cursors currently use window namespaces. Pending records may coordinate in the storage domain, but a new window's empty replica does not prove another window never submitted; consult original durable state or authoritative acceptance. Token refresh preserves ownership. Account/workspace/local-runtime-setting changes that destroy runtime/routing checkpoint state, fence the old generation, and follow shutdown order. Recheck archive/deletion before submission; recovery cannot resurrect a deleted session from an uncertain record. + +### 11.4 Transitions, cancellation, and retry + +An immutable record and centralized synchronous transition function check runtime generation, ownership, attempt, and phase. Ref.modify can integrate this with Effect, or encapsulated ordinary variables can implement it: correctness comes from transition rules and no await between check/update. Serialize/version-check recovery writes and checkpoint fixed identity/submitting before invoking the writer. Platform coordination separately owns cross-window exclusion. + +Preparation completion, cancel, retry, and submit use this entry point. A cancellation winning ready → canceled blocks late readiness; a submission winning ready → submitting permits only confirmation/reconciliation. Waiting for predecessors holds no upload capacity or dispatch lock. Semaphore bounds actual in-flight transport, not entire message network waits. + +Connect existing file hash/upload AbortSignals to Effect; add xhr.abort() and listener release to image postMultipartWithProgress. Replace multipart void fetch(DELETE) with owned bounded cleanup preserving the original error. Noncancelable Electron File.arrayBuffer()/IPC revokes submission eligibility but retains raw Promise/capacity ownership until settlement; late results only clean up without submitting or cloud fallback. Main temporary files and CLI blobs/backfill keep their existing owners; draft finalizers cannot delete handed-off attachments. + +Distinguish validation, storage, authorization, retryable transport, and uncertain submission. Interruption cannot trigger failure fallback. Keep one part-retry layer; migration to Schedule removes the old loop, without whole-write/steer retries. Record individual attachment successes and isolate failures between messages. Never wrap whole uploads, IPC, or network sync in uninterruptible; it cannot make CRDT, disk, and RPC transactional. + +### 11.5 Workspace shutdown order + +For normal workspace/account changes, section 8's confirmation precedes navigation/React unmounting. Then stop takeover and revoke old work's submission eligibility → checkpoint interrupted/uncertain state → interrupt/join cooperative tasks and account for unsettled raw IPC → await record persistence and service Scope closure → destroy session caches, transport, and repo. Repeated dispose returns the same completion result. + +React cleanup in `RuntimeProvider` cannot make React await asynchronous disposal; the guard and wait belong to the explicit leave flow. Runtime's asynchronous `dispose()` still enforces internal resource order. While raw work remains unsettled, normal leave waits or lets the user stay. Forced leave retains recovery records; Electron main continues existing IPC temporary-file cleanup, and late renderer callbacks cannot access the old repo. Timeout is not safe handoff. Forced process termination/mobile reclamation cannot guarantee finalizers, so recovery depends on previously persisted checkpoints rather than a final exit-time flush. + +### 11.6 Staged adoption and acceptance + +Complete ownership changes before changing transfer timing. The following four PRs merge in sequence, each with observable completion conditions. Split by responsibility rather than individual files or screens; shipping first and patching failures later is not acceptance. + +| PR | Complete responsibility delivered | User behavior and merge conditions | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1: Extract submission boundaries | Extract a UI-independent submission entry from components/hooks, retaining separate creation, continuation, and guide adapters. Pass target, input, and configuration explicitly; React retains focus/navigation. Reuse existing types and writer without inventing a general task framework. | Preserve transfer timing, routing, and side-effect timing. Behavioral tests at actual boundaries compare message content, configuration, creation/queue results, and draft preservation on failure across new, child, continuation, and other ordinary-message sources. Record existing defects as counterexamples with an owning later stage, not expected correct behavior; do not promote old booleans into durability guarantees. | +| 2: Own asynchronous resources | Introduce one workspace Effect runtime for uploads, hashing, image processing, multipart cleanup, and store/sync borrows. Expose ordinary Promise/cancellation/subscription interfaces. Each migrated resource has one acquisition, retry, and release owner. | Transfer still starts on addition; behavioral improvements are limited to explicit cancellation and cleanup. Verify actual underlying cancellation, late IPC/handles, preservation of UI-shared stores, stopped retries, and workspace shutdown order, rather than only fiber termination. Resources introduced here must have complete exit cleanup in this PR. | +| 3: Own submission and delivery | Move already-prepared messages into the service: stable identity, recovery intent recorded before submission, serialized writes/reconciliation, explicit uncertain outcomes, and registered independent delivery. Define local persistence and target-sync receipts plus original-replica recovery. Ordinary messages share ordering while retaining direct/queue/guide rules. | Transfer timing remains unchanged; this is an explicitly scoped reliability change. Verify partial writes, lost ACKs, UI unmounting, offline operation, restart, and cross-window claiming. Never blindly retry uncertainty; distinguish authoritative guide non-submission. Submission does not await remote sync or full Agent execution; annotation/configuration/analytics bookkeeping follows actual acceptance stages. Complete record quotas, retention, exit handling, and recovery compatibility in this PR. | +| 4: Deliver complete attachment drafts | Switch new conversations, children, and continuations together to transfer after Send. Save complete input before clearing drafts; connect pending views, progress, failed-item retry, cancellation, FIFO including text, warmup release, and platform exit/recovery. | Only this cutover promises the Spec's draft experience. Set file-storage quotas/retention first and preserve drafts on save failure. Run A01–A18, E01–E12 below, and relevant platform acceptance. Landing alone is insufficient; exit, failure, and recovery are not deferred patches. | + +During adoption: + +- **One actual executor per responsibility.** Old hooks and the new service never both orchestrate the same message/attachment. The migrating PR removes the previous owner. Pure results and synthetic fixtures may be compared; production uploads, history writes, and dispatch must not run twice. Uncertain submission never automatically falls back to legacy sending. +- **Keep Effect inside asynchronous services.** Do not rewrite components, Jotai, or pure TypeScript state solely for adoption; Ref.modify remains optional. Reuse the writer, caches, transport/reconnect, and CLI. Do not combine adoption with protocol redesign, an Effect major upgrade, or upload-free local references. Keep pinned 3.18.4. +- **Verify each newly owned real boundary.** Observe successful content/state and control failure, cancellation, late completion, and shutdown with explicit signals. Add actual storage/recovery acceptance when persistence is introduced. Compare behavior and interaction latency/resource use for pure migrations; investigate meaningful regressions before layering on the next stage. Test counts and small probes do not replace these conditions. +- **Switch whole service versions.** Prefer independent PRs and internal builds over long-lived dual implementations. If a temporary switch is necessary, select it before workspace service startup. In-flight work keeps its owner; per-message splitting must not break ordering. + +Rollback is a completion condition for each PR. Before recovery records exist, refactoring can be reverted after in-flight operations finish. Once records exist, stop takeover, finish or reliably retain in-flight work, and return only to a compatible version that can recognize and process those records. Do not merely revert to code that ignores them, delete unfinished records, or resend uncertain messages through the old path. PR 3 cannot ship while record compatibility and original-replica recovery remain undefined; PR 4 cannot be enabled early. + +Add the following checks with their owning responsibilities, then run them together for complete draft acceptance: + +| ID | Deterministic check | Required observable outcome | +| --- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| E01 | Release preparation after Send returns/component unmounts, then close workspace | Original task continues; closure awaits cleanup; no callback accesses destroyed repo | +| E02 | Explicitly order ready/cancel/retry in both directions | One valid transition; obsolete attempt cannot revive; later messages obey FIFO | +| E03 | Cancel image XHR/hash; release late noncancelable IPC | Cooperative work really stops; late IPC neither submits/falls back nor prematurely releases actual in-flight capacity | +| E04 | Writer produces a side effect before interruption/lost acknowledgment | Keep recovery record and reconcile original ID without blindly writing again | +| E05 | One attachment/session fails while another succeeds | Preserve success; other sessions continue; failed message stays complete | +| E06 | Cancel retry wait, block async cleanup, claim from two windows | TestClock/explicit signals control steps; no late retry, correct close order, one executor in the actual storage domain | +| E07 | Cancel before acquire returns; UI and send share a store | Release late handles; ending send drops only its own reference, leaving UI valid | +| E08 | All conversation UI unmounts; RPC ACK precedes disconnected history/meta sync | Owned recoverable delivery continues; no duplicate append or treating detached/abort as sync success | +| E09 | Warmup start arrives late, upload exceeds lease lifetime, new draft warms up | Clean up exact old lease without canceling new draft; actual send may cold-start | +| E10 | Switch parent tab, change ACP resume, revoke/archive, edit annotations during upload | Preserve target/user choices; revalidate runtime identity/eligibility; old bookkeeping preserves newer content | +| E11 | Navigation fails after child write; applied steer ACK lost; authoritative no-active-turn | Preserve child identity, reconcile uncertainty without duplicate execution, transition only on proven non-submission | +| E12 | Window B claims A's uncertain record with no turn in B's replica | Reconcile rather than treating empty replica as rejection; restart continues existing delivery without a new turn | + +Use `Deferred`, explicit Promise gates, and `TestClock`, not real sleeps or a guessed number of microtask flushes. The [Effect probes](models/session-files.effect-probe.mjs) pass six boundary cases covering Promise interruption, signals, Scope cleanup, generations, and two current store-ref-tracker.ts cases with synthetic stores: releasing late acquisition and preserving UI-owned resources. They do not establish E01–E12 or product integration acceptance. + +Original-path wire/IPC, file registration authority, local image adaptation, and old-Daemon compatibility remain in the [follow-up PR](local-attachment-references.md), not prerequisites for adopting Effect. + +## 12. Evidence and verification status + +Inspected source: `8c429a890037c5b21855ce7ef9f59e3677c25a38`. This Spec and the [decision note](../.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md) are design artifacts. This revision defines the PR split and Effect lifecycle proposal without changing product behavior or running device send acceptance. + +- New conversations: `packages/components/src/components/chat/chat-landing.tsx`, `hooks/use-chat-landing-{file-draft,image-draft,draft-session}.ts`. +- Continuations: `packages/components/src/components/sessions/session-chat-input-area.tsx`, `session-chat-interface.tsx`. +- Shared boundaries: `packages/components/src/lib/{electron-session-file-sender,session-file-upload,session-image-upload,multipart-upload}.ts`, `hooks/use-session-actions.ts`, `providers/{runtime-provider.tsx,workspace-writer-impl.ts}`; `packages/shared/src/{history-writer,session-input,message-schemas}.ts`, `session-data/loro.ts`. +- Exit lifecycle: `apps/electron/src/main/index.ts` and `main/window.ts`. +- Current explanations: [CLI attachment lifecycle](../.agents/docs/cli-lib-session-files.md), [composer/run configuration](../.agents/docs/sessions-run-config.md). Existing decisions: [workspace draft isolation](../.agents/notes/implemented/bug-fix/2026-09-11-workspace-window-composer-drafts.md), [context copy and withdrawn text-attachment experiment](../.agents/notes/implemented/feature/2026-09-09-conversation-context-fallback.md). +- Platform references: [Electron quit/update ordering](https://www.electronjs.org/docs/latest/api/app#event-before-quit), [browser beforeunload limits](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event). + +- Existing Effect use: `pnpm-workspace.yaml` pins 3.18.4; `src/providers/local-reconnect-loop.ts`, `src/lib/code-collab-file-index-cache.ts`, and `tests/local-reconnect-loop.test.ts` provide partial examples; `src/providers/create-workspace-runtime.ts` owns asynchronous teardown. Paths are under `packages/components` except the workspace configuration. +- Official Effect 3.18.4 API source: [Scope/fiber ownership](https://github.com/Effect-TS/effect/blob/effect%403.18.4/packages/effect/src/Effect.ts), [Promises and cancellation signals](https://github.com/Effect-TS/effect/blob/effect%403.18.4/packages/effect/src/Effect.ts), [ManagedRuntime](https://github.com/Effect-TS/effect/blob/effect%403.18.4/packages/effect/src/ManagedRuntime.ts), [TestClock](https://github.com/Effect-TS/effect/blob/effect%403.18.4/packages/effect/src/TestClock.ts). Actual probes used a temporary 3.18.4 installation without changing repository dependencies. + +- Coupled lifecycle evidence: `packages/components/src/components/chat/submission/use-composer-submission.ts:44`; `hooks/use-session-preparation.ts:113`; `components/sessions/{session-detail.tsx:1979,session-chat-interface.tsx:2394}`; `providers/{workspace-writer-impl.ts:76,create-workspace-runtime.ts:3854,store-ref-tracker.ts:212}` (the latter groups are relative to `packages/components/src/`). CLI: `apps/cli/src/session/{session-preparation-service.ts:142,session-dispatch-watcher.ts:681,turn-history-gate.ts:5}`. diff --git a/specs/session-files.zh.md b/specs/session-files.zh.md new file mode 100644 index 000000000..997df3815 --- /dev/null +++ b/specs/session-files.zh.md @@ -0,0 +1,364 @@ +# 附件草稿与待发送消息 + +Status: draft +Translation: current + +[English](session-files.md) + +## 摘要 + +- **新对话和已有对话都采用附件 draft 模式。** 选择、拖入、粘贴图片/文件只做初步校验和本地预览,可移除、替换;点击发送才调用现有附件传输。 +- **点击发送后由独立管理器接管完整输入。** 保存成功后释放输入框,显示待发送消息和上传进度,允许切换会话;完成时只更新原目标,不抢回导航或清理新草稿。 +- **全部附件就绪才真正提交。** 一个失败就保留整条消息,支持重试和取消;同会话按接管顺序提交,后发纯文本不越过前面的附件消息,不同会话独立推进。 +- **两类入口共用流程,分别验收。** 新对话保持预留 session ID 和可返回的待发送入口;已有对话冻结本轮配置并接入现有 direct/queue/guide,不改变正在执行的上一轮。 +- **重试、退出和恢复不丢内容、不重复提交。** 固定 turn ID,区分本地接受、持久化与 Daemon 接收;结果未知先核对。关闭/刷新保护覆盖未完成任务,移动端恢复后确认重试,不承诺应用退出后继续上传。 +- **建议分阶段用 Effect 管理任务与资源。** 先抽提交边界,再接管资源,再完善提交/投递,最后同时交付两类入口的 draft。前三步保留添加即传的时机;组件保留普通接口,Effect 留在 workspace 服务内部。持久化、跨窗口互斥与提交结果核对仍需明确实现。 +- **当前 PR 只落实 draft 生命周期。** 复用现有云端上传、本机 handoff、fallback 和 backfill 语义;原路径直引、永久零上传及相关协议/Daemon 改动属于[独立后续 PR](local-attachment-references.zh.md),不作为本 PR 的依赖。长文本转文件和图片编辑器也不在本版。本文仍为待审阅草稿,尚未实现。 + +## 1. 场景与 PR 范围 + +用户在新对话输入页或已有对话输入框里添加附件,发送前可检查和修改草稿。点击发送后转为本机待发送消息,允许用户去处理其他会话;全部附件传输确认后再进入真正的发送链路。返回原会话可查看进度、失败原因或重试。 + +“离开会话”指同一承载页面内导航;“离开页面”包括关闭、刷新、外站跳转或应用销毁 runtime。前者继续处理,后者须提示。附件服务可以先接收文件,但不能提前让 Agent 执行这条消息;已有任务和无执行副作用的启动预热不受影响。 + +本 PR 覆盖新建会话、已有会话继续发送(含子会话)、图片、普通文件及纯附件消息。桌面和移动布局使用同一生命周期;原生移动壳的恢复接入在其所属仓库验收。公开桌面端仍遵守[平台边界](../packages/platform/AGENTS.md),不能为了统一 draft 流程引入原本不存在的 cloud capability。 + +| 当前 PR | 独立后续 PR | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| 草稿校验/预览、发送接管、现有传输延后、进度、取消/重试、提交与恢复、双入口验收 | 本机原路径引用、无路径内容的永久本地附件、新引用协议/授权/能力协商、Daemon 解析、取消自动上传/补传 | + +两者可独立审阅与交付。当前 PR 不改变附件 wire 类型、既有路径复制/物化方式、上传服务接口、fallback 或 backfill 策略;客户端传输 helper 可以补齐取消参数;Electron 仍需为 draft 的退出保护调整生命周期。后续 PR 接入相同准备边界,不重做草稿和发送编排。系统级后台上传、跨设备未发送草稿同步、长文本自动转文件、新图片编辑器及新的 Agent 输出策略均不在本版;已有折叠文本继续在发送时展开。 + +为降低接入风险,建议将三项前置整理独立为依次合并的 PR,完整 draft 功能在第四个 PR 交付,详见第 11.6 节。本文“当前 PR”指该 draft 功能 PR;其验收范围不按新建/继续对话拆开。本机跳过上传仍是另一项后续工作。 + +## 2. 当前实现与落地差距 + +以下是本 checkout 的源码观察,不代表部署验收。完整路径集中在末尾证据区。 + +| 当前行为 | 本 PR 的调整 | +| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| 新建及已有会话添加图片/文件后立即传输,状态编排依赖组件/hooks | 添加只校验、预览;统一管理器在点击发送后调用传输 | +| 图片未成功会阻止发送,普通文件失败却被过滤掉 | 统一等待全部附件;不静默减少附件 | +| landing 与已有会话分别维护附件和提交状态 | 共用草稿/接管契约,仅创建会话与继续发送的最终动作不同 | +| 同机文件经临时文件/CLI blob store;旧 local 数据可补传,部分路径可 fallback | 将这些现有调用延后到发送,不重写其传输语义;不是永久零上传 | +| `startSession` / `addSessionHistory` 每次生成 ID,HistoryWriter append 不按 ID 去重;首次 meta/history 并行写 | 固定提交 ID,通过现有 writer 核对并恢复部分成功 | +| writer 接受只表示本地 CRDT 写入;工作区切换会销毁 runtime | 定义持久交接、恢复记录和退出保护 | +| Electron `before-quit` 先销毁 relay 并关闭 CLI | 先检查待发送任务和确认,再执行清理 | + +当前 renderer 通过 WorkspaceWriter / SessionData / HistoryWriter 写用户消息,不能依旧注释恢复“Electron 由 CLI 代写”。继续沿用[唯一历史 writer](session-history-writes.zh.md),不引入第二套历史写入。 + +## 3. 职责与数据归属 + +```mermaid +flowchart LR + N[新对话附件草稿] --> P[统一待发送管理器] + C[已有对话附件草稿] --> P + P <--> J[本机恢复记录] + P -->|点击发送后| U[现有附件上传或本机 handoff] + U -->|全部附件 ready| W[现有 writer 与可靠发送链路] + W --> D[目标 Daemon / Agent] + P --> V[消息区、列表、退出保护] +``` + +| 责任 | 归属 | +| --------------------------------------------------- | ------------------------------------------------------------- | +| 输入文字、mention spans、引用、附件顺序与发送前编辑 | 输入框草稿;组件不拥有后台任务 | +| 接管、传输、取消、重试、顺序、结果核对与恢复 | shared components 中不依赖会话组件挂载的管理器 | +| 首次创建参数与预留 session ID / 既有会话目标 | 两个入口的快照构造;统一传给管理器 | +| 文件上传或已有本机 handoff | 现有平台传输能力,成功后返回现有 SessionInputBlock | +| 消息/队列写入和发送 | 既有 WorkspaceWriter / SessionData / HistoryWriter 与发送机制 | +| 本机待发送数据保存、窗口/应用退出 | 平台存储能力及 Electron main/renderer 生命周期 | + +管理器至少高于 landing、会话页和移动面板,活到承载页面退出。不为切工作区引入多个长期 runtime;销毁旧 runtime 前处理其待发送任务。 + +恢复记录按平台/账户/工作区/会话隔离,持有固定身份、创建参数(如有)、输入快照、附件源及成功结果、发送意图和阶段。它不属于共享 Session Doc;上传进度、File/Blob、object URL、token 和恢复锁都不写入共享消息。真实消息仍使用现有附件表示。 + +同一浏览器存储域或 Electron 数据目录内,同记录只有一个执行者。通过本地互斥协调领取、排队序号和提交资格;其他窗口不重复执行,失去所有权的旧执行者不能提交。不同设备/存储域之间不承诺点击时间的全局顺序,继续现有协作规则。 + +## 4. 附件 draft 生命周期 + +### 4.1 添加、修改与离开输入框 + +选择、拖入或粘贴时,只检查类型、数量、空文件和现有大小限制,保存 File/Blob 与有界本地预览,显示“待上传”(已有本机传输路径可显示“待准备”)。不启动上传、全量 hash 或本机 handoff,也不将普通 pending history/queue 当草稿存储。 + +发送前可移除附件或替换草稿来源;失败校验不得留下会被静默忽略的附件。移除、替换后释放不再使用的 object URL,不释放其他草稿或已接管任务仍使用的源数据。切换会话或暂时离开 landing 再返回,同一作用域内的附件、文字、引用与顺序仍在,不能只恢复附件文件名而丢失 File/Blob。 + +发送前改变目标或运行配置只更新草稿并重新做必要校验,不自动上传。未点发送就移除全部附件,不产生传输任务。已有折叠文本仍由原有逻辑展开;不新增文本转文件或图片编辑 UI。 + +### 4.2 发送时复用现有传输 + +点击发送并成功接管后,按既有平台与目标选择上传或本机 handoff;复用上传结果和已有 SessionInputBlock,不新加本地路径引用类型。旧 `transport: local` 就绪仍表示该目标可按旧链路使用附件,不表示永久不上传,也无需在本 PR 改成等待后台 backfill 完成。 + +云端上传需要最终有效响应,传输 100% 仍在服务端校验不能 ready。本机 handoff 按现有响应判断就绪;原有 fallback 仍受平台 capability 限制,不因 draft 重构扩大。哪条现有链路被使用是传输实现细节,UI 不能把本机准备伪装成网络上传百分比。 + +管理器只接收明确的附件成功结果;失败/取消不能通过过滤附件变成成功。重试复用同任务已确认结果,只重新处理失败或已失效的结果;提交失败不重新上传所有附件。图片传输也须支持取消,旧代次的完成不能再触发提交。 + +## 5. 新对话与已有对话的完整流程 + +| 阶段 | 新对话 | 已有对话继续发送 | +| ------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------- | +| 添加附件 | 归属当前 landing 草稿,与文字、引用和预留 session ID 一起保存 | 归属当前 workspace/session,切换会话不串草稿 | +| 点击发送 | 冻结创建参数、输入和配置,沿用预留 ID;仅接管成功才清该份草稿 | 冻结本轮输入/配置/目标;仅接管成功才清该份草稿 | +| 上传期间 | 有独立于真实 meta 的可打开待发送视图和列表入口;可继续开其他新对话 | 原会话显示待发送消息,原有 Agent 活动继续;可切会话或继续编辑下一条 | +| 全部就绪 | 用同一 ID 创建会话及第一条消息,只交接一次 | 用同一 turn ID 进入现有 direct/queue/guide,只交接一次 | +| 准备失败/重试 | 保留创建参数、文字及所有附件;重试不生成新 session | 保留原目标和输入;重试不读取当前正在看的其他会话 | +| 成功 | 占位合并成真实会话/首条消息;不抢回导航 | 占位与实际 history/queue 合并;不清后来编辑的下一条草稿 | +| 提交前取消 | 移除该待发送任务,不创建空会话;保留用户另外开始的新草稿 | 仅取消该未提交任务,不停止 Agent 或取消已有队列消息 | + +两个入口调用同一接管与准备服务,不能保留两套独立的上传、重试或取消状态机。首次创建与已有会话的最终提交适配不同,但输入快照、就绪条件和失败语义一致。已有会话不能因为点击时 Agent 正忙,就在附件未准备完前写入可执行队列。新对话的预热 lease 要移交或取消,不依赖卸载后的 hook 自动完成。 + +## 6. 接管、状态与顺序 + +### 6.1 点击发送的边界 + +同步防双击后,冻结文字、mention spans、代码/视觉引用、附件顺序、创建参数、目标、Role 及其版本、模型/模式/权限、MCP 选择(含显式空数组)和发送意图。附件快照保存点击发送时的 File/Blob、顺序及现有已准备结果。角色或配置后来改变不能替换这份快照;权限撤销、机器移除和会话归档须在提交前重新检查。 + +接管成功表示管理器持有可恢复记录和所需的源数据,才清理本次草稿并释放输入框;移动键盘仍在点击发送时按现有策略收起,保存失败不自动重新聚焦。保存期间显示“正在保存待发送内容”;失败则保持原输入。各平台的 File/Blob 与待发送内容按第 9 节保存;不依赖后续 PR 的本机引用登记。旧异步回调不能清理用户后来输入的草稿。 + +### 6.2 最小消息状态 + +| 状态 | 含义与允许动作 | +| ----------------- | ------------------------------------------------------- | +| 待准备 / 准备中 | hash、现有上传/本机 handoff 或服务端校验;可取消 | +| 等待上一条 | 附件可独立准备,但前一条尚未交接或取消;可取消 | +| 准备失败 / 已中断 | 尚未提交;保留整条内容,重试失败附件或取消 | +| 提交中 | 进入 writer/队列交接;不再提供“取消发送”承诺 | +| 正在确认发送结果 | 写入、持久化或交接结果未知;仅核对,不盲目重发 | +| 已交接 | 本地发送系统已持久接管;投递/执行状态由现有机制继续展示 | +| 已取消 | 仅提交前可到达;晚到的准备结果不得复活消息 | + +普通消息状态与准备/投递资源的存活分别管理;已交接消息可能仍有持久投递任务。单附件记录 `ready` 或具体准备阶段;上传的 byte progress 与服务端确认分开。传输 100% 但尚未确认时不 ready。不对本机 handoff 伪造上传百分比。取消须覆盖所有准备阶段;可取消的 I/O 实际停止,不能取消的操作按第 11.4 节撤销提交资格并收尾,使用 attempt 代次忽略旧结果。 + +同一客户端存储域内,同会话按接管顺序进入发送系统,包括后续纯文本消息。A 失败或结果未知时 B 等待,取消 A 或确认 A 已交接后才放行 B。不同会话可并行,全局传输有界;具体并发值通过负载验证确定,不作为协议常量。 + +### 6.3 direct / queue / guide + +管理器的待发送记录与 Daemon 的消息队列是两层:附件就绪前,不能写入可执行的 history 或 queue 来占位。上传不能占住 direct dispatch 锁。 + +冻结的是用户意图和配置,真正提交时仍检查 Agent 当前是否忙。普通发送安全地进入现有 direct/queue 路径,不因上传期间 Agent 开始工作而中断其新任务。明确的 queue 意图仍进入队列。 + +guide 冻结所指向的 assistant turn。准备完成前该 turn 已结束且从未尝试 steer 时,改为普通后续排队并在 UI 说明,不改为引导另一个 turn。已尝试 steer 后,权威 `no-active-turn` 若按现有协议明确证明未提交,可沿用同 ID 转后续发送;其他失败/未知回执须核对,不能证明未应用。已应用的 steer 按[现有历史契约](session-history-writes.zh.md)处理,不能再次作为普通消息执行。 + +## 7. 提交身份、持久化与首次会话 + +接管时产生固定 submission/turn 身份;沿用已有 `userTurnId` 与 queue → history 的身份关系,不添加一套随重试变化的消息 ID。队列项自身也保持稳定身份。检查同 ID 是否已存在以及输入是否相同,需在现有 writer 的本机串行边界完成;内容不一致就是冲突,不能覆盖。 + +固定 ID 本身不能阻止 LoroList 重复 append。实现必须验证同记录多窗口互斥、writer 核对、队列提升及重连行为,不能把“请求发了两次但 ID 一样”当成成功去重。保障目标是一次本机提交不制造重复消息,不宣称提供新的分布式 exactly-once Agent 执行机制。 + +区分三层证据: + +1. **writer 接受**:本地 CRDT 已变更,不代表数据已写盘,更不代表 Daemon 收到。 +2. **安全交接**:输入、创建元数据(如有)以及后续 dispatch/queue 所需信息已由现有持久发送路径接管,恢复后仍能推进。达到这个条件才释放不再需要的草稿内容、解除本次退出保护;尚未完成的投递义务继续以可恢复记录交给 workspace 任务,不能直接删除。 +3. **目标接收/执行**:只有现有 Daemon/Agent 证据支持时显示;同步连接成功不是接收证据。 + +提交报错须区分确定未写和结果未知。确定未写可沿用同一 ID 重试,不重传已准备成功的附件;未知则先查原 history、queue 和交接状态。离线副本里查不到不等于没写过。收到接受回执但未证实安全交接时继续保留恢复记录;附件引用也不能提前清理。 + +新会话复用 landing 预留的 session ID,并保存 project/branch、Agent 等创建参数。准备期间用本机可打开的占位视图和列表项,不依赖真实 session meta 已存在。全部附件就绪后创建真实会话;meta 已写/history 未写、history 已写/meta 未写分别补齐同一会话,不能重建新 ID。原页面的 ACP preparation lease 必须移交或取消,不能依赖卸载后的 hook;允许取消预热后正常启动。 + +真实 history 或 queue 可见时,用固定 turn ID 将占位替换成同一行,不短暂消失或出现两行。补齐待发送占位的元数据也不能覆盖其他窗口后来修改的真实会话数据。 + +## 8. 展示与离开保护 + +| 位置/阶段 | 展示与行为 | +| -------------------- | ------------------------------------------------------ | +| 输入框附件 | 待准备 / 待上传;支持移除和发送前替换来源 | +| 本机准备 | “正在准备文件 · 尚未发送”,完成条件沿用现有 handoff | +| 远程传输 | “文件上传中,消息尚未发送”,逐附件进度及可信字节总量 | +| 服务端确认 | “正在校验文件”,即使字节传输为 100% | +| 失败 | 明确失败附件与原因,整条消息保留“重试 / 取消发送” | +| 结果未知 | “正在确认发送结果”;不能显示“Daemon 尚未收到” | +| 侧栏/移动首页 | 对应会话的准备/上传/失败标记,新会话也可打开 | +| Agent 正在处理上一条 | 保留 Agent 状态,另加待发送标记;上传不冒充 Agent busy | + +全部文案进入 i18n;可访问名称表达状态,不能仅靠 spinner。按受影响消息订阅并节流进度,避免每个分片事件重绘整个列表。完成仅更新原目标,不改变当前导航。 + +### 8.1 浏览器和应用内导航 + +有尚未安全交接或明确取消的记录时,在承载页面安装 `beforeunload`,包含失败和结果未知任务;全部结束后移除。调用 `preventDefault()` 并设置兼容性的 `returnValue`。浏览器弹窗文案由浏览器决定,不能指定“现在有未保存的编辑”,也不能保证移动端触发。 + +切换会话、关闭不销毁 runtime 的面板不弹框。切工作区、退出账户、清缓存或主动刷新前,用应用内对话框说明未发送内容,默认留下。提交前可选择“取消待发送并离开”;提交中/结果未知只能说明“可能已发送”,保留核对记录,不能提供假的撤回承诺。退出登录/清缓存不得静默抹掉这种不确定记录:无法核对时可取消退出;强制清除须明确披露可能已发送和恢复数据会丢失。 + +### 8.2 Electron + +main 汇总所有产品窗口的未完成状态,窗口关闭/重载检查受影响窗口,应用退出/更新/清缓存检查所有窗口。确认必须在设置退出标记、销毁 relay、停止 CLI 之前完成;取消退出时这些资源均保持可用。普通 hide 继续任务,不视为真正关闭。 + +更新安装须在调用退出安装入口之前经过同一保护,不能只依赖 `before-quit`,因为更新退出的窗口关闭顺序不同。确认期间防止新任务越过退出检查,重复 quit 不重复弹框。renderer 不响应时不能将超时当成“没有待发送消息”;使用主进程最近的状态和明确的强制退出提示。已持久保存的中断记录仍供重启恢复。 + +## 9. 本地恢复与清理 + +首版采用“可恢复内容,但不承诺离页继续传输”。各平台将已接管输入、源 File/Blob 和阶段存入本机 IndexedDB 或等价平台存储。Electron 也复用此草稿恢复方式,不把后续的原路径登记作为恢复前提。恢复数据只是未提交内容的本机副本,不是新的 SessionInputBlock 或永久本地附件协议。object URL 恢复时重新创建,token 每次从原账户/工作区能力获取,不写进记录。 + +持久保存是接管条件:配额不足、存储禁用、文件读取失败时不清空草稿,也不假装已经在后台发送。大文件本机保存需要进度/错误反馈;存储额度、过期策略必须明确,不自动删除仍在等待、失败或核对中的内容来腾空间。 + +切到其他 App 或锁屏可能暂停执行,返回后刷新真实状态。重启后,准备阶段显示“发送中断,可重试”,重新核对账户、工作区、机器和授权后由用户恢复;不自动执行旧消息。提交阶段先核对同 ID 的接受与交接结果,不能按“上传未完成”直接重发。已接受消息的投递义务可继续同步,不授权新建 turn 或重复执行 Agent。领取恢复记录也遵循单执行者约束。 + +取消或安全交接后,在底层操作及预览均不再使用时释放待发送记录的源 Blob 和 object URL;已提交附件的存储与清理继续遵循现有传输生命周期,不由草稿清理删除。取消上传仅承诺不提交这条消息,不保证远端已经接收的临时字节立即物理删除;使用已有 abort/清理能力,不擅自新增后端删除协议。 + +存储被系统驱逐、磁盘损坏、用户清数据或强制杀进程均超出无条件恢复保证。恢复不能在注销后向另一个账户显示内容。隐私清理与未决提交的处理须提供明确用户选择。 + +## 10. 验收条件 + +下面是实现必须通过的行为检查;不是本次已通过的产品测试。使用合成文件、可控 Promise、故障注入和真实出口观察,不使用真实用户附件或靠 sleep 竞争。 + +| ID | 触发 | 可观察结果 | +| --- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| A01 | 选择、粘贴、拖入图片/文件,尚未点击发送 | 无附件上传、全量 hash 或本机 handoff 调用;校验和本地预览可用 | +| A02 | 接管后从 A 切到 B,再让 A 准备完成 | 只提交到 A;B 草稿/配置/导航不变;新建会话有可返回入口 | +| A03 | 两附件仅一个成功,或上传 100% 但仍校验 | 不提交 history、queue 或 dispatch;失败文件不被跳过 | +| A04 | 双击发送、重复重试、取消后旧回调成功 | 同任务不重复接管/提交;已取消任务不复活 | +| A05 | A 带文件失败,后发 B 纯文本;另一个会话 C 正常 | B 等待 A,C 可推进;取消 A 后 B 才能交接 | +| A06 | 接管后修改 Role/模型/权限/MCP/文字 | 原消息仍用冻结配置和正确 spans;权限撤销则阻止提交而非改配置兜底 | +| A07 | 新对话添加附件后离开 landing,再返回、发送并切去另一新草稿 | 原文字/附件/配置可恢复;接管后使用原 session ID;完成不清除新草稿 | +| A08 | 已有对话 A 添加附件,切到 B 并返回;发送 A 后继续编辑下一条 | A/B 草稿隔离;A 上传后不清除下一条输入;不要求 A 输入组件持续挂载 | +| A09 | 在新对话和继续对话中发送纯图片、纯文件、混合附件、文字加附件 | 都能接管;附件类型、数量、顺序及文本/引用完整,全部就绪后才提交 | +| A10 | 两类入口中发送前移除/替换附件,或改变目标;遇到空文件/超限 | 不自动传输;只用最终草稿;错误可见,不静默漏附件;释放未使用预览 | +| A11 | 新对话首条附件失败后重试/取消;已有会话传输失败后重试/取消 | 两者均保留完整内容,重试只处理失败附件;新会话不重复创建,继续对话不停止 Agent | +| A12 | 新对话预热已启动,发送接管后 landing 卸载;或取消待发送 | lease 显式移交或取消;不因卸载丢任务、重复启动或取消其他草稿的预热 | +| A13 | writer 已写但回执丢失、queue 提升、首次 meta/history 部分成功 | 原 ID 核对恢复;只有一条逻辑消息/会话;不能确定时停止重发 | +| A14 | writer 接受后、持久化或 dispatch 交接前故障 | 恢复记录尚在;不把本地接受显示成 Daemon 收到 | +| A15 | guide 的目标 turn 在准备期间结束,或已应用 steer 的 ACK 丢失 | 未尝试的前者改为后续排队;后者核对原结果,不重复普通执行 | +| A16 | 浏览器刷新/关闭,Electron 子窗口关闭/quit/更新;另窗口仍有任务 | 保护覆盖正确范围;取消退出后 CLI/relay 可用;hide 可继续 | +| A17 | 存储配额失败、重启、移动壳回收、多窗口同时恢复 | 保存失败保留草稿;中断可恢复;同一记录一个执行者,未知结果先核对 | +| A18 | 两类入口在既有云端上传/本机 handoff 路径发送,包含 fallback 和旧 local 数据 | 仅传输时机改变;返回原有附件类型,保留既有 fallback/backfill、物化及图像语义;不新增 cloud capability | + +A01–A06、A09–A11、A13–A18 的适用场景必须同时在新对话和已有对话继续发送上验收,并覆盖桌面与移动布局,不能只验证共用 helper。A07/A12 专验新对话,A08 专验继续对话。 + +[有限模型](models/session-files.model.ts)只检查声明域内的草稿发送门槛、就绪、顺序和状态决策,不能证明磁盘持久化、浏览器生命周期或真实 Agent 行为。最终验收须包括 Electron 实包、浏览器、原生移动壳与代表性适配器;公开仓库的组件测试不能代替私有壳/服务集成验收。 + +## 11. Effect TS 落地方案与实施顺序 + +### 11.1 使用范围与模块边界 + +建议使用仓库已锁定的 **Effect 3.18.4** 管理待发送任务。React/Jotai 保留草稿编辑和视图订阅;接管后的异步逻辑进入一个不依赖组件挂载的 `PendingSendService`。本节是拟采用的实现方案,尚未接入产品。 + +| 耦合模块 | 当前生命周期 / 依赖 | 接入后的责任 | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| 输入框与 `useComposerSubmission` | 提交 token 随挂载作用域结束,还管理防双击、键盘和焦点 | 保留 UI token,只管快照保存/接管;后台任务归 workspace;完成只清原草稿,后台结果不夺焦点 | +| landing 与子对话 draft | 预留 ID、tab 别名、父会话参数、草稿清理和导航绑在创建回调中 | 分开待发送入口与真实 meta;冻结父关系/目标,按 ID 合并占位;导航失败不能删除已写会话 | +| `useSessionPreparation` / CLI preparation service | hook 的 debounce/idle timer 与取消 RPC;CLI 有硬 TTL、兼容检查及资源 claim | 前端只拥有预热 lease 控制,不拥有 ACP 本体;首版在有附件的接管时释放预热,保留可靠取消收尾;实际发送可正常冷启动 | +| 文件/图片传输、Electron handoff | hash Worker、XHR、重试、main 临时文件、CLI blob 各有所有者 | 准备 Scope 管可取消客户端资源;不可取消 IPC 保留原始在途所有权,main/CLI 仍清理自己创建的资源 | +| 配置、MCP、Role、授权和计费能力 | 目前从多个 hook/组件闭包读取;部分值在等待中会变 | 冻结用户输入/配置,提交时复查资格;token/路由动态获取,自动 resume 指针重新核对,不能冻结成旧 ACP 身份 | +| history/queue writer | store 借用有 finally;创建 meta/history 并行;dispatch 参数目前未在 writer 内执行 | 沿用唯一 writer,提取无 UI 提交边界;固定 ID、部分成功核对、本地 flush、dispatch/queue 激活信息必须一起覆盖 | +| 会话 store / room sync / 缓存与预取 | store 引用和 sync lease 分开;最后引用释放后缓存才 dispose/unload | 写入/核对时短借 store,同步时另借 sync lease;只释放自己的借用,不直接 dispose 共享 store;附件准备/离线等待不保活完整历史 | +| `requestSessionDispatch` / CLI watcher / history gate | RPC 带完整输入并 ACK 暂存;正文历史继续同步,CLI 自己串行执行 | workspace 拥有投递收尾;ACK、持久化、同步、执行分别记录,保留既有 CLI 去重与 history gate,不让 renderer Scope 管 Agent 生命周期 | +| direct/queue/guide 与其他消息入口 | presence、未完成历史、直接提交锁、队列编辑/提升共同决定行为 | 所有普通新消息入口共用同会话提交资格;短借最新状态决定路由;已有 queue 编辑、Stop、编辑重发保留各自契约 | +| 视觉批注、运行偏好、统计与列表状态 | `accepted` 回调会标批注已提交、记录最近运行、清引用及滚动 | 草稿清理在接管后;提交类副作用在真实提交证据后;失败不重发已接受消息;进度独立于 Agent presence | +| workspace、账户、窗口和退出 | token 可在原 runtime 更新;窗口 repo/cursor 各有 namespace;退出会释放 transport/CLI | 同一 runtime 的 token 刷新不重建服务;身份/拓扑切换走 guard;跨窗口接管必须核对原持久化副本;服务先收尾再释放依赖 | + +已有重连循环使用 Effect 的时钟/fiber,文件索引缓存使用 Scope,可参考依赖注入与异步释放方式。它们仍有 Promise 和手工状态,不能据此认定已有完整的结构化任务管理。本 PR 不顺带重写重连、文件缓存或整个 workspace runtime。 + +### 11.2 明确的生命周期树 + +以下是拟采用的资源归属;持久恢复记录不是 Scope 内的临时资源。 + +```text +workspace runtime 的一代实例 +└─ Send 服务的 ManagedRuntime / 服务 Scope + ├─ 消息准备任务 → 本次尝试 Scope(hash、上传、进度、等待) + ├─ 消息提交任务 → 短暂 writer/store 借用 + ├─ 提交后的投递尝试 → store/sync lease、RPC、结果核对 + └─ 准备/IPC 晚到结果的必要收尾 + +React 输入框:只拥有短接管和焦点 token +CLI Agent / backfill:各自运行,不是 renderer Scope 的子任务 +持久恢复记录:任务暂停或进程结束后仍需存在 +``` + +通过 `Layer.scoped` 组装服务及可注入的存储/传输/提交依赖,由 workspace 持有一个 `ManagedRuntime`。接管命令返回后,任务显式 `forkIn` 到服务持有的 Scope;不能挂在马上结束的按钮调用 fiber 上,也不使用没有该所有者约束的 `forkDaemon`。Scope 按一起停止/释放的资源组划分,不是每个函数一个 Scope;准备结束不能带走已登记的投递任务。失败或离线暂停时内容留在存储里,尽量不靠永不结束的 fiber/已打开 store 保活。依赖采用存储、传输、提交三个边界即可,不为每个 helper 增加独立 Layer,也不为每条消息建 ManagedRuntime。 + +`acquireRelease` / finalizer 负责该次任务拥有的 Worker、监听器、会话资源 lease 等。预览 URL 由实际预览持有者释放;恢复 Blob、已成功附件和已写消息按第 9 节处理,不能因 Scope 关闭一律删除。普通失败只结束所属任务,不终止整个服务或另一个会话。 + +### 11.3 跨模块交接的具体规则 + +**UI 接管与真实提交分开。** 服务对输入框返回的是“已保存并接管”,不是“已写 history”。`useComposerSubmission` 的锁、移动端立即收键盘、桌面一次性焦点交接仍按原 UI 契约工作;本 Spec 不把它们拖长到上传完成。保存失败保留草稿,移动端不自动重新聚焦。视觉批注的 `mark-submitted`、最近运行配置及提交统计移到真实接受/交接事件,不复用 `onSendMessage === true` 的旧含义。补记失败保留诊断/必要的补记任务,不能重发消息;清理和补记不得覆盖后来编辑的草稿或批注。 + +**新建不止 landing。** `draft-session-chat-interface` / `session-detail.handleSendDraft` 也必须接入。冻结 `parentSessionId`、项目、机器、子 tab ID 和未来 session ID;上传中离开父会话不改变归属。现有 tab 升级别名须能在返回时从待发送记录重建。真实创建失败分辨确定未写和部分成功;不能沿用一个大 catch 在导航/统计失败时删除已接受的子会话。显式关闭/删除承载待发送内容的 draft 或 Side Chat 要处理对应记录,普通隐藏面板仍不取消任务。 + +**预热是可丢弃优化。** 当前 `handoffToSession()` 仅清掉 hook 的引用/计时器并返回布尔值,不是一个可跨长时间上传持有的资源句柄。首版在附件接管时停止该草稿的预热调度,并将“等待 start 结束后取消准确 preparationId”的收尾交给服务;不伪装成已经移交给真实会话,也不延长 CLI TTL。预热取消失败不能丢消息;CLI 的过期/兼容 claim 仍有效,必要时正常冷启动。无附件路径保留原有有效预热复用。若以后保留上传期间预热,需单独定义显式 lease 移交及到期,不通过捕获 hook 闭包实现。 + +**冻结选择,重新读取运行事实。** 用户文字、附件、Role 版本、模型/权限、MCP(含 `[]`)、工具开关、项目与引导目标固定;读取 token、机器路由、授权/计费资格、归档/删除状态和当前 busy 状态使用 runtime 注入的现有能力。离线/授权未知沿用现有规则,不能一律拒绝或冒充允许;不能因 token 刷新重传成功附件。自动 `acpSessionId/resume` 是运行时指针,提交前必须核对当前会话;显式 fork/编辑重发目标仍按其原契约,不能作为普通延迟消息随意改写。 + +**所有普通新消息共享提交入口。** 继续对话中的纯文本、计划执行和工具栏生成的普通 prompt 不能绕过同会话 FIFO。提取现有 route resolver,并保留“presence 暂缺但历史尚未结束则 queue”的保守规则;组件内 direct 锁不足以协调多个面板。已交接到 Daemon queue 的编辑、重排、提升和 Stop 不归附件准备 Scope 管;编辑重发的历史改写屏障不能被普通发送绕过。guide 结果改用明确分类:已应用、权威确认未提交、未知;现有 `no-active-turn` 在原协议保证未提交时可沿用同 ID 转普通发送,不能把其他 `false`/超时当成未应用。 + +**后台投递单独有所有者。** 现有 `requestSessionDispatch` 同时启动同步等待、RPC 和 meta 指针更新;writer 的 `dispatch` 参数并未完成这些动作。将它们抽成提交适配器及 workspace 级投递任务,保留 RPC 加速而不等待远端才清输入框。RPC 传完整输入,CLI 可先执行,但 ACK 只证明暂存/去重接收,不能证明 history/meta 持久化;继续同步以维持 CLI `TurnHistoryGate` 的历史顺序,不能等 Agent 整轮结束才放行下一条本地提交。 + +拟采用的本机交接边界为:同 ID 的 history 或 queue、所需 meta/激活信息均写入并经 repo 本地持久化屏障确认,同时投递义务已可恢复地登记。然后准备任务可结束,原大 Blob 在不再使用时可释放;同一恢复记录可压缩为较小的投递记录,直到必要的目标同步/接收证据足以结束这项义务。不能关闭上传 Scope 时一起中断投递,或先删恢复记录再启动一个无人持有的 Promise。RPC 超时只影响核对,不重新 append;不为这一步新增另一套消息协议。 + +**借用 store 和借用同步连接是两回事。** 写入/核对通过现有 cache/writer 借用;同步使用它的独立 lease。finalizer 释放本次借用,不调用 `store.dispose()` / `repo.unloadDoc()` 抢走 UI 的资源。异步 acquire 晚到也须释放,不能被 `tryPromise` 中断后丢掉返回句柄;`acquireRelease` 的获取阶段默认不可中断,必须评估其等待边界,不能让未知时长的获取堵死退出。实际同步尝试持有必要 store;离线退避释放 store/连接,下次恢复再借。没有 UI 挂载也能投递,但不能每条上传各建 transport 或 Mirror。 + +当前 `waitUntilSynced(signal)` 在已 abort 时返回,选中 binding 为 detached 时也可返回;等待 transport ready 的部分还没有贯通信号。因此不能把 Promise resolve 当作“已同步”的收据。提交适配器需要区分本地持久化、目标 binding 已确认同步、已中断/未连接和结果未知,并处理晚到 join 的释放。复用 runtime 的目标路由与重连,不让每条消息新建重连循环。 + +**跨窗口恢复不是换个 Ref。** 当前 repo 与 cursor 按 window namespace 隔离。待发送记录可在存储域内协调,但接管其他窗口的未知提交时,新窗口的空副本不能证明未发送;须核对原持久副本或权威接受记录。正常 token 刷新不改变服务所有者;账户/工作区/本地运行设置导致的 runtime 或路由销毁,则保存检查点、撤销旧代次并按退出顺序收尾。归档/删除提交前重新检查,未知记录不能借恢复把已删除会话复活。 + +### 11.4 状态转换、取消与重试 + +服务内以不可变记录和统一的同步转换函数检查 runtime 代次、执行权、attempt 代次和当前阶段。可以使用 `Ref.modify` 融入 Effect,也可以封装普通变量;安全性来自规则与无 await 的检查/修改,不来自 Ref 这个名字。恢复写按记录串行并检查版本,进入真实 writer 前先保存固定身份和 submitting 检查点。跨窗口执行权另由平台协调。 + +准备成功、取消、重试、提交都走同一入口;取消赢得 `ready → canceled` 后,晚到 ready 不再提交;提交赢得 `ready → submitting` 后,结果只能确认/核对。等待前一条不占上传额度或 dispatch 锁;`Semaphore` 只限制实际在途传输,不包住整条消息的网络等待。 + +文件 hash/上传的既有 AbortSignal 接通 Effect,图片的 `postMultipartWithProgress` 补上 `xhr.abort()` 与监听释放。multipart 的 `void fetch(DELETE)` 改为有所有者且有时限的清理,保留原错误。Electron `File.arrayBuffer()` / IPC 不支持取消时,撤销提交资格但保留原 Promise 和实际在途额度直到结束;晚到结果只收尾,不提交或触发 cloud fallback。main 临时文件、CLI blob/backfill 继续归现有所有者,不能在草稿 finalizer 删除已交接附件。 + +校验、存储、授权、可重试传输、未知提交分别处理;中断不作为失败触发 fallback。保留现有分片重试为唯一重试层,若迁到 `Schedule` 同时删除原循环;不重试整条写入/steer。每个附件的成功结果独立记录,一个失败不杀掉其他消息。不把上传、IPC 或网络同步整体放入 `uninterruptible`;它不能把 CRDT、磁盘与 RPC 变成事务。 + +### 11.5 workspace 退出的收尾顺序 + +正常切工作区或退出账户,应在导航和 React 卸载前完成第 8 节确认。确认后:停止接管并撤销旧任务的提交资格 → 保存中断/未知状态 → 中断并等待可取消任务清理、核算未结束的原始 IPC → 等待记录落盘和服务 Scope 关闭 → 再销毁会话缓存、transport 和 repo。重复 dispose 返回同一个完成结果。 + +`RuntimeProvider` 的 React cleanup 不能让 React 等待异步收尾,所以 guard 和等待必须在主动离开流程;runtime 自身的异步 `dispose()` 仍确保内部资源关闭顺序。原始操作尚未结束时,正常离开继续等待或让用户留下;强制离开保留恢复记录,Electron main 继续负责已有 IPC 临时文件的最终清理,renderer 晚到回调不得访问旧 repo。超时不能报安全交接。强制杀进程/移动壳回收不保证 finalizer 执行,恢复依赖此前已持久保存的检查点,不能寄希望于退出时最后一次 flush。 + +### 11.6 分阶段接入与验收 + +按职责完成迁移,再改变上传时机。下面四个 PR 依次合并,每个都有可观察的完成条件;不按文件或页面切碎功能,也不以“先上线,出问题再补”为验收方式。 + +| PR | 完整交付的职责 | 用户行为与合并条件 | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1:抽出提交边界 | 从组件/hooks 提取无 UI 的提交入口,保留创建、继续发送与 guide 的各自适配;显式传入目标、输入和配置,React 保留焦点/导航。沿用既有类型和 writer,不先造通用任务框架。 | 保持现有上传时机、路由与副作用时机。用真实边界的行为测试比较消息内容、配置、创建/队列结果和失败后的草稿;覆盖新建、子会话、继续发送及其他普通消息来源。已有缺陷记录为反例及后续归属,不改写成正确行为,也不将旧 boolean 包装成持久化保证。 | +| 2:接管异步资源 | 在 workspace 引入唯一 Effect runtime,迁入上传、hash、图片处理、multipart 清理和 store/sync 借用。为调用者保留普通 Promise/取消/订阅接口;迁入的资源只有一个申请、重试与释放所有者。 | 仍然添加即传;本 PR 的行为改进限于明确取消与收尾。验收真实底层取消、晚到 IPC/句柄、不误销毁 UI 共用 store、重试停止及 workspace 关闭顺序。不能只证明 fiber 已停止。此时引入的资源必须在本 PR 完成退出清理。 | +| 3:接管提交与投递 | 让服务管理已经准备好的消息:固定身份、先登记恢复意图、串行写入/核对、区分未知结果、登记并执行独立投递。明确本地持久化和目标同步收据、原副本恢复来源;普通消息共用顺序入口,保留 direct/queue/guide 的业务规则。 | 上传时机仍不变;这是有明确范围的可靠性改造。验收部分写入、ACK 丢失、UI 卸载、离线、重启和跨窗口领取;未知结果不盲目重发,guide 的明确未提交单独处理。提交不等待远端同步或 Agent 执行结束;批注/配置/统计按真实接受阶段补记。新恢复记录的额度、保留、退出和恢复兼容性必须随本 PR 完成。 | +| 4:完整接入附件 draft | 新建、子会话和继续对话一起切换到发送后传输;保存完整输入后清草稿,接通待发送视图、进度、失败项重试、取消和包含纯文本的 FIFO,并落实预热释放与各端退出/恢复。 | 切换后才承诺本 Spec 的 draft 体验。文件保存额度/保留规则先确定,保存失败不清草稿;执行 A01–A18、下表 E01–E12 及对应平台验收。禁止仅 landing 可用就视为交付,也不把退出、失败与恢复留给后续修补。 | + +接入期间遵守以下边界: + +- **每项职责只有一个实际执行者。** 同一个消息/附件不同时由旧 hook 和新服务编排;迁移该职责的 PR 同时删除旧所有者。可比较纯计算结果和合成用例,不能在生产环境双跑上传、写历史或 dispatch。未知提交不自动 fallback 到旧发送路径。 +- **Effect 停留在异步服务内部。** 组件、Jotai 和纯 TS 状态逻辑不为接入而统一改写;Ref.modify 是可选实现。复用现有 writer、缓存、transport/reconnect 和 CLI,不同时重做协议、升级 Effect 主版本或接入本机零上传。锁定现有 3.18.4。 +- **每一步验证刚接管的真实边界。** 既看成功后的内容/状态,也用显式信号控制失败、取消、晚到与关闭;涉及持久化后增加真实存储与恢复验收。纯迁移比较行为及交互延迟/资源占用,发现明显退化先定位,不叠加下一阶段。测试数量和微型实验不能替代这些条件。 +- **切换以完整服务版本为单位。** 优先通过独立 PR 和内部构建验证,不长期保留两套实现。若确需临时开关,只能在 workspace 服务启动前选定;在途任务不换执行者,不能逐消息分流而破坏顺序。 + +回退也属于各 PR 的完成条件。尚未引入恢复记录的整理可在结束在途操作后回退;引入记录后,应先停止接管,完成或可靠保留在途任务,再回到能识别并处理这些记录的兼容版本。不能只回退代码到忽略记录的旧版本、删除未完成记录,或把结果未知的消息交给旧路径重新发送。记录兼容与原副本恢复尚未确定时,PR 3 不进入发布,PR 4 不提前启用。 + +以下检查随所属职责逐步加入,并在完整 draft 接入时一起验收: + +| ID | 确定性检查 | 必须观察的结果 | +| --- | ---------------------------------------------------------------- | -------------------------------------------------------------------------- | +| E01 | Send 返回、组件卸载后放行准备;随后关闭 workspace | 原任务继续;关闭等待清理,repo 销毁后无回调再访问 | +| E02 | 用显式信号排列 ready/取消/重试两种先后顺序 | 一个有效状态转换;旧 attempt 不复活,后续消息按 FIFO 放行 | +| E03 | 图片 XHR/hash 取消,以及不响应取消的 IPC 晚到 | 前者实际停止;后者不提交、不 fallback、不提前释放实际在途额度 | +| E04 | writer 已产生副作用,随后中断/丢失回执 | 恢复记录保留并核对原 ID,不能再次盲写 | +| E05 | 一个附件或一个会话失败,另一个成功 | 成功结果保留;其他会话继续,失败消息保持完整 | +| E06 | 重试等待中取消、异步清理阻塞、同时两窗口领取 | TestClock/显式信号控制;无迟到重试,关闭顺序正确,真实存储域只有一个执行者 | +| E07 | acquire 未返回时取消;UI 与发送同时借 store | 晚到句柄释放;结束发送只退自己的引用,UI store 仍有效 | +| E08 | 所有会话 UI 卸载、RPC 已 ACK,但 history/meta 同步断开 | 投递仍有所有者/恢复记录;不重复 append、不把 detached/abort 当同步成功 | +| E09 | 预热 start 晚到、上传超过原 lease 有效期、新 draft 再预热 | 旧 lease 按准确身份清理,不取消新草稿;正式发送可冷启动 | +| E10 | 上传中切换父 tab、ACP resume 更新、撤权/归档、批注再编辑 | 原目标与用户配置不变;运行时指针核对,失去资格不提交,旧补记不覆盖新内容 | +| E11 | 子会话已写后导航失败;已应用 steer ACK 丢失;权威 no-active-turn | 前者保留同 ID 会话;未知不重复执行;明确未提交才按原契约转换 | +| E12 | 窗口 B 领取 A 的未知记录,B 的 repo 尚无该 turn | 保持核对,不以当前副本空白断言未发送;重启继续已有投递义务而不新建 turn | + +测试使用 `Deferred`、显式 Promise 闸门和 `TestClock` 控制步骤;不靠真实 sleep 或固定次数的 microtask 刷新碰运气。[Effect 实验](models/session-files.effect-probe.mjs)验证了 Promise 中断限制、signal 桥接、服务 Scope 收尾和代次检查,共六个边界用例通过,其中两项运行当前 `store-ref-tracker.ts` 与合成 store,验证取消中晚到获取的释放、以及不误销毁 UI 的共享资源;它不是 E01–E12 或产品集成已通过的声明。 + +原路径引用 wire/IPC、文件授权登记、图片本机适配和旧 Daemon 兼容继续交给[后续 PR](local-attachment-references.zh.md),不是 Effect 接入的前置任务。 + +## 12. 证据与验证状态 + +源码基线:`8c429a890037c5b21855ce7ef9f59e3677c25a38`。本文与[决策记录](../.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md)是设计产物;本次修订落实 PR 范围拆分与 Effect 生命周期方案,未修改产品行为,未运行真实设备发送验收。 + +- 新对话:`packages/components/src/components/chat/chat-landing.tsx`、`hooks/use-chat-landing-{file-draft,image-draft,draft-session}.ts`。 +- 已有对话:`packages/components/src/components/sessions/session-chat-input-area.tsx`、`session-chat-interface.tsx`。 +- 共用边界:`packages/components/src/lib/{electron-session-file-sender,session-file-upload,session-image-upload,multipart-upload}.ts`、`hooks/use-session-actions.ts`、`providers/{runtime-provider.tsx,workspace-writer-impl.ts}`;`packages/shared/src/{history-writer,session-input,message-schemas}.ts`、`session-data/loro.ts`。 +- 退出生命周期:`apps/electron/src/main/index.ts` 与 `main/window.ts`。 +- 当前实现解释:[CLI 附件链路](../.agents/docs/cli-lib-session-files.md)、[输入和运行配置](../.agents/docs/sessions-run-config.md)。已有决定:[工作区草稿隔离](../.agents/notes/implemented/bug-fix/2026-09-11-workspace-window-composer-drafts.zh.md)、[上下文复制及撤回的文本附件实验](../.agents/notes/implemented/feature/2026-09-09-conversation-context-fallback.zh.md)。 +- 平台资料:[Electron 应用退出与更新顺序](https://www.electronjs.org/docs/latest/api/app#event-before-quit)、[浏览器 beforeunload 限制](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event)。 + +- Effect 现状:`pnpm-workspace.yaml` 锁定 3.18.4;`src/providers/local-reconnect-loop.ts`、`src/lib/code-collab-file-index-cache.ts` 与 `tests/local-reconnect-loop.test.ts` 提供现有局部用例;`src/providers/create-workspace-runtime.ts` 拥有异步资源销毁。路径均在 `packages/components` 下(workspace 配置除外)。 +- Effect 官方 3.18.4 API 源码:[Scope/fiber 归属](https://github.com/Effect-TS/effect/blob/effect%403.18.4/packages/effect/src/Effect.ts)、[Promise 与取消信号](https://github.com/Effect-TS/effect/blob/effect%403.18.4/packages/effect/src/Effect.ts)、[ManagedRuntime](https://github.com/Effect-TS/effect/blob/effect%403.18.4/packages/effect/src/ManagedRuntime.ts)、[TestClock](https://github.com/Effect-TS/effect/blob/effect%403.18.4/packages/effect/src/TestClock.ts)。实际实验使用临时安装的 3.18.4,未修改仓库依赖。 + +- 本轮生命周期证据:`packages/components/src/components/chat/submission/use-composer-submission.ts:44`;`hooks/use-session-preparation.ts:113`;`components/sessions/{session-detail.tsx:1979,session-chat-interface.tsx:2394}`;`providers/{workspace-writer-impl.ts:76,create-workspace-runtime.ts:3854,store-ref-tracker.ts:212}`(后四组前缀均为 `packages/components/src/`)。CLI 边界:`apps/cli/src/session/{session-preparation-service.ts:142,session-dispatch-watcher.ts:681,turn-history-gate.ts:5}`。 From 4fe64c0e7f4fe27a0d40fee03d473694088f34dc Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 14 Sep 2026 22:41:01 +0800 Subject: [PATCH 2/3] docs: link attachment submission stack base Model: gpt-6 --- .../architecture/2026-09-14-deferred-attachment-send.md | 2 ++ .../architecture/2026-09-14-deferred-attachment-send.zh.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md index 21da6f779..288f9685f 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md @@ -67,6 +67,8 @@ Remove the previous owner when migrating a responsibility. Never run real upload ## Stack implementation status +PR 1: [#705](https://github.com/LodyAI/Lody/pull/705) — `refactor/attachment-submission-boundary` → `main`. + Layer 1 extracts `lib/session-submission.ts` from `use-session-actions.ts` and keeps React bindings for billing admission, analytics, and observable atoms. Creation, initial history, continuation, dispatch, and guide still use the same diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md index 7708c17b9..30dcf5c43 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md @@ -67,6 +67,8 @@ Translation: current ## PR 栈实施状态 +PR 1: [#705](https://github.com/LodyAI/Lody/pull/705) — `refactor/attachment-submission-boundary` → `main`. + 第一层从 `use-session-actions.ts` 提取 `lib/session-submission.ts`,React 保留额度准入、统计和 atom 观察绑定。创建、首条历史、继续发送、dispatch 与 guide 仍使用同一 writer 和路由,上传时机与接受行为不变;这一层不启用持久 From 9ceb53e0f3b6484c039a25e46e60ccbb07020343 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 16 Sep 2026 15:14:38 +0800 Subject: [PATCH 3/3] fix: retain steer recovery ownership Model: gpt-6 --- .../components/src/lib/session-submission.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/components/src/lib/session-submission.ts b/packages/components/src/lib/session-submission.ts index 1b6e5a184..07f9e436f 100644 --- a/packages/components/src/lib/session-submission.ts +++ b/packages/components/src/lib/session-submission.ts @@ -398,21 +398,35 @@ export function createSessionSubmission(ports: SessionSubmissionPorts) { if (!entry || !inputConfig || !userId || !machineId) { return false; } - const response = await runtime.requestSessionSteer(machineId, { + const steerRequest = { sessionId, expectedTurnId, userTurnId, userId, timestamp: entry.timestamp, inputConfig, - }); + }; + let response = await runtime.requestSessionSteer(machineId, steerRequest); + if (response?.recoveryOwned && response.disposition === 'promotion-failed') { + // The CLI owns recovery for this verdict. Retry through that same owner; + // a renderer-side promotion could overwrite a newer activation pointer. + response = await runtime.requestSessionSteer(machineId, steerRequest); + if ( + !response || + response.disposition === 'promotion-failed' || + response.disposition === 'error' + ) { + throw new Error(response?.error ?? 'Could not recover the undelivered guidance'); + } + } if (response?.applied) { onRpcDelivered(sessionId, userTurnId); return true; } if ( - response?.disposition === 'no-active-turn' || - response?.disposition === 'promotion-failed' + !response?.recoveryOwned && + (response?.disposition === 'no-active-turn' || + response?.disposition === 'promotion-failed') ) { // The CLI proved the steer was not applied, either before submission // or from the adapter's final verdict. Reuse the same user turn as an