diff --git a/bugs/0524.md b/bugs/0524.md new file mode 100644 index 00000000..1a93d577 --- /dev/null +++ b/bugs/0524.md @@ -0,0 +1,49 @@ + 我手动测试,以下bug仍然存在: + 1.我的空间:向文件树中的md文件中拖动文件进行上传文件,能正常在主编辑 md 中显示,点击也能正常打开,但是见 /mnt/Data1T/mnote/tmp/image copy + 65.png,文件是和主文件夹同级(应该在我的文件夹下级)。 +2.上传文件后,弹出文件冲突:/mnt/Data1T/mnote/tmp/image copy 76.png,重新新建页面(冲突信息被带到新页面, + 刷新后消失),文件冲突依然存在。冲突信息:/mnt/Data1T/mnote/tmp/image copy 77.png;另外一个例子:/mnt/Data1T/mnote/tmp/image copy 85.png,新建的页面直接向文件树拖入文件就提示冲突。 + + 处理记录(2026-05-24): + - 归类:`05-editor-mainline`,本质是本地 Markdown working copy 冲突合同缺失,不是单个上传入口问题。 + - 参考设计:`design/05-editor-mainline/process/5-27-local-markdown-working-copy-conflict-contract-v1.md`。 + - Sidex/VSCode 对照结论:正文冲突只应属于同一个 Markdown resource;同 root 下的附件、拖入资源、目录级 watcher 事件不能升级成当前 working copy 冲突。 + - 根因 1:前端本地文件夹 document event channel 只按 `rootUri` 共享,且 SSE 请求没有携带当前 `documentId`,后端无法按 Markdown 正文路径过滤。 + - 根因 2:上传附件/文件树拖入等非 Markdown 资源事件的 `documentId` 为空,前端仍继续设置 `externalChangePending`,导致误报文件冲突。 + - 根因 3:冲突面板是手动插入 `.document-pane` 的 DOM,页面切换时没有随 editor view unmount 清理,因此旧页面冲突 UI 会短暂残留到新页面。 + - 修复: + - `rust/crates/mnote-web/src/routes/web_shell.rs`:document event channel key 从 `rootUri` 收窄为 `rootUri#documentId`,订阅 `/api/local-folder/events` 时携带 `documentId`。 + - `rust/crates/mnote-web/src/routes/web_shell.rs`:收到空 `documentId` 的资源事件直接忽略,不进入正文冲突链路。 + - `rust/crates/mnote-web/src/routes/web_shell.rs`:`unmountEditorViewBinding` 清理旧 session 冲突面板,避免跨页面残留。 + - `rust/crates/mnote-web/src/routes/local_folder_events.rs`:补充 document event filter 测试,确保资源路径不会匹配 Markdown 正文路径。 + - `scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`:上传附件和文件树拖入后增加 no-conflict 断言。 + - 验证: + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_event_filter_rejects_resource_only_changes -- --nocapture` 通过。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture` 通过。 + - `cargo fmt --manifest-path rust/Cargo.toml --all --check` 通过。 + - `node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js` 通过,说明真实冲突路径仍可用。 + - `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` 中上传附件 no-conflict 与文件树拖入 no-conflict 通过;后续 broken-link 检查 timeout 属于第 2 条以外的资源缺失解析问题,需单独处理。 + - Worker 对比: + - Reasonix coding 正确定位空 `documentId` 污染,但漏掉 DOM 生命周期与按 `documentId` 隔离 channel。 + - Claude Code coding 额外定位 DOM 生命周期问题,但清理位置偏晚,仍需主控调整到 editor view unmount。 + - Claude Code browser 证据更完整,覆盖 no-conflict 与真实冲突;Reasonix browser 只适合作为部分 UI 证据,因为其 result/handoff 状态不一致且误判 SSE 500 为真实冲突阻塞。 +3.隐藏本地markdown标题,我去除勾选后,新建页面刷新又恢复默认了,应该维持我的选择的状态。另外应该针对我的空间和其它本地文件夹进行区分,我的空间(包括别人分享给我的我的空间,即所有我的空间)中默认显示本地markdown标题,其它的本地文件夹,默认隐藏markdown标题。 + + 处理记录(2026-05-24): + - 归类:`05-editor-mainline`,本质是本地 Markdown page options 默认值与持久化合同不完整。 + - 根因:`resolve_local_markdown_page_aggregate` 在页面没有持久化 `hideTitleHeader` 时无条件设为 `true`,导致所有本地 Markdown 都默认隐藏标题,没有区分托管工作区(我的空间)与普通本地文件夹。用户取消勾选后只对当前页面写入 `.mnote/page-options.json`,新建页面仍因无记录回到错误默认值。 + - 修复: + - `rust/crates/mnote-web/src/routes/local_folder_source.rs`:无持久化值时检查当前 root 是否存在托管工作区清单;托管工作区默认 `hide_title_header=false`,普通本地文件夹默认 `true`。已有持久化值继续优先,避免覆盖用户选择。 + - `rust/crates/mnote-web/src/routes/local_folder_source.rs`:更新托管工作区标题测试,并新增普通本地文件夹默认隐藏标题测试。 + - `scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`:Check 6 调整为托管工作区默认显示标题;先勾选隐藏并验证持久化,再取消勾选显示并验证刷新后仍显示。 + - 验证: + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown_page_title_comes_from_file_name_not_body_heading -- --nocapture` 通过。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown_non_managed_folder_default_hides_title_header -- --nocapture` 通过。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_documents_save_title_and_options_write_to_disk -- --nocapture` 通过。 + - `cargo fmt --manifest-path rust/Cargo.toml --all --check` 通过。 + - `node --check scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` 通过。 + - Worker 对比: + - Reasonix coding 在隔离 worktree `mnote-wt-0524c-reasonix-title` 完成可用修复,范围控制在 `local_folder_source.rs` 与 `task479`,主控复核后采纳。 + - Claude Code coding 本轮 Hindsight 注入成功,但因 `--max-budget-usd 1.20` 超预算退出,只停留在代码探索阶段,未产生 diff;这说明 Claude Code full-mode 对编码任务成本较高,需要更小任务切片或更高预算。 +4.CTRL+Z/CTRL+Y对于删除链接不可用,不能恢复删除的链接。(你应该系统看看还有没有其它ctrl+z/CTRL+Y(撤销/恢复)不能用的场景) +5,md文件上传后刷新前面的黑色方块会变成灰色:/mnt/Data1T/mnote/tmp/image copy 80.png;/mnt/Data1T/mnote/tmp/image copy 81.png diff --git a/design/05-editor-mainline/process/5-27-local-markdown-working-copy-conflict-contract-v1.md b/design/05-editor-mainline/process/5-27-local-markdown-working-copy-conflict-contract-v1.md new file mode 100644 index 00000000..0f76887e --- /dev/null +++ b/design/05-editor-mainline/process/5-27-local-markdown-working-copy-conflict-contract-v1.md @@ -0,0 +1,67 @@ +# 5-27 本地 Markdown Working Copy 冲突合同 v1 + +## 背景 + +`bugs/0524.md` 第 2 条暴露的问题不是单个上传入口错误,而是本地文件夹 watcher、正文 session、文件树资源事件和冲突 UI 之间缺少清晰边界: + +- 上传附件或向文件树拖入文件后,非 Markdown 文件变化会进入正文外部变更链路。 +- 新建页面后,旧的冲突提示可能被同 root 的事件流带到新页面,刷新后消失。 +- 真实冲突仍然需要保留:当前 Markdown 有未保存编辑,同时磁盘上的同一个 Markdown 文件被外部修改时,必须进入冲突处理。 + +## Sidex 对照 + +Sidex/VSCode 的核心模型是 `StoredFileWorkingCopy`: + +- 每个 working copy 绑定一个具体 `resource`。 +- 保存时以 `lastResolvedFileStat.etag/mtime` 做 dirty write prevention。 +- 只有同一个 resource 的写入出现 `FILE_MODIFIED_SINCE` 时,才进入 `inConflictMode`。 +- 文件系统 watcher 的目录级事件不会直接把同目录其它文件变化升级成当前 working copy 冲突。 + +MNote 不需要照搬 VSCode 的实现,但应采用同一条合同:正文冲突只属于当前 Markdown 文件,不属于同 root 下任意附件、文件树资源或元数据文件。 + +## 当前根因 + +当前 `mnote-web` 的 document event channel 以 `rootUri` 共享: + +- 前端订阅 `/api/local-folder/events?rootUri=...`,没有传 `documentId`。 +- 后端 `build_document_events_stream` 在没有 `documentId` 时不会按 Markdown 相对路径过滤。 +- watcher 对非 Markdown 资源发出的 payload 中 `documentId` 为空。 +- 前端收到空 `documentId` 后,仍会对同 root 下所有 document session 设置 `externalChangePending` 并调度正文刷新。 + +这会把附件上传、文件树拖入、资源创建等 root 级变化误投递到正文 session,形成文件冲突误报和跨页面状态污染。 + +另一个独立根因是冲突 UI 的 DOM 生命周期: + +- `renderSessionConflictSurface` 会把冲突面板直接插入 `.document-pane`。 +- 页面切换时 `unmountEditorViewBinding` 只卸载 editor runtime 和事件监听,没有清理旧 session 的冲突面板。 +- 因此旧页面已出现冲突时,新建/切换到新页面可能短暂看到旧冲突面板;刷新后整页 DOM 重建,问题消失。 + +## 合同 + +1. Markdown 正文 session 只订阅自己的 Markdown 文件事件。 +2. 非 Markdown 资源事件只用于文件树/资源投影刷新,不触发正文 `externalChangePending`。 +3. 真实正文冲突只在“同一个 Markdown documentId + 当前 session dirty/saving/saveTimer/recent input”时出现。 +4. 同 root 下不同 Markdown 页面必须有独立 document event channel。 +5. 文件树 live stream 继续承担 root 级变化刷新,不依赖正文冲突链路。 + +## 已执行修复切片 + +- 前端 document event channel key 从 `rootUri` 收窄为 `rootUri#documentId`。 +- 前端订阅本地文件夹 document events 时携带 `documentId`。 +- 前端收到无 `documentId` 的 change payload 时直接忽略,不再设置正文 `externalChangePending`。 +- editor view unmount 时清理旧 session 的冲突面板,避免手动插入的 DOM 跨页面残留。 +- 后端补充 document event filter 测试,确保资源文件路径不会匹配 Markdown 正文路径。 + +## 后续系统性收口 + +- 将 `DocumentBufferStore` 作为正文冲突唯一状态底座,前端 session 只展示 buffer state,不自行拼第二套冲突事实。 +- 为资源 tab 增加独立 resource watch 合同:按 `resourcePath` 监听资源文件,而不是复用 Markdown document event。 +- 为新建页面增加创建者写入抑制或 bootstrap generation,避免未来新建空文件 watcher 与首次打开时序竞争。 +- 把 `task451` 的真实冲突 smoke 与上传/新建页面无冲突 smoke 合并成一组 local Markdown conflict regression。 + +## 验收 + +- 上传第一个/第二个附件后,主编辑区不显示 `mnote-editor-conflict-panel`。 +- 新建页面后,旧页面冲突 UI 不带入新页面。 +- 新建页面后直接向文件树拖入文件,不显示文件冲突。 +- dirty 当前 Markdown 后由外部修改同一个 `.md` 文件,仍显示冲突并保留 accept disk / keep current / diff 流程。 diff --git a/design/07-ai/process/7-35-reasonix-browser-test-contract-v1.md b/design/07-ai/process/7-35-reasonix-browser-test-contract-v1.md new file mode 100644 index 00000000..c9b77631 --- /dev/null +++ b/design/07-ai/process/7-35-reasonix-browser-test-contract-v1.md @@ -0,0 +1,146 @@ +# Reasonix Browser Test Contract v1 + +## 状态 + +- 状态:process +- Owner:AI runtime / browser verification +- 背景:Reasonix 已能作为独立辅助 agent 执行只读审计,但浏览器验收若只输出自然语言结论,主控仍需重测,无法稳定节省 token。 + +## 目标 + +把 Reasonix 浏览器测试从“描述性结论”收口为“可审计证据包”。Codex/Hermes 主控只接受带截图、日志、网络、断言和环境前置条件的 Reasonix 浏览器结论。 + +## 非目标 + +- 不让 Reasonix 直接裁决业务 bug 是否完成。 +- 不让 Reasonix 修改源码、提交 git、回滚文件。 +- 不把浏览器测试结论写成长期产品事实源;长期事实仍以 bug/design/checklist 和真实验证日志为准。 + +## 合同 + +每个 Reasonix 浏览器测试任务必须产出以下文件: + +```text +result.json +final.md +process-handoff.md +process-handoff.json +artifacts/ + screenshots/ + console.json + network.json + trace-or-steps.md +``` + +`result.json` 至少包含: + +```json +{ + "status": "passed | failed | blocked", + "run_id": "reasonix-...", + "target_url": "http://127.0.0.1:3000", + "environment": { + "dev_server_restarted": true, + "cache_cleared": true, + "browser_context": "isolated", + "auth_mode": "test-account", + "workspace_root": "/tmp/..." + }, + "steps": [ + { + "name": "upload first attachment", + "action": "drag file into editor", + "screenshot": "artifacts/screenshots/01-upload-first.png" + } + ], + "assertions": [ + { + "name": "attachment opens in tab after second upload", + "expected": "tab count increases and active tab kind is resource", + "actual": "click produced no tab change", + "passed": false, + "evidence": "artifacts/screenshots/04-click-after-second-upload.png" + } + ], + "console_errors": [], + "network_failures": [], + "screenshots": [], + "modified_files": [] +} +``` + +## 浏览器前置条件 + +- 测试前重启 `npm run dev:hot` 或复用主控明确提供的已重启服务。 +- 使用全新 isolated browser context;清空 cookies、localStorage、sessionStorage、IndexedDB、Cache Storage。 +- 默认使用 `http://127.0.0.1:3000/auth` 的测试账号快速登录。 +- local-first 测试必须创建临时页面或临时 workspace,并记录路径。 +- 所有上传文件路径必须记录,不能只写“上传文件”。 + +## 截图要求 + +用户可见 bug 至少保留: + +- 初始状态截图。 +- 关键动作后截图。 +- 失败状态截图。 +- 修复验证时的通过状态截图。 + +如果 bug 涉及 “点击无反应”,必须同时记录: + +- 点击前 tab 列表。 +- 点击后 tab 列表。 +- 点击目标节点截图。 +- console/network。 + +## 0524 试验集映射 + +`bugs/0524.md` 可作为第一批协同测试样本: + +1. 我的空间拖入附件路径错误: + - 验收:上传后文件实际路径应在当前页面 bundle/下级,而不是与主文件夹同级。 + - Reasonix 输出:文件树截图、实际磁盘路径、上传请求、编辑区附件截图。 +2. 上传后文件冲突: + - 验收:本页面不误报自写冲突;新建页面不继承旧冲突状态。 + - Reasonix 输出:冲突弹窗截图、页面切换后截图、console/network。 +3. 隐藏本地 Markdown 标题设置: + - 验收:不同 workspace/source 的默认值区分,用户选择刷新后保持。 + - Reasonix 输出:设置变更前后截图、刷新后截图、local/session storage 或后端请求证据。 +4. Ctrl+Z/Ctrl+Y 删除链接: + - 验收:删除附件链接后 undo/redo 可恢复/再次删除。 + - Reasonix 输出:键盘动作步骤、编辑区 DOM/截图、断言结果。 +5. md 文件上传后黑色方块刷新变灰: + - 验收:上传前后和刷新后附件 icon 状态一致,或有明确缺失状态样式。 + - Reasonix 输出:刷新前后截图、附件节点属性。 + +## 主控接受条件 + +Codex/Hermes 只有在以下条件满足时,才能把 Reasonix 浏览器结果作为验收证据: + +- `result.json` 存在且能解析。 +- 至少一张截图能直接显示用户可见状态。 +- console/network 不为空时已被解释;为空时明确记录为空。 +- `modified_files` 为 `[]` 或 `none`,确认 Reasonix 没有改源码。 +- `process-handoff` 已 retain 到 Hindsight,主控可 recall。 + +## 失败处理 + +- 缺截图:结果降级为线索,不作为验收。 +- 缺 console/network:UI 失败只可作为复现证据,不可作为根因证据。 +- 没清缓存或没隔离 browser context:结果标记 `blocked` 或 `needs_rerun`。 +- Reasonix 修改源码:本次测试作废,主控检查 diff 后决定是否保留建议。 + +## 评价指标 + +- accepted_browser_findings:Reasonix 浏览器发现最终被主控采纳的数量。 +- false_browser_leads:Reasonix 浏览器结论导致主控走错方向的数量。 +- retest_required_ratio:主控必须完全重测的比例。 +- artifact_read_time:主控读取证据包并形成判断所需时间。 +- missing_artifact_count:缺失截图、console、network、断言的数量。 + +## Done Gate + +- [ ] `reasonix-browser-tester` skill 已补入本合同的最小 artifact 要求。 +- [ ] 使用 `bugs/0524.md` 中至少 1 个 bug 进行 Reasonix 浏览器试跑。 +- [ ] 试跑后记录 accepted findings / false leads / retest required。 +- [ ] 根据试跑结果更新本合同或 Reasonix skill。 diff --git a/design/07-ai/process/7-36-reasonix-skill-maintainer-v1.md b/design/07-ai/process/7-36-reasonix-skill-maintainer-v1.md new file mode 100644 index 00000000..4273579c --- /dev/null +++ b/design/07-ai/process/7-36-reasonix-skill-maintainer-v1.md @@ -0,0 +1,146 @@ +# Reasonix Skill Maintainer v1 + +## 状态 + +- 状态:process +- Owner:AI runtime / multi-agent collaboration +- 背景:Hindsight 解决了 Reasonix 过程可追溯问题,但不能自动让 Reasonix 下一次更好。需要一个主控驱动的最小自进化机制,由 Codex/Hermes 根据真实 run 修改 Reasonix skill、任务模板和 handoff 合同。 + +## 目标 + +建立“真实任务 -> 复盘 -> 修改 Reasonix skill/模板 -> 小 smoke -> 再试跑”的闭环,让 Reasonix 从通用辅助逐步变成可审计、低噪声、能节省主控 token 的 worker。 + +## 非目标 + +- 不让 Reasonix 自主修改自己的 skill。 +- 不照搬 Hermes 的完整自进化机制。 +- 不把一次失败就提升为长期规则。 +- 不修改项目 `AGENTS.md`,除非用户明确要求。 + +## 角色边界 + +- Codex/Hermes:主控,负责判断哪些协作缺陷值得固化为 skill 规则。 +- Reasonix:被维护的 worker,负责执行明确任务。 +- Hindsight:热记忆,保存过程 handoff 和协作复盘。 +- MemPalace:冷归档,保存长期可检索的会话摘要。 + +## 输入 + +每次维护 Reasonix skill 前,主控必须先读取: + +- Reasonix run 输出目录: + - `result.json` + - `process-handoff.json` + - `process-handoff.md` + - `final.md` + - 必要时 `reasonix-transcript.jsonl` +- Hindsight recall: + - `hindsight-embed -p agents memory recall reasonix ""` +- 当前相关 skill: + - `reasonix-coding-worker` + - `reasonix-browser-tester` + - `reasonix-parallel-coding-flow` + - 新增 `reasonix-skill-maintainer` + +## 复盘维度 + +每个 Reasonix run 至少评估: + +- accepted_findings:被主控采纳的关键发现数。 +- false_leads:误导主控的判断数。 +- missing_evidence:缺失证据项。 +- verification_strength:是否有真实命令、截图、日志、网络、diff。 +- handoff_cleanliness:handoff 是否能快速读懂。 +- modified_files_accuracy:是否准确记录修改文件。 +- retest_required:主控是否必须完全重测。 +- token_saving_estimate:是否减少主控探索或验证成本。 + +## 维护决策 + +只有满足以下至少一项,才修改 Reasonix skill: + +- 同类缺陷出现两次以上。 +- 单次缺陷导致主控明显走错方向。 +- 缺失 artifact 使浏览器结果无法验收。 +- `process-handoff` 的结构缺陷导致主控必须重读 transcript。 +- 用户明确指出 Reasonix 协同方式需要调整。 + +不应修改 skill 的情况: + +- 只是单次措辞不佳。 +- 是当前任务本身不清楚,而不是 Reasonix 规则缺失。 +- 可通过更好的任务书解决,不需要变成长期规则。 + +## 修改范围 + +优先顺序: + +1. 修改任务书模板:成本最低,适合单类任务。 +2. 修改 Reasonix skill:适合跨任务重复规则。 +3. 修改 runner/extractor schema:适合结构化输出缺陷。 +4. 修改 Codex/Hermes 主控 skill:适合复核顺序和验收口径。 + +每次只做最小修改,并记录: + +- 修改前问题。 +- 修改文件。 +- 预期改善。 +- smoke 验证方式。 + +## Smoke 验证 + +修改 Reasonix skill 后,必须至少做一个轻量 smoke: + +- 只读任务优先,不改业务代码。 +- 要求 Reasonix 产出新格式字段。 +- 主控检查字段是否存在、是否可解析、是否减少噪声。 + +浏览器类 smoke 应检查: + +- 是否有截图路径。 +- 是否有 console/network 摘要。 +- 是否有 `cache_cleared`、`browser_context`。 +- 是否有 `assertions[]`。 + +## 0524 试跑计划 + +使用 `bugs/0524.md` 作为真实任务池,不一次性全交给 Reasonix: + +1. 第一轮:只让 Reasonix 浏览器复现第 2 个 bug“上传后文件冲突”,不修代码。 + - 目标:验证 browser artifact contract 是否足够。 +2. 第二轮:让 Reasonix 只读审计第 1 个 bug“我的空间附件路径错误”。 + - 目标:验证 coding-worker handoff 是否能给出可采纳根因。 +3. 第三轮:Codex 修复其中一个 bug 后,让 Reasonix 做回归。 + - 目标:评估 Reasonix 是否能减少主控浏览器验证 token。 + +每轮都记录: + +- Reasonix 耗时。 +- 主控读取 handoff 所需时间。 +- 主控是否重测。 +- 被采纳发现。 +- 误导点。 +- 需要修改的 skill 规则。 + +## 输出记录 + +建议每次维护后在对应 bug/design 中补一段: + +```text +Reasonix 协同复盘: +- run id: +- 任务类型: +- accepted findings: +- false leads: +- missing artifacts: +- skill changes: +- next iteration: +``` + +## Done Gate + +- [ ] 创建 `reasonix-skill-maintainer` skill。 +- [ ] `reasonix-browser-tester` 已引用 browser artifact contract。 +- [ ] 用 `bugs/0524.md` 至少跑一轮真实 Reasonix 协同测试。 +- [ ] 根据真实结果更新 skill 或明确“不需要修改”。 +- [ ] 在 Hindsight / MemPalace 中保留协作复盘摘要。 diff --git a/design/07-ai/process/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md b/design/07-ai/process/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md new file mode 100644 index 00000000..04e22bb3 --- /dev/null +++ b/design/07-ai/process/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md @@ -0,0 +1,141 @@ +# 7-37 Claude Code / Reasonix Worker 评估协议:0524 Bug2 v1 + +## 目标 + +用 `bugs/0524.md` 第 2 条“本地 Markdown 上传/文件树拖入后误报文件冲突”作为真实任务,评估 Claude Code 与 Reasonix 作为 worker 的可用性: + +1. 编码能力:是否能在相同基线下定位根因、做小范围修复、补测试并通过验证。 +2. 网页测试能力:是否能在真实浏览器中复现/验收,产出可审计截图、console、network 和结构化结论。 + +Codex 仍是主控,负责设计、派发、复核、合并和最终验收。 + +## 公平性约束 + +- 两个 coding worker 使用从同一 `HEAD` 创建的独立 worktree,不共享当前主工作区未提交改动。 +- 两个 coding worker 使用同一份任务书,允许修改范围一致,验收命令一致。 +- 两个 browser worker 对同一个主工作区修复结果做只读验证,不允许改源码。 +- 评分只基于可复核产物:diff、测试输出、process handoff、截图、console/network,不凭 final 文本。 +- 若 worker 卡住、超时、无 handoff、无截图或修改范围失控,按实际情况扣分,不手工补完其证据。 + +## Coding Worker 任务切片 + +输入: + +- bug:`bugs/0524.md` 第 2 条。 +- 参考设计:`design/05-editor-mainline/process/5-27-local-markdown-working-copy-conflict-contract-v1.md`。 +- 允许修改: + - `rust/crates/mnote-web/src/routes/web_shell.rs` + - `rust/crates/mnote-web/src/routes/local_folder_events.rs` + - `scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` + - 必要时新增/更新同目录小测试 +- 禁止修改:无关设计、AGENTS、其它 bugs、git 提交、回滚用户改动。 + +验收: + +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_event_filter_rejects_resource_only_changes -- --nocapture` +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture` +- `cargo fmt --manifest-path rust/Cargo.toml --all --check` +- 若能启动浏览器 smoke,运行 `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` 并记录结果。 + +## Browser Worker 任务切片 + +输入: + +- 被测主工作区当前修复结果。 +- 入口:`http://127.0.0.1:3000`。 +- 登录:`/auth` 的“测试账号快速登录”。 + +必须验证: + +- 打开本地 Markdown 页面,上传附件后,不出现 `mnote-editor-conflict-panel`。 +- 新建/切换页面后,旧冲突面板不残留到新页面。 +- 向文件树文件夹拖入文件后,不出现 `external-change-conflict`。 +- 真实冲突路径仍可用:dirty 当前 Markdown 后外部修改同一 `.md` 文件,应显示冲突。 + +必须产出: + +- `result.json` +- `process-handoff.md/json` +- 至少一张显示通过或失败状态的截图 +- console/network 摘要 +- `modified_files` 必须为空 + +## 评分维度 + +- `root_cause`:是否找到两个核心根因:document event 越界、冲突 DOM 残留。 +- `patch_scope`:是否只改必要文件,未引入架构外补丁。 +- `test_quality`:是否补了能失败/能防回归的测试。 +- `verification`:是否实际运行命令并记录输出。 +- `handoff_quality`:是否有过程证据,而不是只给最终摘要。 +- `latency_cost`:完成时间、工具调用量、是否卡住。 +- `mislead_risk`:错误假设、过度修改、把无关失败归因到本 bug 的风险。 + +## 初步观察 + +- Reasonix 只读分析较快完成,正确指出空 `documentId` 会污染 session,但对新建页时序有部分猜测,需要主控复核。 +- Claude Code 修复后本轮可产出 handoff,明确指出冲突面板 DOM 生命周期缺口;耗时更长,但对第二个根因帮助明显。 +- 后续需要用隔离 coding 任务和真实 browser 任务继续比较,而不是只比较只读分析。 + +## 0524 Bug2 本轮实测记录 + +### 主控基线 + +- 主控修复覆盖三个切片: + - document event channel 从 `rootUri` 收窄到 `rootUri#documentId`。 + - 空 `documentId` 的资源事件不再进入正文冲突链路。 + - editor view unmount 清理旧 session 冲突面板 DOM。 +- 主控验证: + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_event_filter_rejects_resource_only_changes -- --nocapture`:通过。 + - `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`:通过。 + - `cargo fmt --manifest-path rust/Cargo.toml --all --check`:通过。 + - `node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`:通过,真实冲突仍可用。 + - `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`:上传附件和文件树拖入 no-conflict 断言通过;后续 broken-link 检查失败,属于其它已知路径,不计入本 bug 修复失败。 + +### Coding Worker 对比 + +- Reasonix coding: + - run id:`reasonix-2026-05-24T07-44-15-864Z-6898e686`。 + - worktree:`/mnt/Data1T/mnote-wt-0524b-reasonix-coding`。 + - 正确抓到空 `documentId` 事件污染正文 session。 + - 补了前端 `if (!documentId) return;` 和后端映射测试。 + - 漏掉冲突面板 DOM 残留,也没有做 `rootUri#documentId` channel 隔离。 + - 自报未跑 Playwright,适合作为“定位主因 + 小补丁”证据,不足以直接合并。 +- Claude Code coding: + - run id:`claudecode-2026-05-24T07-44-15-975Z-e03aaf1f`。 + - worktree:`/mnt/Data1T/mnote-wt-0524b-claudecode-coding`。 + - 正确抓到空 `documentId` 事件污染。 + - 额外抓到冲突面板 DOM 生命周期问题。 + - 修复位置是 `releaseDocumentSession`,比主控采用的 `unmountEditorViewBinding` 更晚,仍可能存在切换瞬间残留风险。 + - 未做 `rootUri#documentId` channel 隔离,也未补 task479 no-conflict smoke。 + - 编码帮助大于 Reasonix,但仍需主控补边界。 + +### Browser Worker 对比 + +- Claude Code browser: + - run id:`claudecode-2026-05-24T07-47-11-262Z-46f574f0`。 + - artifact:`/tmp/mnote-0524-bug2-browser-worker-1779608936242/`。 + - 产出 `result.json`、截图、console/network。 + - 上传附件 no-conflict、文件树拖入 no-conflict、dirty + 外部修改真实冲突均通过。 + - `modified_files=[]`。 + - 该轮可作为较高质量浏览器验收证据。 +- Reasonix browser: + - run id:`reasonix-2026-05-24T07-47-11-249Z-43f061bb`。 + - artifact:`/tmp/mnote-0524-bug2-browser-worker-0524T0800/`。 + - 产出 `result.json`、截图、console/network。 + - 上传附件 no-conflict、文件树拖入 no-conflict 通过。 + - runner status 为 `error` / handoff status 为 `failed`,但 `result.json.status=passed`,状态合同不一致。 + - 将 `/api/local-folder/events` 500 判断为真实冲突阻塞;主控 `task451` 和 Claude Code browser 已证明真实冲突仍可验证,因此该判断有误导风险。 + - 可作为部分 UI 证据,但不能单独作为最终验收。 + +### 可审计性结论 + +- Hindsight recall 可召回本轮 Claude Code 与 Reasonix 过程 handoff,但 Reasonix recall 混入了无关项目结果,主控不能只看 recall 摘要。 +- Claude Code 的 browser artifact 更适合验收:`result.json`、截图路径、console/network、`modified_files=[]` 之间一致。 +- Reasonix 的 browser artifact 可作为辅助证据,但 runner `status=error` 与 artifact `status=passed` 冲突时,必须降级为线索。 +- 本轮公平测试说明:worker 能节省主控定位和浏览器操作 token,但不能替代主控做架构边界、diff 取舍和最终验收。 + +## 当前评分倾向 + +- 编码能力:Claude Code 略优,能补出 DOM 生命周期根因;Reasonix 更快但更像最小局部补丁。 +- 浏览器测试能力:Claude Code 本轮明显优于 Reasonix,证据更完整且能覆盖真实冲突;Reasonix 产物可审计但状态和结论自相矛盾。 +- 主控策略:以后可让 Reasonix 做低风险定位/快速 smoke,让 Claude Code 做更完整的 browser artifact;两者结论都必须由 Codex 复核 diff、截图和命令输出。 diff --git a/rust/crates/mnote-web/src/routes/local_folder_events.rs b/rust/crates/mnote-web/src/routes/local_folder_events.rs index 8f421343..ddd9ee33 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_events.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -92,11 +92,7 @@ async fn build_document_events_stream( match subscription.receiver.recv().await { Ok(payload) => { if let Some(expected) = document_relative_path.as_deref() { - let relative_path = payload - .get("relativePath") - .and_then(Value::as_str) - .unwrap_or_default(); - if expected != relative_path { + if !document_event_targets_relative_path(&payload, expected) { continue; } } @@ -239,6 +235,14 @@ fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option bool { + let relative_path = payload + .get("relativePath") + .and_then(Value::as_str) + .unwrap_or_default(); + expected == relative_path +} + fn system_time_ms(time: SystemTime) -> u128 { time.duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) @@ -368,6 +372,23 @@ mod tests { ); } + #[test] + fn document_event_filter_rejects_resource_only_changes() { + let payload = json!({ + "sourceKind": "local_folder", + "rootUri": "file:///test", + "relativePath": "docs/README/image.png", + "documentId": "", + "eventKind": "Create(File)", + "revision": 1, + }); + + assert!( + !document_event_targets_relative_path(&payload, "docs/README.md"), + "正文事件流不能把资源文件变化当作 Markdown 正文变化" + ); + } + #[test] fn build_tree_snapshot_payload_has_required_fields() { let sidebar_projection = json!({ diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 96445218..123407e3 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -2780,7 +2780,10 @@ pub fn resolve_local_markdown_page_aggregate( .and_then(page_options_hide_title_header_value) .is_none() { - page_options.hide_title_header = true; + // 我的空间(有 workspace.json 的托管工作区,包括用户本人的及分享的)默认显示页头标题。 + // 普通本地文件夹(无托管清单)默认隐藏页头标题,避免文件名与正文 H1 重复。 + let is_managed = load_local_workspace_manifest(&canonical_root).is_ok(); + page_options.hide_title_header = !is_managed; } let character_count = parsed.body.chars().count() as u64; let word_count = parsed @@ -10344,6 +10347,23 @@ fn main() {} let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:File~20Name.md") .expect("aggregate"); assert_eq!(aggregate.title, "File Name"); + // init_workspace 创建的是托管工作区(我的空间),默认显示页头标题。 + assert!(!aggregate.layout.page_options.hide_title_header); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_non_managed_folder_default_hides_title_header() { + // 非托管本地文件夹:没有 .mnote/workspace.json,应该默认隐藏标题。 + let root = temp_root("mnote-local-non-managed-title-default"); + let _ = std::fs::create_dir_all(root.join(".mnote")); + std::fs::write(root.join("Page.md"), "# Page Heading\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + + let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page.md") + .expect("aggregate"); + assert_eq!(aggregate.title, "Page"); assert!(aggregate.layout.page_options.hide_title_header); let _ = std::fs::remove_dir_all(&root); diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 7648ab81..8c377b15 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -1578,6 +1578,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const sessionViews = (session) => Array.from(session.views.values()); + const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`; + const detachSessionFromLocalFolderChannel = (session) => { const channel = session.localFolderChannel; if (!channel) return; @@ -1588,7 +1590,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { } catch (_) { // noop } - localFolderEventRegistry.delete(channel.rootUri); + localFolderEventRegistry.delete(channel.key); } session.localFolderChannel = null; }; @@ -2394,13 +2396,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') { return; } - let channel = localFolderEventRegistry.get(session.rootUri); + const channelKey = localFolderEventChannelKey(session); + let channel = localFolderEventRegistry.get(channelKey); if (!channel) { const url = new URL('/api/local-folder/events', window.location.origin); url.searchParams.set('rootUri', session.rootUri); + url.searchParams.set('documentId', session.documentId); const eventSource = new EventSource(url.toString()); channel = { + key: channelKey, rootUri: session.rootUri, + documentId: session.documentId, eventSource, sessions: new Map(), }; @@ -2410,6 +2416,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { Array.from(channel.sessions.values()).forEach((targetSession) => { if (!targetSession || targetSession.views.size === 0) return; const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; + if (!documentId) return; const eventKind = String(payload.eventKind || ''); const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); if (documentId && !targetsCurrentDocument) return; @@ -2435,7 +2442,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { eventSource.onerror = () => { console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连'); }; - localFolderEventRegistry.set(session.rootUri, channel); + localFolderEventRegistry.set(channelKey, channel); } channel.sessions.set(session.key, session); session.localFolderChannel = channel; @@ -3103,6 +3110,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { view.disconnectSlashObserver(); view.disconnectSlashObserver = null; } + clearSessionConflictSurface(view.session); if (view.mountId != null && typeof view.runtime?.unmount === 'function') { try { view.runtime.unmount(view.mountId); @@ -5422,6 +5430,9 @@ mod tests { assert!(html.contains("refreshMindmapRuntimesFromTreePayload")); assert!(html.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__")); assert!(html.contains("/api/local-folder/events")); + assert!(html.contains("localFolderEventChannelKey")); + assert!(html.contains("url.searchParams.set('documentId', session.documentId);")); + assert!(html.contains("if (!documentId) return;")); assert!(html.contains("new EventSource(url.toString())")); assert!(html.contains("localFolderEventRegistry")); assert!(html @@ -5432,6 +5443,7 @@ mod tests { assert!(html.contains("session.views.size === 0")); assert!(html.contains("targetSession.saving")); assert!(html.contains("lastSelfSaveSignalAt")); + assert!(html.contains("clearSessionConflictSurface(view.session);")); assert!(html.contains("command: 'replaceContent'")); assert!(html.contains("external-change-conflict")); assert!(html.contains("mnote-editor-conflict-panel")); diff --git a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js index 34458734..ef33666f 100644 --- a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js +++ b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js @@ -136,6 +136,26 @@ async function waitForFileContent(filePath, predicate, timeoutMs) { return { ok: false, content: lastContent }; } +async function assertNoPrimaryConflictPanel(page, label) { + await page.waitForTimeout(1000); + const conflict = await page.evaluate(() => { + const pane = document.querySelector('.document-pane[data-pane-role="primary"]'); + const panel = pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]') || null; + const status = pane?.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') + || document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') + || ""; + return { + visible: panel instanceof HTMLElement && panel.offsetParent !== null, + text: panel instanceof HTMLElement ? panel.textContent || "" : "", + status, + }; + }); + assert( + !conflict.visible && conflict.status !== "external-change-conflict", + `${label} 不应出现文件冲突: ${JSON.stringify(conflict)}`, + ); +} + async function waitForPrimaryAttachment(page, fileName) { const link = page .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]', { @@ -327,6 +347,7 @@ async function main() { assert.equal(uploadAsset.rootRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 page resource rootRelativePath"); assert.equal(uploadAsset.markdownRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 markdownRelativePath"); assert.equal(uploadAsset.ownerDocumentId, "local-md:README.md", "主编辑区上传响应应返回 ownerDocumentId"); + await assertNoPrimaryConflictPanel(page, "主编辑区上传附件后"); checks[checkId] = { ok: true, @@ -411,6 +432,7 @@ async function main() { !fs.existsSync(wrongPath) && !fs.existsSync(wrongResourcePath), `拖入 docs 文件夹的文件不应落在 root (${wrongPath}) 或 resourceDir (${wrongResourcePath}) 下`, ); + await assertNoPrimaryConflictPanel(page, "文件树文件夹拖入文件后"); checks[checkId] = { ok: true, @@ -778,19 +800,41 @@ async function main() { // 预期:文件树显示 MyNotes.md(文件名),正文显示 Body Heading(H1 内容) const treeShowsFileName = treeTitle && treeTitle.includes("MyNotes.md"); const bodyShowsHeading = bodyHeading && bodyHeading.includes("Body Heading"); - const titleHeaderInitiallyHidden = await page.evaluate(() => { + // 托管工作区(我的空间)默认显示页头标题。 + const titleHeaderInitiallyVisible = await page.evaluate(() => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); - return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false; + return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false; }); await page.locator('[data-testid="wolai-page-settings-trigger"]').click({ timeout: UI_TIMEOUT_MS }); const hideTitleCheckbox = page.locator('[data-page-option-checkbox="hideTitleHeader"]').first(); await hideTitleCheckbox.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const hideTitleCheckedBefore = await hideTitleCheckbox.isChecked(); - assert.equal(hideTitleCheckedBefore, true, "本地 Markdown 应默认开启隐藏文件标题"); - await hideTitleCheckbox.setChecked(false, { timeout: UI_TIMEOUT_MS }); + assert.equal(hideTitleCheckedBefore, false, "托管工作区(我的空间)默认不隐藏文件标题"); + await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS }); const optionsPath = path.join(root, ".mnote", "page-options.json"); - const optionsSaved = await waitForFileContent( + const optionsHidden = await waitForFileContent( + optionsPath, + (content) => { + try { + const payload = JSON.parse(content); + return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === true; + } catch { + return false; + } + }, + UI_TIMEOUT_MS, + ); + assert(optionsHidden.ok, `隐藏标题应持久化 hideTitleHeader=true,实际: ${optionsHidden.content}`); + await page.waitForTimeout(500); + const titleHeaderHiddenAfterCheck = await page.evaluate(() => { + const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); + return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false; + }); + assert(titleHeaderHiddenAfterCheck, "勾选隐藏标题后标题应隐藏"); + + await hideTitleCheckbox.setChecked(false, { timeout: UI_TIMEOUT_MS }); + const optionsShown = await waitForFileContent( optionsPath, (content) => { try { @@ -802,7 +846,7 @@ async function main() { }, UI_TIMEOUT_MS, ); - assert(optionsSaved.ok, `页面设置应持久化 hideTitleHeader=false,实际: ${optionsSaved.content}`); + assert(optionsShown.ok, `显示标题应持久化 hideTitleHeader=false,实际: ${optionsShown.content}`); await page.waitForTimeout(500); const titleHeaderVisibleAfterToggle = await page.evaluate(() => { const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header'); @@ -819,14 +863,15 @@ async function main() { }); checks[checkId] = { - ok: !!(treeShowsFileName && bodyShowsHeading && titleHeaderInitiallyHidden && titleHeaderVisibleAfterToggle && titleHeaderVisibleAfterReload), + ok: !!(treeShowsFileName && bodyShowsHeading && titleHeaderInitiallyVisible && titleHeaderVisibleAfterToggle && titleHeaderVisibleAfterReload), message: `treeTitle="${treeTitle}" bodyHeading="${bodyHeading}"`, treeTitle, bodyHeading, treeShowsFileName, bodyShowsHeading, - titleHeaderInitiallyHidden, + titleHeaderInitiallyVisible, hideTitleCheckedBefore, + titleHeaderHiddenAfterCheck, titleHeaderVisibleAfterToggle, titleHeaderVisibleAfterReload, };