Skip to content

feat(library): 暂存未入库原件并在确认归档后释放 - #4730

Merged
MagicLizi merged 27 commits into
makecindy:mainfrom
PraiseZhu:feat/mivo-library-staging-20260920
Sep 24, 2026
Merged

MagicLizi merged 27 commits into
makecindy:mainfrom
PraiseZhu:feat/mivo-library-staging-20260920

Conversation

@PraiseZhu

@PraiseZhu PraiseZhu commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

这次改了什么

摘要

Mivo 导入的原件在写入 Library 前若只保存在插件的非持久 Electron 分区,整应用重启后可能无法恢复。本次在 Host 增加按账号与插件隔离的上传暂存,复用 LibraryVault 的文件操作;原件就位前先耐久记录完整任务身份,完整数据与恢复记录持久化后才返回耐久回执,归档完成后核验 Library 正本再释放。清单已存在时逐字段校验后幂等恢复,重建宿主实例时按可信任务记录校验原件并补完归档记录。同 epoch 进程重启后,磁盘 ownerScopeKey 的 generation 与 live key 对齐为 durable owner,避免误报 manifest 字段非法。

变更类型

  • feat 新功能
  • fix 缺陷修复
  • refactor / perf 重构或性能优化
  • docs / test / chore 文档、测试或工程维护
  • 其他:无

范围

  • 关联 Issue / 需求:Mivo 全来源图片归档与 Agent 像素读取;依赖 fix(library): 自定义媒体库暂时不可用时不重建空库 #4728。
  • 本 PR 包含:现有 library-request 上的 staging 操作、能力查询、恢复与额度核验、匹配当前 Library 的释放事务、故障回归与作者文档;重启后 durable owner 读取兼容。
  • 明确不包含:新 UI、提示、按钮、设置、系统授权触发路径、第二媒体库、媒体库迁移或自动清理、Agent 新渲染协议。
  • 用户可见变化:无新增交互。Library 仍是唯一媒体库;暂存不产生 imageRef 或 archived 状态。
  • 是否存在 breaking change:无;capabilities.version 保持 1,旧插件可忽略新增能力,不要求重装或重授权。
  • 分支依赖:上游以 main 为目标;当前 head 已普通 merge fix(library): 自定义媒体库暂时不可用时不重建空库 #4728 d7c68cd96、cherry-pick 配额 P1,并在 6445 之上修重启 recovery。不是无重叠的独立 diff。

UI 变化

不涉及。

  • 引用的设计规范:不涉及,只有 Host 后台存储与协议文档。

怎么验证的

自动验证

当前 head:7faa6c91aca9fc983bc65a082ada2d78a27b1bb1(祖先 6445e75671eed381ed5ec655c50eb054d21c67ef)。旧 SHA 绿不能复用到本 head。

隔离树 Darwin @ 7faa:
pnpm --filter desktop exec vitest run src/main/cindy-brain/__tests__/libraryStaging.test.ts
24 passed(含真实磁盘 local:local-v1:2 → 新 Store :0 list/read)
pnpm --filter desktop run typecheck
exit 0
pnpm check:dco
20 signed + 3 merge exempt(相对当时 origin/main ed4bef88)

独立差额审 APPROVE @ 7faa,24/24 独立复跑。不是 GitHub Approve。

6445 真实 Host 重启 E2E:FAIL(staging.list manifest 字段非法);失败材料保留,不用 6445/单测替代 7faa。7faa 独立实机(2026-09-21-ai-ui-4730-e2e.md):首次 begin/chunk/commit/list/read PASS;同 epoch 重启 list/read 同一 durable PASS;0a386×7faa lostACK PASS;Library.write ACK SHA 7adec1fb81fa4fd1f57f6be013191b402254627a7ecf3b1c4a663d978b6e2d97、generation 0、identity 绑定 PASS;release 首次 true / 重复 false → list 移除 → canonical read 同 hash PASS。容量/idle 压力 未实机触发,仅单测。

云端 client-ci https://github.com/makecindy/cindy/actions/runs/35528298590 @ 7faa completed success。

手工验证

6445 真实 Host 重启 recovery FAIL 已取证保留。7faa 真实 Host:重启 recovery + ACK/release 后半链已过(见 2026-09-21-ai-ui-4730-e2e.md)。容量/idle 未实机。

未执行的验证

  • 容量/idle 实机压力
  • 真实模型消费像素 / 断电 / 拔盘
  • Host 复验 已完成,不列缺失

风险

