這是 Claude 對話在 osFoundry Notes 上可以用的寫入工具,內容取自公開 MCP endpoint 的列表。資料是 2026-11-06 用未驗證的 tools/list 抓下來的,只保留跟本文有關的欄位:
{"name": "edit_note_tab", "description": "Use an exact tab ID and version from read_note_tab. Use replace_text for Note/Markdown, slide_add or slides_add_outline for Slides, sheet_set_range for Sheet, or replace_tab_data for supported content tabs. File and Source attachments cannot be edited through this tool. A repeated idempotency key returns the first result.", "inputSchema": { "required": ["note_id", "tab_id", "expected_version", "idempotency_key", "operation"], "properties": { "operation": {"enum": ["replace_text", "replace_tab_data", "slide_add", "slides_add_outline", "sheet_set_range"]}, "sheet_id": {"type": "string"}, "start_row": {"type": "integer"}, "start_col": {"type": "integer"}, "values": {"type": "array", "items": {"type": "array"}}}}, "annotations": {"readOnlyHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} 五個必填欄位裡,有兩個跟編輯內容本身完全無關。expected_version 讓這次寫入變成 compare-and-swap:只有在 Note 仍停在 agent 讀到的那個版本時,寫入才會成功。idempotency_key 讓重送不會造成傷害。其他 Note 寫入工具 create_note_tab 和 add_note_source 也要求同樣這一組。讀取則不需要:list_note_tabs、read_note_tab、read_sheet_range(最多 100 x 100 個儲存格)和 list_note_attachments 都是 osfoundry:read 底下的 readOnlyHint: true。
以下是這份 contract 實際換來的東西,在一個重建版本上量測。版本檢查只是一半,另一半是 agent 被拒絕之後怎麼做。
為什麼過期的寫入要直接拒絕,而不是合併?
replace_text 會送出一個分頁的完整文字。如果 Claude 讀的是第 7 版,有人存成了第 8 版,接著 Claude 送出它的文字,伺服器手上就有兩份完整文件,以及一個它早已沒保留的共同祖先。它無法判斷 Claude 是想刪掉對方新加的段落,還是根本沒看到。Last-writer-wins 會在沒人察覺的情況下選錯。拒絕是唯一不用猜的做法。
這就是樂觀並行控制(樂觀鎖)。模型思考二十秒的時候,沒有人握著 lock。每次寫入都會註明它是基於哪個版本,伺服器只在那個版本仍是最新時才做 swap:
UPDATE notes SET version = :new WHERE id = :note AND version = :expected; -- 0 rows → conflict osFoundry 的桌面 app 在人這一端也是同樣的做法。在 apps/web/src/screens/notes/context/NotesContext.tsx 裡,Note 存檔是一個 PATCH /notes/:id,帶著 expected_updated_at(編輯器最後看到的伺服器 revision),而在 Connected 寫入之後,還會帶一個 expected_version,由 connected-note-save-guard.ts 存在 localStorage,重新整理也不會不見。存檔失敗時,編輯器會把使用者的內容留成本機草稿,並顯示同步錯誤。兩邊的編輯器都是條件式寫入。
被拒絕之後,agent 該怎麼做?
有兩種看起來都合理的反應,但只有一種真的有用:
- 重新讀取分頁,把原本要做的修改套用到最新內容上,再用最新版本寫入。
- 只重新讀版本號,然後重送同一份 payload。
第二種會通過檢查,卻照樣蓋掉對方的內容,因為 payload 是用舊文字算出來的。檢查看不出這一點。版本號只能證明你看過最新的版本號,不能證明你用了它的內容。
重建版本
這裡沒有跑任何 Claude binary。notes_store.py 在 WAL 模式的 sqlite 上實作了 list_note_tabs、read_note_tab、read_sheet_range、edit_note_tab(replace_text、sheet_set_range)和 create_note_tab,工具名稱與參數名稱都跟正式環境一致。每次寫入都在 BEGIN IMMEDIATE 裡執行:檢查冪等表、比對版本、套用、遞增版本、儲存結果,全部在同一個交易內完成。race.py 啟動兩個各自擁有連線的 thread:一個腳本化的「Claude」和一個腳本化的人。兩邊各做 100 次 read → think → write。思考時間經過縮放:Claude 是 20–80 ms,人是 5–40 ms,所以 agent 從讀到寫的時間窗比較長,跟真實模型一樣。每次寫入都會附加一行獨一無二的內容(claude-17、human-42),事後少掉的那一行就是可以計數的 lost update。Python 3.9.6、Apple M1、seed 1–5。
寫入路徑,省略 sqlite 的細節:
def _guarded(self, note_id, tab_id, expected_version, idempotency_key, apply): db.execute("BEGIN IMMEDIATE") hit = db.execute("SELECT result FROM idem WHERE key=?", (idempotency_key,)).fetchone() if hit: return {**json.loads(hit[0]), "replayed": True} current = tab_version if self.check == "tab" else note_version if self.check != "none" and expected_version != current: return {"error": "version_conflict", "current_version": current} result = apply(db) # replace_text / sheet_set_range / new tab # bump version, store (key, result), COMMIT agent 的迴圈,含 policy 切換:
r = s.edit_note_tab("note-1", tab, tab_now["version"], uuid.uuid4().hex, **payload) while "error" in r: fresh = s.read_note_tab("note-1", tab) if policy == "reapply": payload["text"] = fresh["data"] + "\n" + line # redo the change on what's there now tab_now = fresh # refresh_version keeps the old text r = s.edit_note_tab(...) 發生衝突的嘗試會拿到新的冪等 key。一個 key 代表「基於某個 base 的某一次預期寫入」。被拒絕之後重新套用的寫入,就是另一次不同的寫入。
沒有檢查的話,會遺失多少更新?
五次執行,每次每個編輯者 100 次編輯,每一列總共 1,000 行預期寫入:
| 寫入保護 | 衝突時 agent 的做法 | 遺失行數 | 遺失:Claude / 人 | 衝突:Claude / 人 | 最多嘗試次數 |
|---|---|---|---|---|---|
| 無 | — | 300 | 125 / 175 | 0 / 0 | 1 |
| Note 版本 | 重新讀取、重新套用 | 0 | 0 / 0 | 219 / 133 | 2 |
| Note 版本 | 只重讀版本、重送舊文字 | 304 | 117 / 187 | 213 / 136 | 3 |
沒有檢查時,所有編輯有 30% 不見了,而且人遺失的比 Claude 多。agent 的思考時間較長,寫入所依據的讀取就比較舊,所以蓋掉的也比較多。有檢查並重新套用時,什麼都沒遺失。Claude 有 41–48% 的寫入需要第二次嘗試,從來沒有第三次。平均嘗試次數 Claude 是 1.41–1.48,人是 1.24–1.30。不論哪種模式,每次執行的 wall time 都是 9.6–10.5 s,因為主要時間都花在思考上。
第三列才是該記住的。伺服器回報了超過 300 次衝突,卻什麼也沒保護到。版本檢查能強制你重新讀取,但沒辦法強制你用上讀到的內容。
檢查有成本嗎?
bench.py 量測 2,000 次連續的 edit_note_tab 呼叫,每次是 200 字元的 replace_text,每種模式重複三次。每次寫入的中位數:無檢查 61.9–63.1 µs,Note 版本 57.0–58.8 µs,分頁版本 55.1–61.1 µs。比對只是在一個本來就開著的交易裡多做一次有索引的讀取。差距比每次執行之間的雜訊還小。p99(0.42–1.33 ms)是 sqlite 的 fsync 抖動,跟模式無關。
為什麼版本是屬於 Note 的,代價是什麼?
osFoundry 的工具說明把一個版本綁在整份 Note 上。create_note_tab 寫著 "Use the version from list_note_tabs",也就是 Note 層級的列表。在桌面 app 處理 Connected 編輯的套用路徑(connected-note-application.ts)裡,expected_version 是拿來跟 Note 的 updated_at 比對。所以有人在 Sheet 分頁打字,就會推動 Claude 寫入文字分頁時要比對的那個版本。
在重建版本裡,這是可以量出來的。執行 race.py tabs,讓 Claude 編輯文字分頁、人在 Sheet 分頁寫儲存格,3 次執行,每次 100 次編輯:
| 版本範圍 | Claude 衝突 | 人的衝突 | 遺失 |
|---|---|---|---|
| Note | 300 次寫入中 130 次(43%) | 300 次中 87 次(29%) | 0 |
| 分頁 | 0 | 0 | 0 |
這 217 次衝突全都是假衝突,因為兩個編輯者從來沒碰過同一個分頁。每一次的代價就是多讀一次,僅此而已。Note 層級的版本能提供分頁版本給不了的東西:每次寫入都會對整份 Note 做檢查,所以讀了 Sheet 來寫 Brief 的 agent,不可能根據中途已經變動的 Sheet 數值寫出 Brief。分頁層級的版本消除了假衝突,也正好失去了這個跨分頁的保證。對於讀一個分頁、寫另一個分頁的 agent 來說,粗粒度的版本是比較安全的預設。
毫秒時間戳會對版本檢查造成什麼影響?
用時間戳當版本,前提是兩次存檔不可能產生相同的字串。race.py fast 把思考時間降到零,每個編輯者 2,000 次編輯,5 次執行,比較截到毫秒的版本,以及微秒時間戳加上 6 位十六進位後綴的版本:
| 版本格式 | 遺失行數(共 20,000) | 有遺失的執行次數 |
|---|---|---|
| 毫秒 | 136(0.68%) | 5 次中 5 次 |
| 微秒 + 後綴 | 0 | 5 次中 0 次 |
兩次寫入在同一毫秒內 commit 時,第二次會拿到跟第一次一樣的版本。在第一次寫入之前讀取的編輯者,就會通過針對第二次的檢查。osFoundry 的 Note 版本保留了 PostgreSQL 時間戳的完整精度。如果你要自己做,請用計數器,或是帶 tie-breaker 的時間戳。
為什麼建立操作需要自己的 key?
create_note_tab 不會取代任何東西,所以單靠版本檢查擋不住重複。重送的呼叫會在新版本上變成一個新分頁。race.py create 建立 50 個 Sheet 分頁,並丟棄 30% 的回應,讓 client 像 host 遇到 timeout 那樣重試:
| 送出的呼叫 | 建立的分頁 | 重複 | |
|---|---|---|---|
| 不帶 key,3 次執行 | 71 / 61 / 63 | 71 / 61 / 63 | 21 / 11 / 13 |
| 重試時用同一個 key | 71 / 61 / 63 | 50 / 50 / 50 | 0 |
帶著 key 時,重試會回傳第一次的結果,包括分頁 ID,所以 agent 下一個 edit_note_tab 會指向確實存在的那個分頁。這就是 osFoundry 工具說明裡 "A repeated idempotency key returns the first tab" 那一行的意思。
如果你要自己接起來
- 每個寫入工具都要把
expected_version設為 schema 裡的必填欄位,而不是選填。選填的檢查,只要模型一趕時間就會被略過。 - 把衝突當成模型能處理的一般工具結果回傳,並在工具說明裡寫清楚該怎麼做:重新讀取,再重做修改。「要重新套用,不能只更新版本號」這條規則,只能寫在你的指示裡。
- 刻意決定版本的範圍。以文件為單位的版本會有假衝突,但能保護跨分頁讀取。以分頁為單位的版本避開假衝突,也放棄了這層保護。
- 讓版本不會撞號:用計數器,或是完整精度並帶 tie-breaker 的時間戳。絕對不要用四捨五入到毫秒的時間戳。
- 建立和附加操作要帶冪等 key,每個 key 代表基於某個 base 的一個意圖。把 key 和結果跟寫入放在同一個交易裡儲存。
- 人用的編輯器也要加保護。如果只有 agent 做衝突檢查,一個過期的瀏覽器分頁還是能蓋掉 agent 的內容。
osFoundry 對底層協定的簡短定義在它的 MCP 詞彙條目。它如何保存並還原 Note 先前的狀態,請見 Note versions 頁面。