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:
+153
-37
@@ -170,10 +170,120 @@ export function writeText(file, text) {
|
|||||||
fs.renameSync(tmp, file);
|
fs.renameSync(tmp, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 單行 append(O_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
@@ -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("guest(sub agent)不得改寫人格的情緒狀態。");
|
||||||
if (role === "guest") die("guest(sub 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
@@ -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 的承諾會掉 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情緒:飽和、單輪預算、走向、偏差稽核");
|
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 }, "連灌");
|
||||||
|
|||||||
Reference in New Issue
Block a user