风险分类

  • 无已知风险
  • SQLite / migration
  • system prompt
  • 协议兼容
  • 权限 / 安全 / 用户数据
  • 存量插件兼容(批准状态 / 指纹 / manifest 校验 / 安装布局 / 包格式)
  • 原生层 / fingerprint / OTA
  • 跨平台差异
  • 其他:无

影响与回滚

  • 影响范围:声明并启用 library 能力的插件可使用新增暂存操作;按 owner 与 ghost 固定根隔离。暂存有硬容量和并发上限,唯一耐久原件不按 TTL 删除。新任务先保存可信 intent 再就位原件;历史无可信任务记录的孤儿文件仍保留并计入额度,不猜归属或自动删除。Library 不可用与权限失败不当成空库。磁盘 recovery 按 durable owner(mode:id)比较,live requireOwner 仍用完整 generation key。路径命中插件基座白名单;fix(library): 自定义媒体库暂时不可用时不重建空库 #4728 批准不得复用。
  • 存量插件影响:无。现有 Library 操作与能力 envelope 版本保持兼容;新插件仍须能力探测并保留源文件到实际耐久回执。
  • 跨平台差异:POSIX 目录 fsync 失败不能返回耐久回执;Windows 目录 fsync 能力限制会如实记载,不声称与 POSIX 断电保证相同。
  • 回滚 / 降级方式:回滚代码时保留 owner-scoped 暂存目录与未释放原件;旧版不能恢复新增暂存任务,不能通过删目录回滚。插件在不支持能力时不得把缺失能力当成功。
  • 审查门:放行人须对本 PR 当前 head 7faa6c9 具名 Approve。本地/差额审不能替代。fork 自动审查排除规则不变,保持 Draft。不 @ 人、不代发 Approve。标签 touches:plugin-base 仍缺(fork 无 AddLabels 权限)。

提交前检查

  • 已 review 完整 diff(6445 本地审 APPROVE;7faa 差额审 APPROVE,无阻断。不代表 GitHub Approve)
  • 每个 commit 都带 DCO 签名(git commit -s,见 DCO)
  • UI 改动已在「UI 变化」注明引用的设计规范章节(不涉及 UI 则跳过)
  • 未提交凭证、令牌或授权文件
  • 已核对受影响的文档,行为变化涉及的旧结论已同步修订(不涉及则无需修改文档)
  • 已确认测试结果或说明未执行原因(staging 24 绿;typecheck/DCO 过;client-ci 35528298590 绿;6445 Host 重启 FAIL 保留;7faa 重启 recovery+ACK/release PASS;容量/idle 仅单测)

Re-resolve the live binding on each request so a missing custom root stays unavailable instead of recreating an empty library. Same-disk restore continues through existing open/status; a rebuilt path remains binding-moved.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Unavailable cached custom sessions are not required to keep the live identity. Restore still uses the original disk object; a replaced directory stays binding-moved.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Host H1 is a thin LibraryVault adapter for plugin upload staging. It keeps
owner×ghost isolation, hard quota, tombstones, and ACK-gated release without
a second media library, new UI, or TCC path.

Quota counts durable, orphan blobs, closed tmp, and in-memory uploads without
overlap. abort of a commitPending blob is STREAM_INVALID so the landed original
stays reserved until ACK. disposeAll/bind/unbind/relocate/delete drain in-flight
release behind relocating. First-minted staging roots also fsync parent directory
entries; Windows still reports fsynced:false.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Same-instance retry after a durable manifest write plus failed journal
fsync no longer dies on ifNotExists ALREADY_EXISTS. Matching identity is
adopted; conflicting identity is rejected without overwrite.

New originals write intent.json before blob rename. A new Store recovers
only from that trusted identity after hash/bytes checks, then installs
manifest+dirsync. Incomplete or conflicting intent fails closed and keeps
the source. Historical orphans without intent stay billed in isolation
and are not TTL-deleted.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
A stale custom resolveLibraryRoot could still reach vault.open, which
recursively created the missing user parent and reported ready empty.
Custom open now refuses to recreate a vanished parent (disk-missing);
default first-time create still mkdir. keep files remain in the renamed
directory and are not treated as deleted.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
…rift

