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:
2026-07-31 09:19:39 +00:00
co-authored by Claude Opus 5
parent 9e7a9cf8e2
commit d9f865d7a4
3 changed files with 295 additions and 63 deletions
+153 -37
View File
@@ -170,10 +170,120 @@ export function writeText(file, text) {
fs.renameSync(tmp, file); fs.renameSync(tmp, file);
} }
/** 單行 appendO_APPEND 對單行寫入是原子的),guest 也能安全使用。 */ // --------------------------------------------------------------------------- //
// 檔案層互斥鎖
//
// 人格鎖(`state/lock.json`)擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」:
// 同一個 session 的 sub agent 與主程序**共用同一把人格鎖**(設計如此),所以兩邊真的會
// 同時寫。而 said / short-term 的裁切是「整檔讀進來 → 過濾 → 覆蓋」,中間任何一筆 append
// 都會被那次覆蓋吃掉——實測背景 prune 進行中寫入 30 筆 salience 95 的承諾,會掉幾筆。
//
// 所以:凡是碰同一個檔案的 append 與 rewrite,一律先拿這把鎖。
// sentinel 檔用 `O_EXCL` 建(跨程序原子),放在 `.runtime/locks/` 而不是人格目錄裡,
// 免得殘留的鎖檔被同步到 Gitea 或被 guard 掃到。
// --------------------------------------------------------------------------- //
export const FILE_LOCK_STALE_MS = 15_000; // 這麼久沒放掉就當持有者死了(避免整個系統卡住)
export const FILE_LOCK_TIMEOUT_MS = 10_000;
const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
/** 同步小睡(這裡不能用 await:呼叫端全是同步 API)。 */
function sleepSync(ms) {
if (ms > 0) Atomics.wait(SLEEP_BUF, 0, 0, ms);
}
export function fileLockPath(file) {
const key = crypto.createHash("md5").update(path.resolve(file)).digest("hex").slice(0, 16);
return path.join(runtimeDir(), "locks", `${path.basename(file)}.${key}.lock`);
}
/**
* 拿著 `file` 的互斥鎖跑 `fn()`,回傳 `fn` 的結果。
*
* 拿不到就短暫重試(含亂數退讓,避免兩邊同步互踩);超過 `FILE_LOCK_STALE_MS`
* 沒被放掉的鎖視為死鎖並搶走。真的等不到就直接做——寧可冒一次競態,
* 也不要因為一個殘留的鎖檔讓人格從此寫不進東西。
*/
export function withFileLock(file, fn, { timeoutMs = FILE_LOCK_TIMEOUT_MS } = {}) {
const lock = fileLockPath(file);
fs.mkdirSync(path.dirname(lock), { recursive: true });
const deadline = Date.now() + timeoutMs;
let fd = null;
while (fd === null) {
try {
fd = fs.openSync(lock, "wx", 0o600);
} catch (err) {
if (err.code !== "EEXIST") throw err;
let stat = null;
try {
stat = fs.statSync(lock);
} catch {
continue; // 剛好被放掉了,再試一次
}
if (Date.now() - stat.mtimeMs > FILE_LOCK_STALE_MS) {
try {
fs.unlinkSync(lock);
} catch {
/* 別人先清掉了 */
}
continue;
}
if (Date.now() > deadline) break; // 等太久:不擋住呼叫端
sleepSync(1 + Math.floor(Math.random() * 5));
}
}
try {
if (fd !== null) fs.writeSync(fd, `${process.pid}\n`);
return fn();
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
try {
fs.unlinkSync(lock);
} catch {
/* ignore */
}
}
}
}
/** 單行 append。拿檔案鎖,才不會被同時進行的整檔覆蓋(prune/trim)吃掉。 */
export function appendJsonl(file, obj) { export function appendJsonl(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, JSON.stringify(obj) + "\n", { encoding: "utf8", mode: 0o600 }); const line = JSON.stringify(obj) + "\n";
withFileLock(file, () => {
fs.appendFileSync(file, line, { encoding: "utf8", mode: 0o600 });
});
}
/**
* 「整檔讀進來 → 過濾 → 覆蓋」的唯一入口:整段在鎖裡面做,
* 所以 `transform` 看到的一定是最新的內容,寫回去也不會蓋掉別人剛 append 的行。
*
* `transform(rows)` 回傳要留下的列;回傳的陣列跟原本一樣長就不寫(省一次 IO)。
*/
export function rewriteJsonl(file, transform) {
return withFileLock(file, () => {
const rows = readJsonl(file);
const kept = transform(rows) ?? rows;
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
}
return { rows, kept };
});
}
/** JSON 檔的 read-modify-write`mutate(data)` 回傳要寫回去的物件。 */
export function updateJson(file, mutate, fallback = null) {
return withFileLock(file, () => {
const next = mutate(readJson(file, fallback));
writeJson(file, next);
return next;
});
} }
export function readJsonl(file, limit = null) { export function readJsonl(file, limit = null) {
@@ -266,6 +376,20 @@ export function loadEmotion(slug) {
return state; return state;
} }
/**
* 情緒的 read-modify-write`mutate(state)` 回傳要寫回去的狀態,整段在檔案鎖裡。
*
* `emotion.json` 是最容易掉更新的一個檔——它每輪都被讀出來、改一點、整份寫回去。
* 實測連續 8 次 `--apply joy=+5`,並行時大約有四成的增量直接消失。
*/
export function updateEmotion(slug, mutate) {
return withFileLock(emotionPath(slug), () => {
const next = mutate(loadEmotion(slug));
writeJson(emotionPath(slug), next);
return next;
});
}
/** 情緒朝 baseline 指數衰減;半衰期依情緒種類不同。 */ /** 情緒朝 baseline 指數衰減;半衰期依情緒種類不同。 */
export function decayEmotion(state, now = new Date()) { export function decayEmotion(state, now = new Date()) {
const last = parseIso(state.updated_at) ?? now; const last = parseIso(state.updated_at) ?? now;
@@ -1143,16 +1267,13 @@ export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) {
/** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */ /** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */
export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) { export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) {
const file = saidPath(slug);
const rows = readJsonl(file);
if (!rows.length) return 0;
const cutoff = Date.now() - hours * 3_600_000; const cutoff = Date.now() - hours * 3_600_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff); return rewriteJsonl(saidPath(slug), (rows) => {
kept = kept.slice(-lines); if (!rows.length) return rows;
if (kept.length !== rows.length) { return rows
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : "")); .filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff)
} .slice(-lines);
return kept.length; }).kept.length;
} }
/** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */ /** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */
@@ -1352,36 +1473,36 @@ function shortTermProtected(row, now = Date.now()) {
* 「還沒經過判斷就把今天清掉」是這裡最不能犯的錯。 * 「還沒經過判斷就把今天清掉」是這裡最不能犯的錯。
*/ */
export function pruneShortTermDetail(slug) { export function pruneShortTermDetail(slug) {
const file = shortTermPath(slug);
const rows = readJsonl(file);
if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 };
const now = Date.now(); const now = Date.now();
const cutoff = now - SHORT_TERM_DAYS * 86_400_000; const cutoff = now - SHORT_TERM_DAYS * 86_400_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? now) >= cutoff); const stats = { by_age: 0, by_capacity: 0 };
const byAge = rows.length - kept.length; // 讀 → 過濾 → 覆蓋整段都在檔案鎖裡:中途 append 進來的新紀錄不會被這次覆蓋吃掉
const { rows, kept } = rewriteJsonl(shortTermPath(slug), (all) => {
if (!all.length) return all;
let live = all.filter((r) => (parseIso(r.ts)?.getTime() ?? now) >= cutoff);
stats.by_age = all.length - live.length;
// R6:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動 // R6:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動
let byCapacity = 0; if (live.length > SHORT_TERM_SOFT_CAP) {
if (kept.length > SHORT_TERM_SOFT_CAP) { const droppable = live
const droppable = kept
.map((row, i) => ({ row, i, protectedRow: shortTermProtected(row, now) })) .map((row, i) => ({ row, i, protectedRow: shortTermProtected(row, now) }))
.filter((x) => !x.protectedRow) .filter((x) => !x.protectedRow)
.sort((a, b) => (Number(a.row.salience || 0) - Number(b.row.salience || 0)) || (a.i - b.i)); .sort((a, b) => (Number(a.row.salience || 0) - Number(b.row.salience || 0)) || (a.i - b.i));
const need = kept.length - SHORT_TERM_SOFT_CAP; const need = live.length - SHORT_TERM_SOFT_CAP;
const drop = new Set(droppable.slice(0, need).map((x) => x.i)); const drop = new Set(droppable.slice(0, need).map((x) => x.i));
byCapacity = drop.size; stats.by_capacity = drop.size;
kept = kept.filter((_, i) => !drop.has(i)); live = live.filter((_, i) => !drop.has(i));
}
const before = kept.length;
kept = kept.slice(-SHORT_TERM_KEEP);
const byHardCap = before - kept.length;
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
} }
const before = live.length;
live = live.slice(-SHORT_TERM_KEEP);
stats.by_capacity += before - live.length;
return live;
});
if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 };
return { return {
kept: kept.length, kept: kept.length,
dropped: rows.length - kept.length, dropped: rows.length - kept.length,
by_age: byAge, by_age: stats.by_age,
by_capacity: byCapacity + byHardCap, by_capacity: stats.by_capacity,
protected: kept.filter((r) => shortTermProtected(r, now)).length, protected: kept.filter((r) => shortTermProtected(r, now)).length,
}; };
} }
@@ -1953,11 +2074,7 @@ export function findRepeat(entries, text, {
} }
function trimJsonl(file, keep) { function trimJsonl(file, keep) {
const rows = readJsonl(file); return rewriteJsonl(file, (rows) => (rows.length <= keep * 1.5 ? rows : rows.slice(-keep))).kept.length;
if (rows.length <= keep * 1.5) return rows.length;
const kept = rows.slice(-keep);
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
return kept.length;
} }
/** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */ /** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */
@@ -3116,8 +3233,7 @@ export function opsBrief(slug) {
/** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */ /** UserPromptSubmit 注入的人格上下文:身分 + 情緒 + 短期記憶 + 相關長期記憶 + 關係。 */
export function turnContext(slug, sessionId, prompt = "") { export function turnContext(slug, sessionId, prompt = "") {
const state = decayEmotion(loadEmotion(slug)); const state = updateEmotion(slug, (s) => decayEmotion(s));
writeJson(emotionPath(slug), state);
const session = loadSession(sessionId); const session = loadSession(sessionId);
const lines = [ const lines = [
"<persona-context>", "<persona-context>",
+27 -15
View File
@@ -931,12 +931,6 @@ commands.emotion = ({ flags }) => {
const session = requireSession(flags); const session = requireSession(flags);
const slug = hostOf(flags, session); const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"])); const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
let state = pl.decayEmotion(pl.loadEmotion(slug));
if (flags.baseline) {
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
if (key in pl.EMOTIONS) state.baseline[key] = pl.clamp(value);
}
}
// 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表) // 讀對方那句話的情緒訊號(不改狀態,只回報;`--read` 可以單獨拿來測詞表)
const readText = str(flags.read); const readText = str(flags.read);
const read = readText ? pl.readUserEmotion(readText) : null; const read = readText ? pl.readUserEmotion(readText) : null;
@@ -964,26 +958,44 @@ commands.emotion = ({ flags }) => {
]); ]);
return; return;
} }
if (flags.apply) { if (flags.apply && role === "guest") die("guestsub agent)不得改寫人格的情緒狀態。");
if (role === "guest") die("guestsub agent)不得改寫人格的情緒狀態。");
const raw = parseDeltas(flags.apply);
// 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣 // 親近度會放大或縮小衝擊:同一句話,從枕邊人跟從生人嘴裡出來不一樣
const rel = pl.relationGain(slug, str(flags.from) || null); const rel = flags.apply ? pl.relationGain(slug, str(flags.from) || null) : null;
const deltas = rel.gain === 1 let deltas = null;
let state;
if (role === "guest") {
// guest 不寫回,所以也不需要鎖
state = pl.decayEmotion(pl.loadEmotion(slug));
} else {
// 讀 → 衰減 → 套用 → 寫回整段在檔案鎖裡:並行的 `--apply` 才不會互相覆蓋
state = pl.updateEmotion(slug, (s) => {
s = pl.decayEmotion(s);
if (flags.baseline) {
for (const [key, value] of Object.entries(parseDeltas(flags.baseline))) {
if (key in pl.EMOTIONS) s.baseline[key] = pl.clamp(value);
}
}
if (flags.apply) {
const raw = parseDeltas(flags.apply);
deltas = rel.gain === 1
? raw ? raw
: Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100])); : Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Math.round(v * rel.gain * 100) / 100]));
state = pl.applyEmotion(state, deltas, str(flags.trigger)); s = pl.applyEmotion(s, deltas, str(flags.trigger));
if (rel.gain !== 1) { if (rel.gain !== 1) {
state.last_trigger = state.last_trigger || {}; s.last_trigger = s.last_trigger || {};
state.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain }; s.last_trigger.relation_gain = { who: rel.name, closeness: rel.closeness, gain: rel.gain };
} }
}
return s;
});
}
if (flags.apply) {
pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) }); pl.recordFelt(slug, { mine: state.last_trigger?.deltas || null, note: str(flags.trigger) });
pl.appendJsonl(pl.journalPath(slug), { pl.appendJsonl(pl.journalPath(slug), {
ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger), ts: pl.nowIso(), kind: "emotion", trigger: str(flags.trigger),
deltas, applied: state.last_trigger?.deltas || {}, levels: state.levels, mood: pl.mood(state), deltas, applied: state.last_trigger?.deltas || {}, levels: state.levels, mood: pl.mood(state),
}); });
} }
if (role !== "guest") pl.writeJson(pl.emotionPath(slug), state);
const m = pl.mood(state); const m = pl.mood(state);
const row = (key) => const row = (key) =>
` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]}`; ` ${pl.EMOTIONS[key].zh} ${key.padEnd(13)}${String(state.levels[key]).padStart(6)}(基線 ${state.baseline[key]}`;
+105 -1
View File
@@ -8,7 +8,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import zlib from "node:zlib"; import zlib from "node:zlib";
import { spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.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)); typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail));
} }
// --------------------------------------------------------------------------- //
// 並行寫入不掉資料
//
// 人格鎖擋的是「兩個人格同時被載入」,不是「兩個程序同時寫同一個檔」——同一個 session
// 的 sub agent 與主程序共用同一把人格鎖(設計如此),所以兩邊真的會同時寫。
// 修之前:背景 prune 進行中寫入 30 筆 salience 95 的承諾會掉 19 筆;
// 並行 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情緒:飽和、單輪預算、走向、偏差稽核"); console.log("\n情緒:飽和、單輪預算、走向、偏差稽核");
let sat = pl.defaultEmotionState(); let sat = pl.defaultEmotionState();
for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌"); for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌");