fix(persona-lib): 檔案層互斥鎖,read-modify-rewrite 不再吃掉並行的 append
short-term.jsonl / said.jsonl 的裁切是「整檔讀進來 → 過濾 → writeText 覆蓋」,
中間沒有任何鎖。而同一個 session 的 sub agent 與主程序共用同一把人格鎖(設計如此),
所以兩邊真的會同時寫——實測背景 prune 進行中 append 30 筆 salience 95 的承諾,
會被吃掉 1~9 筆,正是 shortTermProtected() 明文要保護的那一類。
emotion.json 更嚴重:並行 8 次 `--apply joy=+5`,循序得 50.24,並行只得 38.91(增量遺失 45%)。
加一把用 `fs.openSync(path, "wx")` sentinel 做的檔案鎖:
* withFileLock(file, fn):拿不到就退讓重試,超過 15 秒的殘留鎖視為死鎖並接手,
真的等不到就直接做(寧可冒一次競態,也不要因為殘留鎖檔讓人格從此寫不進東西)。
sentinel 放 .runtime/locks/,不落在人格目錄裡,不會被同步上去。
* rewriteJsonl(file, transform):整檔改寫的唯一入口,讀與寫都在鎖裡。
* updateJson / updateEmotion:JSON 檔的 read-modify-write 同樣進鎖。
* appendJsonl 也拿同一把鎖,否則 append 仍會落在別人的讀與寫之間被覆蓋掉。
改用新入口的:pruneShortTermDetail、trimSaid、trimJsonl、turnContext 的情緒衰減、
persona.mjs 的 `emotion --apply`(讀→衰減→套用→寫回整段在鎖裡)。
selftest +5:並行 append vs prune 一筆不掉、並行 emotion 與循序同值、
withFileLock 互斥(12 程序各加一次=12)、過期鎖檔可接手、鎖檔不落在人格目錄。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+105
-1
@@ -8,7 +8,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -687,6 +687,110 @@ console.log("\n短期記憶:容量壓力(R6)真的會清,而且有保護
|
||||
typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 並行寫入不掉資料
|
||||
//
|
||||
// 人格鎖擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」——同一個 session
|
||||
// 的 sub agent 與主程序共用同一把人格鎖(設計如此),所以兩邊真的會同時寫。
|
||||
// 修之前:背景 prune 進行中寫入 30 筆 salience 95 的承諾會掉 1~9 筆;
|
||||
// 並行 8 次 `emotion --apply joy=+5` 大約有四成的增量消失。
|
||||
console.log("\n並行寫入:read-modify-rewrite 不能吃掉同時 append 的資料");
|
||||
{
|
||||
const LIB = path.join(HERE, "persona-lib.mjs");
|
||||
// 所有子程序睡到同一個時間點才起跑,不然它們只會一個接一個跑、撞不在一起
|
||||
const BARRIER = `
|
||||
const _b = new Int32Array(new SharedArrayBuffer(4));
|
||||
const waitUntil = (t) => { const d = t - Date.now(); if (d > 0) Atomics.wait(_b, 0, 0, d); };
|
||||
`;
|
||||
const child = (code) =>
|
||||
new Promise((resolve) => {
|
||||
const p = spawn(process.execPath, ["--input-type=module", "-e", code],
|
||||
{ env: { ...process.env }, stdio: "ignore" });
|
||||
p.on("exit", resolve);
|
||||
});
|
||||
|
||||
// ① prune 的整檔覆蓋 vs. 同時 append 的受保護紀錄
|
||||
const raceHome = path.join(H, "racer", "memory");
|
||||
const raceFile = path.join(raceHome, "short-term.jsonl");
|
||||
fs.mkdirSync(raceHome, { recursive: true });
|
||||
const staleTs = pl.iso(new Date(Date.now() - 5 * 86_400_000));
|
||||
fs.writeFileSync(raceFile, Array.from({ length: 200 }, (_, i) =>
|
||||
JSON.stringify({ ts: staleTs, text: `舊${i}`, salience: 10, intent: "chat" })).join("\n") + "\n");
|
||||
let startAt = Date.now() + 700;
|
||||
await Promise.all([
|
||||
child(`${BARRIER}
|
||||
const pl = await import(${JSON.stringify(LIB)});
|
||||
waitUntil(${startAt});
|
||||
for (let i = 0; i < 300; i += 1) pl.pruneShortTermDetail("racer");`),
|
||||
child(`${BARRIER}
|
||||
const pl = await import(${JSON.stringify(LIB)});
|
||||
waitUntil(${startAt});
|
||||
const b = new Int32Array(new SharedArrayBuffer(4));
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
pl.rememberShort("racer", { text: "承諾 " + i, salience: 95, intent: "commit" });
|
||||
Atomics.wait(b, 0, 0, 5);
|
||||
}`),
|
||||
// 一邊有人在聊天,prune 才會一路都有事做(不然它清到軟上限就不再覆寫了)
|
||||
child(`${BARRIER}
|
||||
const pl = await import(${JSON.stringify(LIB)});
|
||||
waitUntil(${startAt});
|
||||
const ts = pl.iso(new Date(Date.now() - 5 * 86400000));
|
||||
for (let i = 0; i < 400; i += 1) pl.appendJsonl(pl.shortTermPath("racer"), { ts, text: "雜訊 " + i, salience: 5 });`),
|
||||
]);
|
||||
const survivors = pl.readJsonl(raceFile).filter((r) => String(r.text || "").startsWith("承諾"));
|
||||
check("背景 prune 進行中 append 的承諾,一筆都不能掉",
|
||||
survivors.length === 30, `活下 ${survivors.length}/30 筆`);
|
||||
|
||||
// ② emotion.json 的 read-modify-write:並行要跟循序算出同一個值
|
||||
cli(["create", "--persona", "racee", "--session", "sess-race-9999", "--name", "Racee",
|
||||
"--creature", "沙漏", "--vibe", "安靜", "--emoji", "⏳"]);
|
||||
const joyOf = () => pl.readJson(pl.emotionPath("racee")).levels.joy;
|
||||
const resetJoy = () => pl.writeJson(pl.emotionPath("racee"), pl.defaultEmotionState());
|
||||
resetJoy();
|
||||
for (let i = 0; i < 8; i += 1) cli(["emotion", "--session", "sess-race-9999", "--apply", "joy=+5"]);
|
||||
const sequential = joyOf();
|
||||
resetJoy();
|
||||
startAt = Date.now() + 900;
|
||||
await Promise.all(Array.from({ length: 8 }, () => child(`${BARRIER}
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
waitUntil(${startAt});
|
||||
spawnSync(process.execPath, [${JSON.stringify(CLI)}, "emotion",
|
||||
"--session", "sess-race-9999", "--apply", "joy=+5"], { stdio: "ignore" });`)));
|
||||
const parallel = joyOf();
|
||||
check("並行 8 次 `emotion --apply` 跟循序 8 次結果一樣(情緒更新不遺失)",
|
||||
Math.abs(parallel - sequential) < 0.01, `循序 ${sequential} / 並行 ${parallel}`);
|
||||
cli(["release", "--session", "sess-race-9999"]);
|
||||
|
||||
// ③ withFileLock 本身:N 個程序各做一次 read-increment-write,一次都不能掉
|
||||
const counter = path.join(H, "racer", "counter.json");
|
||||
fs.writeFileSync(counter, JSON.stringify({ n: 0 }));
|
||||
startAt = Date.now() + 700;
|
||||
await Promise.all(Array.from({ length: 12 }, () => child(`${BARRIER}
|
||||
const pl = await import(${JSON.stringify(LIB)});
|
||||
waitUntil(${startAt});
|
||||
pl.updateJson(${JSON.stringify(counter)}, (d) => {
|
||||
const n = (d && d.n) || 0;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 8); // 撐開讀與寫之間的空窗
|
||||
return { n: n + 1 };
|
||||
}, { n: 0 });`)));
|
||||
check("withFileLock 真的互斥:12 個程序各加一次,結果就是 12",
|
||||
pl.readJson(counter).n === 12, `n=${pl.readJson(counter).n}`);
|
||||
|
||||
// ④ 殘留的鎖檔不能讓人格從此寫不進東西
|
||||
const victim = path.join(H, "racer", "stale.jsonl");
|
||||
fs.writeFileSync(victim, "");
|
||||
const staleLock = pl.fileLockPath(victim);
|
||||
fs.mkdirSync(path.dirname(staleLock), { recursive: true });
|
||||
fs.writeFileSync(staleLock, "999999\n");
|
||||
const old = new Date(Date.now() - pl.FILE_LOCK_STALE_MS - 5_000);
|
||||
fs.utimesSync(staleLock, old, old);
|
||||
pl.appendJsonl(victim, { text: "死鎖之後還是要寫得進去" });
|
||||
check("過期的鎖檔會被接手,不會把人格鎖死", pl.readJsonl(victim).length === 1);
|
||||
check("鎖檔放在 .runtime/locks,不會混進人格目錄(也就不會被同步上去)",
|
||||
pl.fileLockPath(victim).startsWith(path.join(pl.runtimeDir(), "locks")) &&
|
||||
!fs.existsSync(staleLock), pl.fileLockPath(victim));
|
||||
}
|
||||
|
||||
console.log("\n情緒:飽和、單輪預算、走向、偏差稽核");
|
||||
let sat = pl.defaultEmotionState();
|
||||
for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌");
|
||||
|
||||
Reference in New Issue
Block a user