Stale resolve plus a same-path new inode is binding-moved at the mkdir
boundary. disk-missing after a granted extraDir actually revokes the slot.
Auto-open failures latch drift so status-only restore works. Keep files
stay in the renamed-away directory.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Custom skeleton mkdir is non-recursive so a vanished user parent cannot
be rebuilt as an empty library. Default roots still create recursively.
Keep files stay in the renamed-away directory.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
After the last successful inspect, hold the parent directory and compare
path identity after mkdir(root). A same-path new inode fails closed as
binding-moved; an empty root created on the replacement is rolled back
and keep files stay parked. Node has no mkdirat; this is not atomic.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Darwin mkdirat/openat (SYS_mkdirat=475) and Linux /proc/self/fd keep
ghostId/meta on the granted parent object. Missing helper fail-closed
before mutation; no path mkdir fallback. Windows custom init unsupported.
Default roots still mkdir recursively. Keep files stay parked.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
After the held parent fd is closed, custom open does not write, rename,
or unlink under this.root. Sweep unlink and persistUsage are skipped;
read-only usage scan remains. Default roots unchanged. Later library
writes still persist. Isolated D: no replacement usage.json.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Open an already-initialized custom library from the held parent fd
without mkdir. Missing structure on platforms without create stays
fail-closed. Teardown only the captured session. Reuse a valid usage
ledger; scan only when it is missing or corrupt. Custom tmp is not
path-unlinked.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Existing libraries open from the inherited parent handle with NtCreateFile
FILE_OPEN+ReadFile. FileIndex 0 and missing helpers fail closed. Create
stays unsupported on Windows. Linux may unlink only uuid.tmp/.stream
staging leftovers via /proc/self/fd. No path mkdir fallback.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Pretty-printed and compact meta/usage payloads both parse. Malformed
JSON or failed field checks stay CORRUPT. Do not split on the first
newline of the helper stdout.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Age and an empty streams map cannot prove a fsynced .tmp/.stream is
garbage after a crash before rename. Open no longer unlinks them.
P2_tmp stays unresolved. Unknown names are kept.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Create dirs with FILE_OPEN_IF and meta.json with FILE_CREATE from the
inherited parent handle. No path mkdir, no rollback rmdir, no new
native dependency. Existing FILE_OPEN stays read-only. Missing helper
still fails closed before mutation.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Record list/resolver/control monotonic marks and dump them on waitFor
failure so Linux CI can discriminate missed 500ms poll vs unpublished
system-deny. Keep the original assertion and timeout. Mac is syntax
sanity only.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
FILE_CREATE collision is exists only after FILE_OPEN of a regular file.
Reparse and unknown collisions fail closed. CreateRelative always
releases handles it still owns, including the catch path.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Keep 94f36c0 as an ancestor. Resolve the only content conflict in
pi-startsession-cleanup onto main mockRunDiscovery (list+scan) while
keeping the 132dc waitFor diagnostics and original system-deny
assertion. Library files are the automatic merge result only.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Consume ed4bef8. Keep 94f as ancestor. Re-resolve pi-startsession-cleanup onto mockRunDiscovery (list+scan) plus 132dc diagnostics, taking main 3s durable-approval waitFor timeout. No extra Library hand edits.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Keep 5c2 as first parent and 4728 d7c as ancestor. Resolve slot/vault by retaining staging dispatch/fsync/quota and custom dirfd fail-closed open. Tests auto-merged.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
…serve

Idle incomplete uploads now yield BUSY/QUOTA slots after timeout.
Concurrent begin reservations are checked against reserveBytes.
Durables and commitPending originals are never TTL-deleted.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Refresh closed tmp bytes after idle abort before quota admission.
Disk reserve subtracts only unwritten stream remainder; commitPending
does not double-count landed blobs. Durables stay off TTL.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Restart resets app-session generation so live ownerScopeKey
(local:id:2 vs local:id:0) no longer matches the on-disk manifest.
Treat generation as a process lease, not durable owner identity.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
@PraiseZhu
PraiseZhu marked this pull request as ready for review September 20, 2026 23:31
@PraiseZhu
PraiseZhu requested a review from a team as a code owner September 20, 2026 23:31
@greptile-apps

greptile-apps Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

该 PR 暂不适合合并,因为并发正本变更可使 release 删除唯一暂存原件,自定义库子目录暂时缺失时也可能被误建为空库。

Findings

  1. P1 正本核验存在竞态 ▶
  2. P1 缺失目录被重建 ▶
  3. P2 暂存实例未被释放 ▶
Fix with agent prompt
### Issue 1
apps/desktop/src/main/cindy-brain/librarySlot.ts:720
`staging.release` 只在这里对 Library 正本做一次哈希核验,之后直到暂存原件被删除都没有锁住或再次核验正本。插件若在此期间并发执行普通 `delete`、`rename` 或覆盖 `write`,正本可能已被移除或替换,但 session 和 epoch 检查仍会通过,最终删除暂存中的唯一可信原件。需要让正本核验与暂存清理互斥,或在不可逆清理前重新核验并阻止并发变更。

### Issue 2
apps/desktop/src/main/cindy-brain/libraryVault.ts:545-550
当自定义 Library 的父目录仍存在、但 `<父目录>/<ghostId>` 暂时消失时,`MISSING` 会直接进入初始化流程并创建新的空库。子目录挂载点临时掉线、既有库被外部移动或目录暂时不可见时,原数据因此被隐藏,后续写入还可能落入替代空库。只有明确的首次建库状态才能初始化;既有 binding 或 session 对应的根缺失时应返回 `disk-missing`。

### Issue 3
apps/desktop/src/main/cindy-brain/librarySlot.ts:886-890
`disposeAll` 只遍历 Library sessions 和在途 release,没有处理 `stagingStores`。由于 store 的 key 包含会递增的完整 owner scope generation,每次账号 generation 变化都会创建新实例,而旧 store、未完成上传、Vault 流及文件句柄继续常驻。反复切换账号会持续累积这些对象;这是非阻断问题,但应在 owner 收口时释放并移除旧 staging stores。

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary

本 PR 为插件 Library 增加按 owner 与插件隔离的耐久暂存协议,并扩展自定义 Library 根的目录身份防护、恢复、配额和释放事务。主要变更包括:

  • 新增 staging begin/chunk/commit/list/read/release/abort 协议及能力清单。
  • 使用 intent、manifest 与 tombstone 支持崩溃恢复和幂等释放。
  • 在 release 前核验当前 Library 正本、epoch 与 owner。
  • 新增跨平台 held-directory-fd helper,避免自定义父目录替换时误建库。
  • 当前仍有正本核验后的并发删除窗口、自定义子目录缺失时误建空库,以及账号切换后 staging store 未释放的问题。
Diagram
sequenceDiagram
  participant P as 插件
  participant S as Staging Store
  participant L as Library 正本
  P->>S: begin / chunk / commit
  S-->>P: durable receipt
  P->>L: write 正本
  L-->>P: hash + bytes + epoch ACK
  P->>S: release(ACK)
  S->>L: hashFile 核验
  Note over L,S: 当前存在并发 delete/rename/write 窗口
  S->>S: 写入并 fsync tombstone
  S->>S: 删除暂存原件与记录
Loading

Reviews (1) · Last reviewed commit: "fix(desktop): recover staging durables a..."

Comment thread apps/desktop/src/main/cindy-brain/librarySlot.ts
Comment thread apps/desktop/src/main/cindy-brain/libraryVault.ts
Comment thread apps/desktop/src/main/cindy-brain/librarySlot.ts
Greptile P1: vanished custom ghost dirs must not rebuild an empty library
on a live vault; staging.release re-hashes the unique original and blocks
concurrent library mutates until the staging original is gone. P2: disposeAll
now invalidates staging stores, uploads, and vaults.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
… fail-closed

Binding records first-create vs already-built trees without bumping generation.
Restart after `<parent>/<ghostId>` disappears now returns disk-missing instead
of initializing an empty replacement library.

Signed-off-by: PraiseZhu <zhuzan@xd.com>
Greptile P1 r4058467558 needs a real per-ghost mutex, not only a
second hash. Mutating Library ops and staging.release now share one
chain so hash-then-delete cannot overlap write/delete/rename. Relocating
releases still fail closed without taking the lock.

Signed-off-by: PraiseZhu <zhuzan@xd.com>

@MagicLizi MagicLizi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

app

@MagicLizi MagicLizi added status:conflict 与目标分支有冲突(review-pr 自动维护,仅展示) touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:large-diff 改动量较大(review-pr 自动维护,仅展示) touches:plugin-base 改动碰到插件基座(review-pr 自动维护,仅展示) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示) touches:rules 改动碰到规则 / 规范文档(review-pr 自动维护,仅展示) labels Sep 24, 2026
# Conflicts:
#	apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts
#	apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts
#	apps/desktop/src/main/cindy-brain/librarySlot.ts
#	apps/desktop/src/main/cindy-brain/libraryVault.ts
@MagicLizi MagicLizi added status:awaiting-bot-review 等外部审查机器人表态(review-pr 自动维护,仅展示) and removed status:conflict 与目标分支有冲突(review-pr 自动维护,仅展示) status:awaiting-bot-review 等外部审查机器人表态(review-pr 自动维护,仅展示) labels Sep 24, 2026
@MagicLizi
MagicLizi merged commit b032d35 into makecindy:main Sep 24, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) touches:large-diff 改动量较大(review-pr 自动维护,仅展示) touches:plugin-base 改动碰到插件基座(review-pr 自动维护,仅展示) touches:product-ui 改动碰到产品 / UI 面(review-pr 自动维护,仅展示) touches:rules 改动碰到规则 / 规范文档(review-pr 自动维护,仅展示)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants